diff --git a/cmd/server/tests/collections_smart_test.go b/cmd/server/tests/collections_smart_test.go new file mode 100644 index 00000000..e944ab2b --- /dev/null +++ b/cmd/server/tests/collections_smart_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "bookhoard/internal/database" +) + +// HTTP helpers ----------------------------------------------------------- + +func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string { + t.Helper() + payload := map[string]interface{}{ + "name": name, + "description": "smart collections test", + "color": "blue", + "icon": "📚", + "collection_type": collectionType, + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed") + + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + return result["id"].(string) +} + +func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + return result +} + +func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response { + t.Helper() + payload := map[string]interface{}{ + "field": field, + "operator": operator, + "value": value, + } + if enabled != nil { + payload["enabled"] = *enabled + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func smartDecodeRuleID(t *testing.T, resp *http.Response) string { + t.Helper() + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + rule := result["rule"].(map[string]interface{}) + return rule["id"].(string) +} + +// Data helpers ----------------------------------------------------------- + +func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) { + t.Helper() + ctx := context.Background() + + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano()) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: uniqueName, + Description: pgtype.Text{String: "smart collections test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err, "should create test library") + libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err) + + t.Cleanup(func() { + _ = setup.DB.DeleteLibrary(ctx, library.ID) + }) + + for title, genre := range titlesByGenre { + media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + Title: title, + Author: pgtype.Text{String: "Smart Rule Author", Valid: true}, + Genre: pgtype.Text{String: genre, Valid: true}, + FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err, "should create test media item") + mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16]) + require.NoError(t, err) + bookIDs = append(bookIDs, mediaUUID.String()) + } + return libUUID.String(), bookIDs +} + +func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool { + t.Helper() + books, _ := coll["books"].([]interface{}) + set := make(map[string]bool, len(books)) + for _, b := range books { + if m, ok := b.(map[string]interface{}); ok { + if title, ok := m["title"].(string); ok { + set[title] = true + } + } + } + return set +} + +// Tests ------------------------------------------------------------------ + +func TestSmartCollections(t *testing.T) { + setup := setupTestServer(t) + adminID := getTestUserID(t, setup.DB) + + t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) { + _, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart SF Book One": "Science Fiction", + "Smart SF Book Two": "Science Fiction", + "Smart FANT Book": "Fantasy", + }) + + // Rule evaluation ignores stored links: a static collection's own + // added item must NOT show up once the collection is smart. + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static") + + // Add one hand-picked book (a Fantasy one that the smart rule + // will not match — so it must vanish from the render). + addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}}) + addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+setup.Token) + addResp, err := http.DefaultClient.Do(addReq) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, addResp.StatusCode) + _ = addResp.Body.Close() + + staticDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, false, staticDetail["is_smart"]) + assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + smartDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type") + assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only") + + memberTitles := smartLibraryTitleSet(t, smartDetail) + assert.True(t, memberTitles["Smart SF Book One"]) + assert.True(t, memberTitles["Smart SF Book Two"]) + assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)") + }) + + t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) { + smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart Diss Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + ruleID := smartDecodeRuleID(t, resp) + + assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"]) + + // Disable: membership drops to zero at once. + falseFlag := false + dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag}) + dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody)) + dReq.Header.Set("Content-Type", "application/json") + dReq.Header.Set("Authorization", "Bearer "+setup.Token) + dResp, err := http.DefaultClient.Do(dReq) + require.NoError(t, err) + require.Equal(t, http.StatusOK, dResp.StatusCode) + _ = dResp.Body.Close() + + assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match") + }) + + t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) { + smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart Guard Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) + addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+setup.Token) + addResp, err := http.DefaultClient.Do(addReq) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, addResp.StatusCode) + _ = addResp.Body.Close() + + removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil) + removeReq.Header.Set("Authorization", "Bearer "+setup.Token) + removeResp, err := http.DefaultClient.Do(removeReq) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, removeResp.StatusCode) + _ = removeResp.Body.Close() + }) + + t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) { + smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart Back Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + ruleID := smartDecodeRuleID(t, resp) + + smartDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, true, smartDetail["is_smart"]) + + delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil) + delReq.Header.Set("Authorization", "Bearer "+setup.Token) + delResp, err := http.DefaultClient.Do(delReq) + require.NoError(t, err) + require.Equal(t, http.StatusOK, delResp.StatusCode) + _ = delResp.Body.Close() + + staticDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static") + + // Membership mutations work again on the reverted static. + addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) + addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+setup.Token) + addResp, err := http.DefaultClient.Do(addReq) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, addResp.StatusCode) + _ = addResp.Body.Close() + }) + + t.Run("RuleValidationAndSystemCollections", func(t *testing.T) { + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static") + + resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + _ = resp.Body.Close() + + resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + // System collections may never carry rules. + colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}) + require.NoError(t, err) + var systemID string + for _, c := range colls { + if c.IsSystemCollection.Bool { + systemID = uuid.UUID(c.ID.Bytes).String() + break + } + } + require.NotEmpty(t, systemID, "admin user should have system collections") + + sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil) + require.Equal(t, http.StatusBadRequest, sysResp.StatusCode) + _ = sysResp.Body.Close() + }) + + t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) { + _, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart List Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + // Default list (the Android app's read): smart collections hidden. + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + defaultResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body) + var defaultData struct { + Collections []struct { + ID string `json:"id"` + } `json:"collections"` + } + require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData)) + for _, c := range defaultData.Collections { + assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)") + } + + // Opt-in listing includes them with LIVE counts. + scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil) + scopeReq.Header.Set("Authorization", "Bearer "+setup.Token) + scopeResp, err := http.DefaultClient.Do(scopeReq) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body) + var scopeData struct { + Collections []struct { + ID string `json:"id"` + BookCount int `json:"book_count"` + IsSmart bool `json:"is_smart"` + } `json:"collections"` + } + require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData)) + found := false + for _, c := range scopeData.Collections { + if c.ID == collID { + found = true + assert.True(t, c.IsSmart) + assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live") + } + } + assert.True(t, found, "include_auto=true must surface smart collections") + }) +} diff --git a/internal/database/querier.go b/internal/database/querier.go index a84280c0..730ffc8a 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -42,6 +42,10 @@ type Querier interface { ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error CountAdmins(ctx context.Context) (int64, error) CountArchivedMediaItems(ctx context.Context) (int64, error) + // Live item count for a STATIC collection (rule-free cards on the + // collections page); same live filter as GetCollectionItems, plus the + // future-hybrid excluded exclusion and an optional library scope. + CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) // Count unlinked books for a device CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) @@ -358,6 +362,12 @@ type Querier interface { GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) + // Smart ("dynamic") collections: rules are evaluated LIVE at render, so + // membership comes from the candidate book set instead of collection_items. + // The set is scoped to the requesting user's visible libraries (family + // lens enforced at the source) and live items only — the same filters the + // static collection paths apply. + GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) // Check if book is in collection @@ -474,6 +484,9 @@ type Querier interface { TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error // Update collection UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) + // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write + // of the JSONB without touching metadata stale clients did not send). + UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index cbe71880..53d82863 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -293,6 +293,31 @@ func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) { return count, err } +const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one +SELECT COUNT(*)::int FROM collection_items ci +JOIN media_items mi ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND NOT COALESCE(ci.excluded, false) + AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +` + +type CountCollectionLiveItemsParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` +} + +// Live item count for a STATIC collection (rule-free cards on the +// collections page); same live filter as GetCollectionItems, plus the +// future-hybrid excluded exclusion and an optional library scope. +func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) { + row := q.db.QueryRow(ctx, CountCollectionLiveItems, arg.CollectionID, arg.LibraryID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one SELECT COUNT(*) as count FROM unlinked_books @@ -8512,6 +8537,110 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp return items, nil } +const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many +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.imported_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.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi +WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) + AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[])) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +ORDER BY mi.title ASC +` + +type GetVisibleLiveMediaForRulesParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` +} + +// Smart ("dynamic") collections: rules are evaluated LIVE at render, so +// membership comes from the candidate book set instead of collection_items. +// The set is scoped to the requesting user's visible libraries (family +// lens enforced at the source) and live items only — the same filters the +// static collection paths apply. +func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) { + rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MediaItems{} + for rows.Next() { + var i MediaItems + if err := rows.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one SELECT EXISTS( SELECT 1 FROM sync_conflicts @@ -12383,6 +12512,39 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara return i, err } +const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one +UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at +` + +type UpdateCollectionRulesJSONParams struct { + ID pgtype.UUID `db:"id" json:"id"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` +} + +// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write +// of the JSONB without touching metadata stale clients did not send). +func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) { + row := q.db.QueryRow(ctx, UpdateCollectionRulesJSON, arg.ID, arg.AutoAssignRules) + var i Collections + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences SET hidden_collections = $2, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index eec78ccc..5ed4ffa6 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2190,6 +2190,11 @@ SET WHERE id = $1 RETURNING *; +-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write +-- of the JSONB without touching metadata stale clients did not send). +-- name: UpdateCollectionRulesJSON :one +UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *; + -- Delete collection -- name: DeleteCollection :exec DELETE FROM collections WHERE id = $1; @@ -2628,6 +2633,31 @@ WHERE ci.collection_id = sqlc.narg('collection_id') ORDER BY ci.added_at DESC LIMIT sqlc.narg('limit'); +-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so +-- membership comes from the candidate book set instead of collection_items. +-- The set is scoped to the requesting user's visible libraries (family +-- lens enforced at the source) and live items only — the same filters the +-- static collection paths apply. +-- name: GetVisibleLiveMediaForRules :many +SELECT mi.* FROM media_items mi +WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +ORDER BY mi.title ASC; + +-- Live item count for a STATIC collection (rule-free cards on the +-- collections page); same live filter as GetCollectionItems, plus the +-- future-hybrid excluded exclusion and an optional library scope. +-- name: CountCollectionLiveItems :one +SELECT COUNT(*)::int FROM collection_items ci +JOIN media_items mi ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND NOT COALESCE(ci.excluded, false) + AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0; + -- name: GetLibraryItems :many SELECT mi.* FROM media_items mi WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 47aa892a..2c776b26 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -40,6 +40,7 @@ type CreateCollectionRequest struct { Description string `form:"description" json:"description"` Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` + CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based) AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"` ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` } @@ -118,6 +119,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { } bookCount := int32(0) + isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0 response := map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), @@ -125,6 +127,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { "description": textToString(collection.Description), "color": textToString(collection.Color), "icon": textToString(collection.Icon), + "collection_type": map[bool]string{true: "smart", false: "static"}[isSmart], "auto_assign_rules": collection.AutoAssignRules, "view_settings": collection.ViewSettings, "book_count": bookCount, @@ -133,7 +136,13 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { // Add redirect header for HTMX requests if c.Request().Header.Get("HX-Request") == "true" { - c.Response().Header().Set("HX-Redirect", "/collections") + // A smart collection starts with an empty ruleset; the builder is + // where it becomes real. Static collections head back to the grid. + if isSmart { + c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules") + } else { + c.Response().Header().Set("HX-Redirect", "/collections") + } } return c.JSON(http.StatusCreated, response) @@ -158,31 +167,46 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { AutoAssignRules json.RawMessage `json:"auto_assign_rules"` CreatedAt string `json:"created_at"` BookCount int `json:"book_count"` + IsSmart bool `json:"is_smart"` } response := make([]CollectionResponse, 0, len(collections)) for _, col := range collections { - if !includeAuto && len(col.AutoAssignRules) > 0 { + isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules) + if !includeAuto && isSmart { + // Legacy default for API clients (the Android app pre-dates + // smart collections): rule-driven collections stay opt-in so + // their membership could never be stale for old readers. continue } bookCount := 0 + var libUUID pgtype.UUID if libraryID != "" { - libUUID, libErr := uuid.Parse(libraryID) - if libErr == nil { - items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{ - CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, - Limit: pgtype.Int4{Int32: 10000, Valid: true}, + if libParsed, libErr := uuid.Parse(libraryID); libErr == nil { + libUUID = pgtype.UUID{Bytes: libParsed, Valid: true} + } + } + if isSmart { + rules := h.collectionService.ParseRules(col.AutoAssignRules) + visibleIDs, visErr := h.userVisibleLibraryIDs(c) + if visErr == nil { + matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ + LibraryID: libUUID, + VisibleIDs: visibleIDs, }) - if countErr == nil { - for _, item := range items { - if !item.Excluded.Valid || !item.Excluded.Bool { - bookCount++ - } - } + if evalErr == nil { + bookCount = len(matches) } } + } else { + count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{ + CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, + LibraryID: libUUID, + }) + if countErr == nil { + bookCount = int(count) + } } response = append(response, CollectionResponse{ @@ -194,6 +218,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { AutoAssignRules: col.AutoAssignRules, CreatedAt: col.CreatedAt.Time.String(), BookCount: bookCount, + IsSmart: isSmart, }) } @@ -236,7 +261,31 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { libUUID = pgtype.UUID{Bytes: parsed, Valid: true} } - if collection.QueryType.Valid && collection.QueryType.String != "" { + isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules) + if isSmart { + rules := h.collectionService.ParseRules(collection.AutoAssignRules) + visibleIDs, err := h.userVisibleLibraryIDs(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ + LibraryID: libUUID, + VisibleIDs: visibleIDs, + }) + if evalErr != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()}) + } + bookList = make([]BookInfo, 0, len(matches)) + for _, item := range matches { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookList = append(bookList, BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: textToString(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + }) + } + } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) dashboardSvc := services.NewDashboardService(h.db) @@ -323,6 +372,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { "view_settings": viewSettings, "books": bookList, "book_count": len(bookList), + "is_smart": isSmart, "created_at": collection.CreatedAt.Time.String(), }) } @@ -400,6 +450,10 @@ func (h *CollectionHandler) AddBooks(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil { + return guardErr + } + user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) @@ -447,6 +501,10 @@ func (h *CollectionHandler) RemoveBook(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil { + return guardErr + } + bookID, err := uuid.Parse(c.Param("bookId")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) @@ -487,6 +545,10 @@ func (h *CollectionHandler) BulkRemoveBooks(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil { + return guardErr + } + user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) @@ -727,6 +789,170 @@ func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database. return h.collectionService.GetUserCollections(c.Request().Context(), userUUID) } +// userVisibleLibraryIDs loads the requester's visible-library set (the +// family lens). Smart-collection evaluation funnels every book candidate +// through it so hidden libraries contribute nothing. +func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) { + user, ok := c.Get("user").(database.Users) + if !ok { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") + } + rows, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) + if err != nil { + return nil, fmt.Errorf("failed to check library access: %w", err) + } + ids := make([]pgtype.UUID, 0, len(rows)) + for _, lib := range rows { + if lib.ID.Valid { + ids = append(ids, lib.ID) + } + } + return ids, nil +} + +// UserVisibleLibraryIDs is the exported entry point for the router's +// frontend page handlers, which resolve the visible-library set before +// evaluating smart-collection rule matches. +func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) { + return h.userVisibleLibraryIDs(c) +} + +// CollectionService returns the underlying service so the router's +// frontend handlers can share the exact same evaluation the API uses. +func (h *CollectionHandler) CollectionService() *services.CollectionService { + return h.collectionService +} + +// rejectSmartMutation returns a 400 for smart collections: membership is +// derived live from rules, so stored link mutations have nothing to act +// on. v1 has no hybrid mode — add/remove with both rules and hand-picked +// books is deliberately unsupported (collection_items.excluded awaits it). +func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError { + collection, err := h.GetCollectionData(c, collectionID) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, "collection not found") + } + if h.collectionService.IsSmartCollection(collection.AutoAssignRules) { + return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand") + } + return nil +} + +type AddRuleRequest struct { + Field string `json:"field" validate:"required"` + Operator string `json:"operator" validate:"required"` + Value string `json:"value" validate:"required"` + Enabled *bool `json:"enabled"` + Priority int `json:"priority"` +} + +// AddRule handles POST /api/collections/:id/rules — appends one rule to +// the ruleset. The first rule converts a static collection to smart. +func (h *CollectionHandler) AddRule(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + collection, err := h.GetCollectionData(c, collectionID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) + } + if collection.IsSystemCollection.Bool { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"}) + } + + var req AddRuleRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{ + Field: req.Field, + Operator: req.Operator, + Value: req.Value, + Enabled: req.Enabled, + Priority: req.Priority, + }) + if err != nil { + var ruleErr *services.RuleValidationError + if errors.As(err, &ruleErr) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "collection_id": uuid.UUID(updated.ID.Bytes).String(), + "rule": rule, + "is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules), + }) +} + +// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently +// the enabled toggle; the payload may only carry {"enabled": bool}. +func (h *CollectionHandler) UpdateRule(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + ruleID := c.Param("ruleId") + if ruleID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"}) + } + + var req struct { + Enabled *bool `json:"enabled" validate:"required"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled) + if err != nil { + if strings.Contains(err.Error(), "rule not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "collection_id": uuid.UUID(updated.ID.Bytes).String(), + "is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules), + }) +} + +// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId. +func (h *CollectionHandler) RemoveRule(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + ruleID := c.Param("ruleId") + if ruleID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"}) + } + + updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID) + if err != nil { + if strings.Contains(err.Error(), "rule not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "collection_id": uuid.UUID(updated.ID.Bytes).String(), + "is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules), + }) +} + type TestRulesRequest struct { Rules []map[string]interface{} `json:"rules" validate:"required"` } @@ -748,16 +974,32 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ - Limit: 1000, - Offset: 0, + // Candidates mirror what smart collections actually evaluate against: + // the requester's visible libraries and live items only — the preview + // must not promise matches the live render will never produce. + visibleIDs, err := h.userVisibleLibraryIDs(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + var visibleArg []pgtype.UUID + if len(visibleIDs) > 0 { + visibleArg = visibleIDs + } + mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{ + LibraryID: pgtype.UUID{}, + VisibleLibraryIds: visibleArg, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"}) } + listRows := make([]database.ListMediaItemsRow, len(mediaItems)) + for i, item := range mediaItems { + listRows[i] = mediaItemsToListMediaItemsRow(item) + } + var matches []BookMatch - for _, item := range mediaItems { + for _, item := range listRows { matchReason := h.checkRulesAgainstBook(item, req.Rules) if matchReason != "" { author := "" @@ -886,6 +1128,16 @@ func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"}) } + // Smart collections are rejected up front per operation so the + // caller sees a clean, actionable error instead of silent no-ops. + for _, op := range req.Operations { + if colUUID, err := uuid.Parse(op.CollectionID); err == nil { + if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil { + return guardErr + } + } + } + results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 diff --git a/internal/router/collections.go b/internal/router/collections.go index 03490e28..ab15a948 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -22,6 +22,12 @@ func registerCollectionsRoutes(cfg *Config) { collections.POST("/test-rules", cfg.CollectionHandler.TestRules) collections.POST("/preview", cfg.CollectionHandler.PreviewCollection) + // Smart collection rule CRUD (the rules builder persists through + // these; type follows rules presence — first rule = smart). + collections.POST("/:id/rules", cfg.CollectionHandler.AddRule) + collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule) + collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule) + // Device shelf mapping routes deviceCollections := protected.Group("/devices/:id/collections") deviceCollections.GET("", cfg.CollectionHandler.GetDeviceMappings) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 319f5cc2..2e7f317a 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -19,8 +19,10 @@ import ( "bookhoard/internal/utils" "bookhoard/internal/version" "bookhoard/templates" + "errors" "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" @@ -496,14 +498,56 @@ func registerFrontendRoutes(cfg *Config) { errorMsg = "Error loading collections" } + // Live per-card counts: smart collections are rule-matched at + // render against the requesting user's visible libraries; static + // ones count their stored links. Library filter applies when a + // specific library is selected. + libRes := resolveLibrary(c, cfg, user.ID) + var libScope pgtype.UUID + if !libRes.IsAll { + if parsed, parseErr := uuid.Parse(libRes.LibraryID); parseErr == nil { + libScope = pgtype.UUID{Bytes: parsed, Valid: true} + } + } + visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) + if visErr != nil { + log.Printf("UserVisibleLibraryIDs failed: %v", visErr) + visibleIDs = nil + } + colData := make([]templates.CollectionData, len(collections)) + colService := cfg.CollectionHandler.CollectionService() for i, col := range collections { + isSmart := colService.IsSmartCollection(col.AutoAssignRules) + bookCount := 0 + if isSmart { + matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), + colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{ + LibraryID: libScope, + VisibleIDs: visibleIDs, + }) + if evalErr == nil { + bookCount = len(matches) + } + } else { + count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{ + CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, + LibraryID: libScope, + }) + if countErr == nil { + bookCount = int(count) + } + } + colData[i] = templates.CollectionData{ ID: uuid.UUID(col.ID.Bytes).String(), Name: col.Name, Description: getText(col.Description), Color: getText(col.Color), Icon: getText(col.Icon), + IsSystem: col.IsSystemCollection.Bool, + IsSmart: isSmart, + BookCount: bookCount, } } @@ -572,6 +616,47 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) + // Smart collection rules builder page (the smart editor). User + // collections only: system sections keep their hardwired queries. + frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + collectionID := c.Param("id") + collUUID, err := uuid.Parse(collectionID) + if err != nil { + return renderErrorPage(c, "Invalid collection ID", "invalid_id") + } + collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return renderErrorPage(c, "Collection not found", "not_found") + } + return renderErrorPage(c, "Error loading collection", "collection_load_error") + } + if collection.IsSystemCollection.Bool { + return renderErrorPage(c, "System collections cannot have rules", "not_allowed") + } + + colData := templates.CollectionData{ + ID: collectionID, + Name: collection.Name, + Description: getText(collection.Description), + Color: getText(collection.Color), + Icon: getText(collection.Icon), + IsSystem: collection.IsSystemCollection.Bool, + IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules), + } + + var buf bytes.Buffer + err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Collection detail page (works for both system and user collections) frontendProtected.GET("/collections/:id", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) @@ -599,7 +684,44 @@ func registerFrontendRoutes(cfg *Config) { libRes := resolveLibrary(c, cfg, user.ID) libraryID := libRes.LibraryID - if collection.QueryType.Valid && collection.QueryType.String != "" { + var libScope pgtype.UUID + if !libRes.IsAll { + if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil { + libScope = pgtype.UUID{Bytes: parsed, Valid: true} + } + } + colService := cfg.CollectionHandler.CollectionService() + isSmart := colService.IsSmartCollection(collection.AutoAssignRules) + + visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) + if visErr != nil { + log.Printf("UserVisibleLibraryIDs failed: %v", visErr) + visibleIDs = nil + } + + if isSmart { + // Smart (dynamic) collection: membership is evaluated live — + // visible libraries, live items only, any enabled rule matches. + matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), + colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ + LibraryID: libScope, + VisibleIDs: visibleIDs, + }) + if evalErr != nil { + return renderErrorPage(c, "Error loading books", "books_load_error") + } + bookCards := make([]handlers.BookInfo, 0, len(matches)) + for _, item := range matches { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards = append(bookCards, handlers.BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: getText(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + }) + } + books = bookCards + } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { dashboardSvc := services.NewDashboardService(cfg.Queries) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{}) if secErr != nil { @@ -684,6 +806,8 @@ func registerFrontendRoutes(cfg *Config) { Color: collection.Color.String, Icon: collection.Icon.String, IsSystem: collection.IsSystemCollection.Bool, + IsSmart: isSmart, + BookCount: len(books), } var buf bytes.Buffer diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index f7252dfe..e9d0dbcb 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -24,8 +24,30 @@ type Rule struct { Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than" Value string `json:"value"` Priority int `json:"priority"` // 1-10, higher values take precedence + // Enabled gates the rule in the LIVE evaluation used by smart + // collections; absent (JSON omitempty) means enabled — rules written + // before the field existed keep matching. + Enabled *bool `json:"enabled,omitempty"` } +// Known rule fields and operators — the rules builder UI and the +// persisted JSONB documents must agree with this vocabulary. +var ruleFields = map[string]bool{ + "genre": true, "series": true, "author": true, "language": true, + "publisher": true, "copyright_year": true, "tags": true, +} + +var ruleOperators = map[string]bool{ + "equals": true, "not_equals": true, "contains": true, "not_contains": true, + "starts_with": true, "ends_with": true, "greater_than": true, "less_than": true, +} + +// RuleValidationError reports a rule payload the evaluator could never +// honor (unknown field/operator, empty value). +type RuleValidationError struct{ Msg string } + +func (e *RuleValidationError) Error() string { return e.Msg } + // RuleEvaluation represents the result of evaluating a rule type RuleEvaluation struct { RuleID string `json:"rule_id"` @@ -244,6 +266,248 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID return collections, nil } +// ParseRules decodes a collection's auto_assign_rules JSONB into typed +// rules. "null", empty, and malformed docs mean "no rules". +func (s *CollectionService) ParseRules(raw []byte) []Rule { + if len(raw) == 0 { + return nil + } + var rules []Rule + if err := json.Unmarshal(raw, &rules); err != nil { + return nil + } + return rules +} + +// IsSmartCollection derives the collection type: a non-empty ruleset +// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC +// (stored collection_items links). +func (s *CollectionService) IsSmartCollection(raw []byte) bool { + return len(s.ParseRules(raw)) > 0 +} + +// EnabledRules filters a ruleset down to the rules that participate in +// live evaluation (absent enabled = enabled for backward compatibility). +func (s *CollectionService) EnabledRules(rules []Rule) []Rule { + enabled := make([]Rule, 0, len(rules)) + for _, rule := range rules { + if rule.Enabled == nil || *rule.Enabled { + enabled = append(enabled, rule) + } + } + return enabled +} + +// validateRule checks a rule payload the evaluator will have to honor. +func (s *CollectionService) validateRule(rule Rule) error { + if !ruleFields[rule.Field] { + return &RuleValidationError{Msg: "unknown rule field: " + rule.Field} + } + if !ruleOperators[rule.Operator] { + return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator} + } + if strings.TrimSpace(rule.Value) == "" { + return &RuleValidationError{Msg: "rule value is required"} + } + if rule.Priority < 0 || rule.Priority > 10 { + return &RuleValidationError{Msg: "priority must be between 0 and 10"} + } + return nil +} + +// AddRule appends a rule to the collection's ruleset, generating an id +// when the caller did not supply one. Adding the first rule CONVERTS a +// static collection to smart (type is derived from rules presence). +func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) { + if err := s.validateRule(rule); err != nil { + return database.Collections{}, Rule{}, err + } + + collection, err := s.GetCollection(ctx, collectionID) + if err != nil { + return database.Collections{}, Rule{}, err + } + + rules := s.ParseRules(collection.AutoAssignRules) + if rule.ID == "" { + rule.ID = uuid.NewString() + } + rules = append(rules, rule) + + rulesJSON, marshalErr := json.Marshal(rules) + if marshalErr != nil { + return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) + } + + updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + AutoAssignRules: rulesJSON, + }) + if err != nil { + return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err) + } + return updated, rule, nil +} + +// SetRuleEnabled flips a single rule's enabled flag in place. +func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) { + collection, err := s.GetCollection(ctx, collectionID) + if err != nil { + return database.Collections{}, err + } + rules := s.ParseRules(collection.AutoAssignRules) + found := false + for i := range rules { + if rules[i].ID == ruleID { + rules[i].Enabled = &enabled + found = true + break + } + } + if !found { + return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID) + } + + rulesJSON, marshalErr := json.Marshal(rules) + if marshalErr != nil { + return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) + } + + updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + AutoAssignRules: rulesJSON, + }) + if err != nil { + return database.Collections{}, fmt.Errorf("failed to save rules: %w", err) + } + return updated, nil +} + +// RemoveRule deletes a single rule from the ruleset. Removing the LAST +// rule converts the collection back to static (type is derived). +func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) { + collection, err := s.GetCollection(ctx, collectionID) + if err != nil { + return database.Collections{}, err + } + rules := s.ParseRules(collection.AutoAssignRules) + kept := make([]Rule, 0, len(rules)) + found := false + for _, rule := range rules { + if rule.ID == ruleID { + found = true + continue + } + kept = append(kept, rule) + } + if !found { + return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID) + } + + rulesJSON, marshalErr := json.Marshal(kept) + if marshalErr != nil { + return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) + } + + updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + AutoAssignRules: rulesJSON, + }) + if err != nil { + return database.Collections{}, fmt.Errorf("failed to save rules: %w", err) + } + return updated, nil +} + +// SmartEvalOptions scopes a smart collection's live evaluation. +type SmartEvalOptions struct { + // LibraryID, when valid, restricts matching to one library (the + // detail page's library switcher). + LibraryID pgtype.UUID + // VisibleIDs is the requesting user's visible-library set (family + // lens). Empty means no visible-library filter. + VisibleIDs []pgtype.UUID +} + +// EvaluateSmartCollection resolves a smart collection's membership LIVE: +// every visible live book matched by at least one enabled rule is in. +// No stored links are consulted — "truly dynamic," always current. +func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) { + enabled := s.EnabledRules(rules) + if len(enabled) == 0 { + return []database.MediaItems{}, nil + } + + var visibleArg []pgtype.UUID + if len(opts.VisibleIDs) > 0 { + visibleArg = opts.VisibleIDs + } + items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{ + LibraryID: opts.LibraryID, + VisibleLibraryIds: visibleArg, + }) + if err != nil { + return nil, fmt.Errorf("failed to load candidate books: %w", err) + } + + matches := make([]database.MediaItems, 0, len(items)) + for _, item := range items { + if s.mediaItemMatchesAnyRule(item, enabled) { + matches = append(matches, item) + } + } + return matches, nil +} + +func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool { + for _, rule := range rules { + if s.mediaItemMatchesRule(item, rule) { + return true + } + } + return false +} + +func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool { + if rule.Enabled != nil && !*rule.Enabled { + return false + } + + var fieldValue string + switch rule.Field { + case "genre": + if item.Genre.Valid { + fieldValue = item.Genre.String + } + case "series": + if item.Series.Valid { + fieldValue = item.Series.String + } + case "author": + if item.Author.Valid { + fieldValue = item.Author.String + } + case "language": + if item.Language.Valid { + fieldValue = item.Language.String + } + case "publisher": + if item.Publisher.Valid { + fieldValue = item.Publisher.String + } + case "copyright_year": + if item.CopyrightYear.Valid { + fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32) + } + case "tags": + if len(item.Tags) > 0 { + fieldValue = strings.Join(item.Tags, ", ") + } + } + + return s.evaluateRule(fieldValue, rule.Operator, rule.Value) +} + // EvaluateRules evaluates auto-assign rules for a media item func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, rules []Rule) []RuleEvaluation { var evaluations []RuleEvaluation @@ -260,6 +524,11 @@ func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, } for _, rule := range sortedRules { + // Disabled rules never match (kept for context in the UI only). + if rule.Enabled != nil && !*rule.Enabled { + evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false}) + continue + } eval := RuleEvaluation{ RuleID: rule.ID, Matches: false, diff --git a/templates/collection_modal.templ b/templates/collection_modal.templ index 39fd2c16..f1f25f7a 100644 --- a/templates/collection_modal.templ +++ b/templates/collection_modal.templ @@ -83,6 +83,25 @@ templ CollectionModal(collection CollectionData) { hx-post="/api/collections" hx-redirect="/collections" > +
+ +
+ + +
+
- + @Header(user, "/collections")
@@ -23,19 +23,23 @@ templ CollectionRules(user User, collection CollectionData) { { collection.Icon }

{ collection.Name }

-

Auto-Assign Rules

+

+ ⚡ Smart + Rules evaluated live — the collection is always current, no refresh needed +

-

Existing Rules

+

Rules

+

A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.

@Icon("filter", "h-6 w-6")

No Rules Yet

-

Create auto-assign rules to automatically add books to this collection

+

Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.

diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index 101ced7f..34f3b17f 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -42,7 +42,20 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -50,7 +63,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Back to Collection
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Auto-Assign Rules

Existing Rules

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

⚡ Smart Rules evaluated live — the collection is always current, no refresh needed

Rules

A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -92,20 +105,20 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

No Rules Yet

Create auto-assign rules to automatically add books to this collection

Create New Rule

No Rules Yet

Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.

Create New Rule

Uncheck to disable without deleting

Rule Test Results

Books that would be added by this rule:

Rule Examples

1

Add all Science Fiction books

Field: genre
Operator: equals
Value: Science Fiction
2

Add books from a specific series

Field: series
Operator: starts with
Value: Harry Potter
3

Add books published in a year range

Field: copyright_year
Operator: greater than
Value: 2020
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Add Rule

Rule Test Results

Books that would be added by this rule:

Rule Examples

1

Add all Science Fiction books

Field: genre
Operator: equals
Value: Science Fiction
2

Add books from a specific series

Field: series
Operator: starts with
Value: Harry Potter
3

Add books published in a year range

Field: copyright_year
Operator: greater than
Value: 2020
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/collections.templ b/templates/collections.templ index c7382ad1..1650fa7b 100644 --- a/templates/collections.templ +++ b/templates/collections.templ @@ -1,6 +1,18 @@ package templates -import "bookhoard/internal/handlers" +import ( + "fmt" + + "bookhoard/internal/handlers" +) + +// pluralizeBooks renders "book"/"books" for count displays. +func pluralizeBooks(n int) string { + if n == 1 { + return "book" + } + return "books" +} templ Collection(user User, collections []CollectionData, errorMessage string) { @@ -75,7 +87,10 @@ templ Collection(user User, collections []CollectionData, errorMessage string) { >
{ col.Icon }
-
+
+ if col.IsSmart { + ⚡ Smart + }
} @@ -131,8 +152,16 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
{ collection.Icon }
-

