Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/nperez0111/bookhive. Track your books, share your shelves, see what others are reading bookhive.buzz
atproto bluesky books bookshelf goodreads management-system
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505@import "tailwindcss";@import "basecoat-css";
@config '../tailwind.config.js';
@theme { --color-sand: #f9eabc; --color-sand-dark: #998a5c;}
:root { --primary: rgb(217 119 6); /* amber-600 */ --primary-foreground: rgb(255 251 235); /* amber-50 */ --secondary: rgb(146 64 14); /* amber-800 */ --secondary-foreground: rgb(254 243 199); /* amber-100 */ --accent: rgb(251 191 36); /* amber-400 */ --accent-foreground: rgb(63 43 8); /* amber-950 */ --background: rgb(249 234 188); /* sand */ --foreground: rgb(28 25 23); /* stone-900 */ --card: rgb(254 252 232); /* yellow-50 */ --card-foreground: rgb(28 25 23); --muted: rgb(254 249 195); /* yellow-100 */ --muted-foreground: rgb(120 113 108); /* stone-500 */ --border: rgb(229 213 160); /* sand-dark variant */ --input: rgb(229 213 160); --ring: rgb(217 119 6); /* amber-600 */ --destructive: rgb(220 38 38); /* red-600 */ --popover: rgb(255 255 255); --popover-foreground: rgb(28 25 23);}
.dark { --primary: rgb(245 158 11); /* amber-500 */ --primary-foreground: rgb(63 43 8); /* amber-950 */ --secondary: rgb(120 53 15); /* amber-900 */ --secondary-foreground: rgb(253 230 138); /* amber-200 */ --accent: rgb(245 158 11); /* amber-500 */ --accent-foreground: rgb(255 251 235); /* amber-50 */ --background: rgb(63 43 8); --foreground: rgb(250 250 249); /* stone-50 */ --card: rgb(63 43 8); --card-foreground: rgb(250 250 249); --muted: rgb(120 68 5); --muted-foreground: rgb(249, 243, 146); /* amber-200 */ --border: rgb(147, 113, 13); --input: rgb(146 83 8); --ring: rgb(245 158 11); /* amber-500 */ --destructive: rgb(239 68 68); /* red-500 */ --popover: rgb(63 43 8); --popover-foreground: rgb(250 250 249);}
html,body { @apply h-full;}
html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}
/* Subtle honeycomb - no pattern (Firefox fix), explicit colors, theme-aware *//* SVGs inlined as data URIs to avoid Bun bundler resolution and eliminate extra HTTP requests */html { background-color: var(--background); /* background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='43.4' viewBox='0 0 50 43.4'%3E%3Cpolygon points='24.8,22 37.3,29.2 37.3,43.7 24.8,50.9 12.3,43.7 12.3,29.2' fill='none' stroke='rgba(0,0,0,0.08)' stroke-width='0.6'/%3E%3Cpolygon points='49.8,22 62.3,29.2 62.3,43.7 49.8,50.9 37.3,43.7 37.3,29.2' fill='none' stroke='rgba(0,0,0,0.08)' stroke-width='0.6'/%3E%3Cpolygon points='-0.2,22 12.3,29.2 12.3,43.7 -0.2,50.9 -12.7,43.7 -12.7,29.2' fill='none' stroke='rgba(0,0,0,0.08)' stroke-width='0.6'/%3E%3Cpolygon points='37.3,0.3 49.8,7.5 49.8,22 37.3,29.2 24.8,22 24.8,7.5' fill='none' stroke='rgba(0,0,0,0.08)' stroke-width='0.6'/%3E%3Cpolygon points='12.3,0.3 24.8,7.5 24.8,22 12.3,29.2 -0.2,22 -0.2,7.5' fill='none' stroke='rgba(0,0,0,0.08)' stroke-width='0.6'/%3E%3C/svg%3E"); */ background-repeat: repeat;}html.dark { /* background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='43.4' viewBox='0 0 50 43.4'%3E%3Cpolygon points='24.8,22 37.3,29.2 37.3,43.7 24.8,50.9 12.3,43.7 12.3,29.2' fill='none' stroke='rgba(255,255,255,0.06)' stroke-width='0.6'/%3E%3Cpolygon points='49.8,22 62.3,29.2 62.3,43.7 49.8,50.9 37.3,43.7 37.3,29.2' fill='none' stroke='rgba(255,255,255,0.06)' stroke-width='0.6'/%3E%3Cpolygon points='-0.2,22 12.3,29.2 12.3,43.7 -0.2,50.9 -12.7,43.7 -12.7,29.2' fill='none' stroke='rgba(255,255,255,0.06)' stroke-width='0.6'/%3E%3Cpolygon points='37.3,0.3 49.8,7.5 49.8,22 37.3,29.2 24.8,22 24.8,7.5' fill='none' stroke='rgba(255,255,255,0.06)' stroke-width='0.6'/%3E%3Cpolygon points='12.3,0.3 24.8,7.5 24.8,22 12.3,29.2 -0.2,22 -0.2,7.5' fill='none' stroke='rgba(255,255,255,0.06)' stroke-width='0.6'/%3E%3C/svg%3E"); */}
/* Body transparent so html honeycomb shows through */body { background: transparent;}
@keyframes loadingBar { 0% { left: 0%; right: 100%; width: 0%; } 10% { left: 0%; right: 75%; width: 25%; } 90% { right: 0%; left: 75%; width: 25%; } 100% { left: 100%; right: 0%; width: 0%; }}
/* Covers are `loading="lazy"`, so a grid renders as a field of empty holes until the images arrive. Tint the cover box so the grid reads as a shelf of books immediately, and fade each cover in over it once decoded rather than snapping.*/.book-cover-frame { background-color: var(--muted);}
/* Keyed off `.is-loaded`, which the cover-fade script in src/pages/navbar.tsx adds once the image actually fires `load`. This used to animate on insertion, which meant the 200ms had almost always elapsed before a `loading="lazy"` cover finished downloading — so the fade only ever played for covers that were already in cache, and every cover that actually needed it snapped in at full opacity instead.
Deliberately no `opacity: 0` default: with JS off nothing ever adds `.is-loaded`, and hiding covers until a script rescues them would break the no-JS rendering the rest of the app keeps working. Without JS the covers simply appear unanimated.*/.book-cover.is-loaded { animation: bookCoverIn 200ms ease-out;}
@keyframes bookCoverIn { from { opacity: 0; } to { opacity: 1; }}
@media (prefers-reduced-motion: reduce) { /* Must match `.book-cover.is-loaded`'s specificity — a media query adds none. */ .book-cover, .book-cover.is-loaded { animation: none; }}
/* View Transitions - Cross-document transitions */@view-transition { navigation: auto;}
/* Book cover view transitions */.book-cover { view-transition-name: var(--book-cover-name); outline: 1px solid rgba(0, 0, 0, 0.1); outline-offset: -1px;}
.dark .book-cover { outline-color: rgba(255, 255, 255, 0.1);}
.book-title { view-transition-name: var(--book-title-name);}
.genre-name { view-transition-name: var(--genre-name);}
/* Smooth transitions for common elements */::view-transition-old(root),::view-transition-new(root) { animation-duration: 0.3s; animation-timing-function: ease-in-out;}
/* Book cover specific transitions */::view-transition-old(book-cover),::view-transition-new(book-cover) { animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);}
/* Book title specific transitions */::view-transition-old(book-title),::view-transition-new(book-title) { animation-duration: 0.3s; animation-timing-function: ease-out;}
/* Sidebar structure and nav (all breakpoints where sidebar is visible) */.sidebar { @apply border-border bg-background text-foreground flex flex-col border-r; padding: 1rem 0;}
.sidebar header { @apply flex items-center gap-2 px-4 pb-4;}
.sidebar header img { @apply shrink-0;}
.sidebar header span { @apply text-foreground font-semibold; white-space: nowrap;}
.sidebar > ul { @apply flex flex-col gap-0.5 px-2;}
.sidebar > ul li { @apply list-none;}
.sidebar > ul a { @apply text-foreground block rounded-md px-3 py-2 text-sm font-medium; transition-property: background-color, color, scale; transition-duration: 150ms; min-height: 40px; display: flex; align-items: center; white-space: nowrap;}
.sidebar > ul a:hover { @apply bg-muted text-foreground;}
.sidebar > ul a:active { scale: 0.96;}
/* "You are here", NOT a call to action. This used to be `bg-primary text-primary-foreground`, byte-identical to `.btn-primary` — so in the mobile drawer the active nav item and the "Buzz in" sign-in button rendered as two identical filled amber pills with no way to tell which was which. A tinted pill still reads as selected and leaves the solid fill to actual CTAs. */.sidebar > ul a[aria-current="page"] { @apply bg-primary/15 text-primary font-semibold;}
.sidebar > ul a[aria-current="page"]:hover { @apply bg-primary/25 text-primary;}
.sidebar hr { @apply border-border my-4;}
.sidebar footer { @apply mt-auto flex flex-col gap-1 px-4 pt-4;}
.sidebar footer a,.sidebar footer button { @apply text-muted-foreground inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline; transition-property: background-color, color, scale; transition-duration: 150ms; min-height: 40px; white-space: nowrap;}
.sidebar footer a:hover,.sidebar footer button:hover { @apply bg-muted text-foreground;}
.sidebar footer a:active,.sidebar footer button:active { scale: 0.96;}
/* Same reasoning as the nav list above — Privacy/Terms are destinations, not CTAs. */.sidebar footer a[aria-current="page"] { @apply bg-primary/15 text-primary font-semibold;}
.sidebar footer a[aria-current="page"]:hover { @apply bg-primary/25 text-primary;}
/* User block in sidebar (when logged in) */.sidebar ul a:has(.avatar) { @apply flex items-center gap-2;}
.sidebar .avatar { @apply size-8 shrink-0 overflow-hidden rounded-full;}
.sidebar .avatar img { @apply size-full object-cover;}
/* Desktop: sidebar fixed, full width; content offset */@media (min-width: 1024px) { .sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: 280px; flex-shrink: 0; }
.layout-content { margin-left: 280px; width: calc(100% - 280px); }}
/* Tablet: same full sidebar as desktop so text isn’t truncated */@media (min-width: 768px) and (max-width: 1023px) { .sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: 280px; flex-shrink: 0; }
.layout-content { margin-left: 280px; width: calc(100% - 280px); }}
/* Mobile: sidebar hidden, opened via drawer */@media (max-width: 767px) { .sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: 280px; z-index: 40; transform: translateX(-100%); transition: transform 0.3s ease; }
.sidebar.open { transform: translateX(0); }
.sidebar-backdrop { position: fixed; inset: 0; z-index: 35; background: rgba(0, 0, 0, 0.5); opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
.sidebar-backdrop.open { opacity: 1; pointer-events: auto; }}
@media (min-width: 768px) { .sidebar-backdrop { display: none; }}
/* Tab label base style (used by feed tabs, etc.) */.tab-label { @apply border-border text-muted-foreground hover:text-foreground cursor-pointer border-b-2 border-transparent px-4 py-2 text-sm font-medium transition-[color,border-color] duration-150;}
/* Remove transparency from form controls — use solid backgrounds */.textarea,.input,select.select,:is(.form, .field) select,:is(.form, .field) textarea { background-color: var(--card);}/* In dark mode --background and --card are the same amber-950, so a form control needs its own surface to read as editable. It used to use var(--input) (a saturated amber-800), which made every input and select look like a filled amber button. A low-alpha white overlay lifts the control off whichever surface it sits on without adding another saturated colour.*/.dark .textarea,.dark .input,.dark select.select,.dark :is(.form, .field) select,.dark :is(.form, .field) textarea { background-color: rgb(255 255 255 / 0.05);}
/* Same root cause for outline buttons: basecoat's `.btn-outline` applies `dark:bg-input/30`, which assumes --input is a near-neutral border colour. Here it is amber, so "outline" buttons rendered as filled amber blocks. Let the border carry the affordance.*/.dark .btn-outline:not(:hover) { background-color: transparent;}
/* Tactile button feedback */.btn { transition-property: scale, background-color, color, box-shadow, border-color; transition-duration: 150ms; transition-timing-function: ease-out;}
.btn:active:not(:disabled) { scale: 0.96;}
/* basecoat's button variants are STANDALONE classes, not modifiers on `.btn`: `.btn` is itself the default (primary) variant and applies `bg-primary text-primary-foreground`, while `.btn-ghost` only declares a `:hover` state. So `class="btn btn-ghost"` — the idiom this app uses in 39 places — rendered as a filled primary button with a ghost hover, i.e. the exact opposite of "ghost", and made every secondary action shout as loudly as the real CTA.
Rather than rewrite 39 call sites, make the compound selector self-sufficient. `.btn.btn-ghost` (0,2,0) outranks both `.btn` and `.btn-sm` (0,1,0). Hover uses `bg-muted` to match the sidebar's house hover rather than basecoat's `bg-accent`, which is a saturated amber here.*/.btn.btn-ghost { background-color: transparent; box-shadow: none; @apply text-foreground;}
.btn.btn-ghost:hover:not(:disabled),.btn.btn-ghost[aria-pressed="true"] { @apply bg-muted text-foreground;}
/* `.btn-outline` has the same problem in the other direction: it overrides the background but not the text colour, so `btn btn-outline` kept `.btn`'s `text-primary-foreground` and rendered as near-invisible text on a transparent button.*/.btn.btn-outline { @apply text-foreground;}
.btn.btn-outline:hover:not(:disabled) { @apply bg-muted text-foreground;}
/* Typography polish */h1,h2,h3 { text-wrap: balance;}
p { text-wrap: pretty;}
/* Card parts (used with .card from basecoat) */.card .card-header { @apply px-6;}
.card .card-body { @apply px-6;}
/* basecoat styles `.card > header/section/footer`, but this app uses the shadcn-style card-header/card-body/card-title divs instead. `.card-title` was referenced in 9 places and defined nowhere, so Preflight's h2 reset made every one of them render as plain body text.*//* Deliberately unscoped — a few section headings use it outside a .card wrapper. */.card-title { @apply text-foreground text-lg font-semibold leading-none;}
/* Same story as .card-title: `empty` / `empty-title` / `empty-description` were used in feed.tsx, error.tsx and profile.tsx but defined in neither basecoat nor here, so every "no results" state rendered as three unstyled stacked divs. This is the one empty-state treatment — prefer it over hand-rolling another `py-12 text-center` block.*/.empty { @apply flex flex-col items-center justify-center px-6 py-12 text-center;}
.empty-title { @apply text-foreground text-lg font-semibold;}
.empty-description { @apply text-muted-foreground mt-1 max-w-prose text-sm;}
/* One focus recipe. Most inputs/buttons in the app set `focus:outline-none` without a focus-visible replacement, and 14 of ~28 ring declarations hardcode amber/yellow instead of --ring. Prefer `.focus-ring` over hand-rolling either.*/.focus-ring { @apply focus-visible:outline-ring focus-visible:outline-2 focus-visible:outline-offset-2;}