{ collection.Name }

+

+ { collection.Name } + if collection.IsSmart { + ⚡ Smart + } +

{ collection.Description }

+ if collection.IsSmart { +

Membership is rules-matched live — always current, nothing to refresh.

+ }
@@ -152,22 +181,31 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo /> if !collection.IsSystem { - - + @Icon("filter", "h-4 w-4") + Edit Rules + + if !collection.IsSmart { + + + } } @@ -178,7 +216,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo for _, book := range books {
- if !collection.IsSystem { + if !collection.IsSystem && !collection.IsSmart {
- if !collection.IsSystem { + if !collection.IsSystem && !collection.IsSmart {
- if !collection.IsSystem { + if !collection.IsSystem && !collection.IsSmart {
diff --git a/templates/collections_templ.go b/templates/collections_templ.go index ab91ab80..7c323086 100644 --- a/templates/collections_templ.go +++ b/templates/collections_templ.go @@ -8,7 +8,19 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "bookhoard/internal/handlers" +import ( + "fmt" + + "bookhoard/internal/handlers" +) + +// pluralizeBooks renders "book"/"books" for count displays. +func pluralizeBooks(n int) string { + if n == 1 { + return "book" + } + return "books" +} func Collection(user User, collections []CollectionData, errorMessage string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { @@ -97,7 +109,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 71, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 83, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) if templ_7745c5c3_Err != nil { @@ -110,7 +122,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 74, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 86, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -123,26 +135,36 @@ func Collection(user User, collections []CollectionData, errorMessage string) te var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 77, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 99, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 114, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 100, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 115, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d %s", col.BookCount, pluralizeBooks(col.BookCount))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 103} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if col.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "· rules-matched live") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -210,7 +255,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -234,25 +279,25 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 138, Col: 27} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -264,7 +309,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -272,227 +317,97 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Back to Collections
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 132, Col: 166} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Back to Collections
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 134, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 153, Col: 166} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 135, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 156, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Books in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "⚡ Smart") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 161, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

Membership is rules-matched live — always current, nothing to refresh.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Books in this Collection

0\" x-text=\"selectedBooks.length + ' selected'\" class=\"badge\" style=\"display: none; background-color: var(--accent); color: var(--bg-primary);\">
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if len(books) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
No books in this collection yet.
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - for _, book := range books { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 196, Col: 23} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.Author != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

by ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 201, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if !collection.IsSystem { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Remove Selected ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(books) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
No books in this collection yet.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + for _, book := range books { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 234, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.Author != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

by ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 239, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !collection.IsSystem && !collection.IsSmart { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if !collection.IsSystem { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

Add Books to Collection

books selected
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "Clear
books selected
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "

Remove from Collection

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" data-library-id=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 424, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" data-is-system=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 425, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-is-smart=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSmart) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 426, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" style=\"display: none;\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -607,119 +712,119 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, book := range books { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if book.CoverImagePath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\" onerror=\"this.src='/static/placeholder-book.svg'\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" class=\"w-full aspect-[2/3] object-cover\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
✓

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"absolute top-1 right-1 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none\" style=\"background-color: var(--accent); color: var(--bg-primary);\">✓

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 418, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 457, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 4a6eca7c..1cd83af1 100644 --- a/templates/types.go +++ b/templates/types.go @@ -31,6 +31,12 @@ type CollectionData struct { Color string Icon string IsSystem bool + // IsSmart marks rule-based ("dynamic") collections: membership is + // evaluated live at render, not stored in collection_items. + IsSmart bool + // BookCount is the LIVE count for the card (rule matches for smart + // collections, stored links for static ones). + BookCount int } type LibraryData struct { diff --git a/web/src/collections.ts b/web/src/collections.ts index 12dfecfb..97ee34c9 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -2,7 +2,6 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { createWebSocket } from "./websocket"; import { - getCurrentLibraryId, initLibrarySwitcher, switchWithTransition, } from "./library-switcher"; @@ -54,81 +53,15 @@ function initializeCollectionWebSocket(): void { }); } -async function loadCollections(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch("/api/collections", { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const data = await response.json(); - renderCollectionsGrid(data.collections || []); - } - } catch (error) { - console.error("Failed to load collections:", error); - } -} - -function renderCollectionsGrid(collections: CollectionData[]): void { - const container = document.getElementById("collections-list"); - if (!container) return; - - if (collections.length === 0) { - container.innerHTML = ` -
-
📚
-

No Collections Yet

-

Create collections to organize your books

- -
`; - return; - } - - const libraryId = getCurrentLibraryId(); - const libraryParam = libraryId ? `?library_id=${libraryId}` : ""; - - container.innerHTML = collections - .map( - (col) => ` -
-
-
-
${col.icon}
-
- - -
-
-

${col.name}

-

${col.description}

- ${col.book_count > 0 ? `

${col.book_count} books

` : ""} -
-
- `, - ) - .join(""); - - initColorSelection(); - Alpine.initTree(container); -} - function renderCollectionBooks(books: BookInfo[]): void { const container = document.getElementById("books-container"); if (!container) return; const dataEl = document.getElementById("collection-data"); const isSystem = dataEl?.dataset.isSystem === "true"; + // Smart collections are rule-managed: no hand-pick actions either way. + const canEditMembership = + !isSystem && dataEl?.dataset.isSmart !== "true"; if (books.length === 0) { container.innerHTML = `
No books in this collection yet.
`; @@ -140,14 +73,14 @@ function renderCollectionBooks(books: BookInfo[]): void { (book) => `
- ${isSystem ? "" : ` + ${canEditMembership ? `
-
`} +
` : ""}
- ${isSystem ? "" : ` + ${canEditMembership ? `
-
`} + ` : ""} `, ) @@ -658,7 +591,6 @@ export { initializeCollectionWebSocket, initCollectionsPage, loadCollectionRules, - loadCollections, navigateToCollection, populateIconGrid, selectColor, @@ -680,7 +612,6 @@ Alpine.data("collections", () => ({ initializeCollectionWebSocket, initCollectionsPage, loadCollectionRules, - loadCollections, navigateToCollection, populateIconGrid, selectColor, diff --git a/web/static/style.css b/web/static/style.css index a1ab6034..6f179865 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{--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-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{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}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-catppuccin-latte{--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)}: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}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.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;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)}.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=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}.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-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-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}.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}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.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-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}.-mt-1{margin-top:-.25rem}.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-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.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-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.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}.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-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-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-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{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-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}.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-50{--tw-bg-opacity:0.5}.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)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.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-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-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-6xl{font-size:3.75rem;line-height:1}.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}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.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}.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)}.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)}#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: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\:bg-brand{background-color:var(--accent)}.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\: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\:p-6{padding:1.5rem}.md\:p-8{padding:2rem}.md\:text-4xl{font-size:2.25rem;line-height:2.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{--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-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{--bg-primary:#eff1f5;--bg-secondary:#fff;--text-primary:#4c4f69;--text-secondary:#6c6f85;--accent:#d20f39;--border:#dcdfe6}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-catppuccin-latte{--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)}: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}@media (min-width:1024px){body{padding-left:var(--sidebar-w)}.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;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)}.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=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}.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-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-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}.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}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.end-1{inset-inline-end:.25rem}.left-0{left:0}.left-0\.5{left:.125rem}.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-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}.-mt-1{margin-top:-.25rem}.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-7{height:1.75rem}.h-72{height:18rem}.h-8{height:2rem}.h-80{height:20rem}.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-96{max-height:24rem}.max-h-\[20rem\]{max-height:20rem}.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}.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-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-2\.5{--tw-translate-y:-0.625rem}.-translate-y-2\.5,.-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-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{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-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}.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-50{--tw-bg-opacity:0.5}.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)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.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-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-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-6xl{font-size:3.75rem;line-height:1}.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}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.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}.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)}.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)}#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: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\: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\:p-6{padding:1.5rem}.md\:p-8{padding:2rem}.md\:text-4xl{font-size:2.25rem;line-height:2.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