/* EZWishlist design system — token set carried over from v1, rebuilt for htmx server-rendered pages */

:root {
    /* WCAG AA: #6366f1 measured 4.47:1 on white and 4.27:1 on --gray-50, just under the
       4.5 needed for body text — and it is both the link colour and the primary button
       background, so links and white-on-button both failed. Darkened one step; hover moved
       down with it. */
    --primary: #5b5bd6;
    --primary-hover: #4f46e5;
    --secondary: #ec4899;
    --success: #10b981;
    --warning: #f59e0b;
    /* #ef4444 gave 3.76:1 both as validation text on white and behind .btn-danger label. */
    --error: #b91c1c;

    --gray-50: #f9fafb;
    --gray-100: #f3f4f6;
    --gray-200: #e5e7eb;
    --gray-300: #d1d5db;
    --gray-400: #9ca3af;
    --gray-500: #6b7280;
    --gray-600: #4b5563;
    --gray-700: #374151;
    --gray-800: #1f2937;
    --gray-900: #111827;

    --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    --radius: 0.5rem;
    --radius-lg: 0.75rem;
    --shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
    --shadow-md: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
    --shadow-lg: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -2px rgba(0,0,0,.05);
    --container: 1100px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 16px; }
body {
    font-family: var(--font);
    color: var(--gray-800);
    background: var(--gray-50);
    line-height: 1.6;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}
main { flex: 1; }
img { max-width: 100%; }
a { color: var(--primary); }

.container { max-width: var(--container); margin: 0 auto; padding: 0 1rem; }

/* Header */
.site-header {
    background: #fff;
    border-bottom: 1px solid var(--gray-200);
    position: sticky; top: 0; z-index: 50;
}
.site-header .container { display: flex; align-items: center; justify-content: space-between; height: 64px; }
/* Wordmark lockup: script "EZ" + typewriter "wishlist". 26px is the wordmark floor —
   below that the clay gift mark stands alone (favicon, app icon). Ratio 0.725 puts
   "wishlist" at two-thirds of the EZ ink height, measured from rendered glyphs. */
.logo { font-weight: 400; font-size: 26px; line-height: 1.1; color: var(--gray-900); text-decoration: none; white-space: nowrap; }
.logo .ez { font-family: 'Caveat Brand', cursive; font-weight: 700; color: var(--primary); }
.logo .rest { font-family: 'Courier Wordmark', 'Courier New', monospace; font-size: .725em; margin-left: .12em; letter-spacing: .035em; }
.site-nav { display: flex; gap: 1rem; align-items: center; }
/* :not(.btn) so the nav's link colour can't outrank button styling — without it
   .site-nav a (0,1,1) beat .btn-primary (0,1,0) and the "Sign in" button rendered
   grey-on-indigo. Buttons keep whatever their .btn-* class sets. */
.site-nav a:not(.btn) { color: var(--gray-600); text-decoration: none; font-weight: 500; font-size: .95rem; }
.site-nav a:not(.btn):hover { color: var(--gray-900); }
/* Phones: the fixed 64px row crams four nav items against the logo and "My wishlists"
   breaks across two lines. Let the header wrap to a second row instead, and keep each
   link on one line. */
@media (max-width: 640px) {
    .site-header .container {
        height: auto; min-height: 56px; flex-wrap: wrap;
        padding-top: .5rem; padding-bottom: .5rem; row-gap: .35rem; column-gap: .75rem;
    }
    /* margin-left:auto + flex-end keep the nav anchored right even when it wraps to its
       own row — matching where it sits when everything fits on one line. */
    .site-nav { flex-wrap: wrap; gap: .5rem .9rem; margin-left: auto; justify-content: flex-end; }
    .site-nav a:not(.btn) { font-size: .9rem; white-space: nowrap; }
}

/* Footer */
.site-footer {
    background: var(--gray-900); color: var(--gray-400);
    padding: 2rem 0; margin-top: 4rem; font-size: .875rem;
}
.site-footer a { color: var(--gray-300); text-decoration: none; }
.site-footer a:hover { color: #fff; }
/* Flex + gap rather than inline anchors with margin-right. Inline wrapping put no vertical
   space at all between rows once the row stacked on a phone — links a couple of pixels apart,
   which is a misclick waiting to happen — and it let "For shops & bloggers" break across two
   lines mid-phrase. row-gap is what separates the tap targets; the padding is what makes them
   tall enough to hit in the first place (~32px against a 21px line box). */
.footer-links { display: flex; flex-wrap: wrap; gap: .6rem 1.25rem; }
.footer-links a { white-space: nowrap; padding: .35rem 0; }
/* Stacked to two or three rows, so buy a little more separation between them. */
@media (max-width: 640px) { .footer-links { gap: .9rem 1.25rem; } }
/* gray-500 on the gray-900 footer was 3.67:1, and this is the affiliate disclosure — the
   one line with a regulatory reason to be legible. */
.site-footer .disclosure { margin-top: .75rem; font-size: .8rem; color: var(--gray-400); }
/* iOS Safari: overscrolling past the footer reveals the page canvas (near-white), and the
   bottom bar tints itself from that. Fixed elements ride the rubber-band on iOS, so a strip
   hanging one viewport below the layout viewport fills the reveal with the footer's ground.
   On body, not html, so kraft pages' warmed --gray-900 resolves to their espresso footer. */
body::after {
    content: ""; position: fixed; top: 100%; left: 0; right: 0; height: 100vh;
    background: var(--gray-900); z-index: -1; pointer-events: none;
}
body.popup::after { content: none; } /* chrome-less popup: no footer to match */

/* Buttons */
.btn {
    display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
    padding: .625rem 1.25rem; border-radius: var(--radius);
    font: 600 .95rem var(--font); cursor: pointer; text-decoration: none;
    border: 1px solid transparent; transition: all 150ms ease;
}
.btn:focus-visible { outline: 3px solid #c7d2fe; outline-offset: 1px; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-secondary { background: #fff; color: var(--gray-700); border-color: var(--gray-300); }
.btn-secondary:hover { background: var(--gray-100); }
.btn-danger { background: var(--error); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: .375rem .75rem; font-size: .85rem; }
.btn-lg { padding: .875rem 1.75rem; font-size: 1.05rem; }
.btn[disabled] { opacity: .55; cursor: not-allowed; }
/* Celebratory animated glow (reserve-success store CTA). The blurred gradient sits
   BEHIND the button (z-index -1) so the solid button face stays readable; the slow
   drift is decorative, so reduced-motion visitors get a static soft glow instead. */
.btn-glow { position: relative; z-index: 0; }
.btn-glow::before {
    content: ''; position: absolute; inset: -3px; z-index: -1;
    border-radius: calc(var(--radius) + 4px);
    background: linear-gradient(120deg, var(--primary), var(--secondary), var(--warning), var(--secondary), var(--primary));
    background-size: 300% 300%;
    filter: blur(7px); opacity: .75;
    animation: ezw-glow 5s ease-in-out infinite;
}
@keyframes ezw-glow {
    0% { background-position: 0% 50%; }
    50% { background-position: 100% 50%; }
    100% { background-position: 0% 50%; }
}
@media (prefers-reduced-motion: reduce) {
    .btn-glow::before { animation: none; }
}

/* Cards */
.card {
    background: #fff; border: 1px solid var(--gray-200);
    border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 1.5rem;
}
.card + .card { margin-top: 1rem; }

/* Forms */
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-weight: 600; font-size: .875rem; margin-bottom: .375rem; color: var(--gray-700); }
.form-control {
    width: 100%; padding: .625rem .75rem; border: 1px solid var(--gray-300);
    border-radius: var(--radius); font: 400 .95rem var(--font); background: #fff;
}
.form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(91,91,214,.15); }
/* iOS Safari zooms the whole page in when it focuses a field whose text is under 16px,
   and it never zooms back out. At .95rem (15.2px) every field here triggered it: tapping
   Title in the add-item dialog scaled the layout a few percent wider than the screen, and
   the modal — sized by position:fixed to the UNZOOMED viewport — ended up hanging off the
   right edge with its left padding intact, which reads as the dialog having slid sideways.
   16px is the only way to opt out that keeps pinch-zoom working (maximum-scale=1 on the
   viewport tag would fix it by banning zoom altogether, and fails WCAG 1.4.4).
   pointer:coarse rather than a width breakpoint — it's the input method that zooms, not
   the screen size, so a narrow desktop window keeps the .95rem the rest of the UI is set
   in. The bare element selectors are a net for any future field that skips .form-control;
   checkboxes, radios, ranges and colour wells never zoom and are sized off other things
   (the item form's "No limit" box off its label's line-height), so they stay out of it. */
@media (pointer: coarse) {
    .form-control,
    input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),
    select, textarea { font-size: 16px; }
}
.form-hint { font-size: .8rem; color: var(--gray-500); margin-top: .25rem; }
.form-check { display: flex; align-items: flex-start; gap: .5rem; margin-bottom: .75rem; }
.form-check input { margin-top: .3rem; }
/* Hint copy inside a checkbox/radio label gets its own line under the label text —
   inline it ran into the label and read as one long sentence. */
.form-check .form-hint { display: block; font-weight: 400; }

/* Item-priority slider — words, not numbers, do the talking (see Services/Priorities) */
.priority-slider { width: 100%; max-width: 340px; display: block; accent-color: var(--primary); margin: .35rem 0 .1rem; }
.priority-slider:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; }

/* Inline range input (button builder's corner radius) — the priority slider's
   affordances without its full-width block layout, so it can sit beside a readout. */
.range-input { accent-color: var(--primary); }
.range-input:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; }

/* Screen-reader-only labels (claim-code input etc.) */
.visually-hidden {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Gifter's "enter claim code" fallback on /w/ — quiet by default */
.claim-code-entry { margin-top: .75rem; font-size: .9rem; }
.claim-code-entry summary { cursor: pointer; color: var(--gray-600); }

/* "Where do I paste this?" disclosure on /button — collapsed by default so the
   builder stays short for people who already know where their theme editor is */
.paste-help { font-size: .9rem; }
.paste-help summary { cursor: pointer; color: var(--gray-600); font-weight: 500; }
.paste-help ul { margin: .65rem 0 .5rem 1.15rem; color: var(--gray-600); }
.paste-help li { margin-bottom: .4rem; }
.paste-help strong { color: var(--gray-700); }
.paste-help .caveat { color: var(--gray-500); font-size: .85rem; margin-top: .6rem; }
/* "Where do I get the link?" (_GetLinkHelp) reuses .paste-help with numbered steps.
   The glyphs sit inline right after the word "Share" they illustrate. */
.paste-help ol { margin: .65rem 0 .1rem 1.15rem; color: var(--gray-600); }
.share-glyph { display: inline-block; vertical-align: -2px; margin-left: .15rem; color: var(--gray-700); }
/* One-tap "Paste" button (_PasteButton) at the head of the paste-a-link rows. Compact
   padding, and the glyph drops at phone widths — the row holds three elements, and every
   pixel the button keeps comes out of the URL box beside it. "Paste" is the meaning;
   the glyph is garnish. */
.paste-btn { gap: .35rem; padding-left: .75rem; padding-right: .75rem; }
.paste-btn svg { flex: none; }
@media (max-width: 430px) { .paste-btn svg { display: none; } }

/* Six-box sign-in code entry on /account/login (JS-revealed; plain input without JS) */
.code-boxes { display: flex; gap: .5rem; }
.code-box {
    width: 3rem; height: 3.4rem; padding: 0; text-align: center;
    font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums;
}
/* Motion, not just words, while the code is verified: the six-box widget submits itself,
   so a static line can read as part of the page rather than as something happening.
   Reduced-motion visitors get a still ring — the text alone carries the message. */
.spinner {
    display: inline-block; width: .85em; height: .85em; vertical-align: -.1em;
    margin-right: .45em; border: 2px solid var(--gray-300);
    border-top-color: var(--primary); border-radius: 50%;
    animation: ezw-spin .6s linear infinite;
}
@keyframes ezw-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
    .spinner { animation: none; }
}

/* "Keep track of gifts you've promised?" account offer on /r/ — quiet, not an alert */
.account-offer {
    border: 1px solid var(--gray-200); border-radius: var(--radius);
    padding: .875rem 1rem; font-size: .9rem; color: var(--gray-600);
}
.account-offer h2 { font-size: 1rem; color: var(--gray-700); margin-bottom: .25rem; }

/* Collapsed wrapper for the offer inside the item modal: one enticing line under the
   failed-lookup note, expanding in place — expanded it shoved the form fields down,
   and at the bottom of the modal body it sat under the fold unseen */
.bookmarklet-fold summary {
    cursor: pointer; font-size: .875rem; font-weight: 600; color: var(--primary);
    margin: -.25rem 0 .75rem;
}
.bookmarklet-fold summary::marker { color: var(--primary); }
.bookmarklet-fold[open] summary { margin-bottom: .25rem; }
.bookmarklet-fold .bookmarklet-offer { margin: 0 0 .75rem; }

/* The save button installs by DRAGGING to a bookmarks bar — a fine-pointer gesture.
   Its desktop-only class gates by WIDTH (720px), which lets tablets and landscape
   phones through: both exceed the breakpoint, neither has a usable bar to drag to
   (iPad's is hidden by default and touch-dragging onto it is unreliable; Android has
   none at all). So the pitch also gates on input method, the same signal the iOS
   focus-zoom fix uses. Covers every surface at once: the item modal's fold, /start's
   failed-lookup reveal, and the dashboard card's offer + its "Save in one click"
   bullet (.ways-list). */
@media (pointer: coarse) {
    .bookmarklet-fold, .bookmarklet-offer, .ways-list .desktop-only { display: none !important; }
    /* The "on a desktop there's also…" line stands in for the setup card wherever the
       drag can't happen — which is this media query, not just narrow screens: without
       it a tablet gets neither the pitch nor any mention the feature exists. */
    .bookmarklet-offer + .form-hint.mobile-only { display: block; }
}

/* "Save straight from the store" setup card (_BookmarkletOffer) — the same quiet
   treatment as .account-offer: it's an offer, not an alert, even when it appears
   under a failed-lookup message */
.bookmarklet-offer {
    border: 1px solid var(--gray-200); border-radius: var(--radius);
    padding: .875rem 1rem; font-size: .9rem; color: var(--gray-600);
    margin-top: .75rem;
}
.bookmarklet-offer ol { margin: .6rem 0 .75rem 1.15rem; padding: 0; }
.bookmarklet-offer li { margin-top: .35rem; }
.bookmarklet-offer kbd {
    border: 1px solid var(--gray-300); border-bottom-width: 2px; border-radius: 4px;
    padding: 0 .3rem; font-size: .8rem; background: var(--gray-100); color: var(--gray-700);
}

/* Width-based stand-in for "has a bookmarks bar": the drag-to-bookmarks flow needs a
   desktop browser, phones get the copy-the-link instructions instead */
.mobile-only { display: none; }
@media (max-width: 720px) {
    .desktop-only { display: none; }
    .mobile-only { display: block; }
    /* display:block would break a button's inline-flex label centering */
    .mobile-only.btn { display: inline-flex; }
}
.validation-error, .field-validation-error { color: var(--error); font-size: .85rem; }
.validation-summary-errors ul { list-style: none; color: var(--error); font-size: .9rem; }

/* Alerts */
.alert { padding: .875rem 1rem; border-radius: var(--radius); margin-bottom: 1rem; font-size: .925rem; }
.alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.alert-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.alert-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }

/* Badges */
.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 700; }
.badge-available { background: #ecfdf5; color: #065f46; }
.badge-reserved { background: #fffbeb; color: #92400e; }
/* gray-500 on gray-100 was 4.39:1 — the only badge that missed. */
.badge-purchased { background: var(--gray-100); color: var(--gray-600); }

/* Hero */
.hero { background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 100%); padding: 4.5rem 0; text-align: center; }
.hero h1 { font-size: clamp(2rem, 5vw, 3rem); font-weight: 800; color: var(--gray-900); line-height: 1.15; }
.hero p.lede { font-size: 1.2rem; color: var(--gray-600); max-width: 620px; margin: 1rem auto 2rem; }

/* Item grid (public wishlist) */
.item-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1.25rem; }
/* `.card + .card` adds a top margin every card but the first gets, which in a stretch
   grid leaves the first card exactly 1rem taller than its row. Same fix as .feature-grid. */
.item-grid > .card { margin-top: 0; }
/* List cards on /explore: thumbnails pinned to the bottom so cards of differing title
   length still line their image strips up across a row. */
.list-card { display: flex; flex-direction: column; text-decoration: none; color: inherit; }
.list-thumbs { display: flex; gap: .375rem; margin-top: auto; padding-top: .875rem; }
.list-thumbs img, .list-thumbs-more, .list-thumbs .img-fallback {
    width: 44px; height: 44px; border-radius: var(--radius);
    border: 1px solid var(--gray-200); background: var(--gray-100); flex: none;
}
.list-thumbs img { object-fit: cover; }
.list-thumbs-more {
    display: flex; align-items: center; justify-content: center;
    font-size: .8rem; font-weight: 600; color: var(--gray-500);
}
/* Stands in for a product image, both when an item has none and when the store's CDN
   fails to serve one (see the data-fallback handler in _Layout). One class for both so
   the two cases are indistinguishable — sized per context, since the same glyph fills a
   170px card tile and a 44px thumbnail. */
.img-fallback { display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.item-img .img-fallback { font-size: 2.5rem; }
.list-thumbs .img-fallback { font-size: 1.35rem; }
.data .img-fallback { font-size: 1.5rem; }
/* /explore trending: compact tiles. minmax(150px) fits two per row on a phone, so the
   trending strip stops shoving the wishlist cards (the section the page is for) below
   the fold. */
.trend-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: .75rem; }
.trend-grid .item-img { height: 110px; }
.trend-grid .item-body { padding: .6rem; gap: .25rem; }
.trend-grid .item-title { font-size: .85rem; font-weight: 600; line-height: 1.3; }
.trend-grid .item-price { font-size: .8rem; }
.trend-grid .item-actions { gap: .35rem; }
.trend-grid .item-actions .btn { padding: .3rem .55rem; font-size: .75rem; }
/* Base action row — also used outside .item-card (QR modal, dashboard cards), where
   wrapped buttons otherwise stack with no gap between rows. */
.item-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
.item-card { background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); display: flex; flex-direction: column; }
.item-card .item-img { height: 170px; background: var(--gray-100); display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; }
/* Status badge as a sticker on the photo's corner — see the /w/ card markup. */
.item-card .item-img .badge { position: absolute; top: 10px; right: 10px; }
.item-card .item-img img { width: 100%; height: 100%; object-fit: contain; padding: 10px; }
.item-card .item-body { padding: 1rem; flex: 1; display: flex; flex-direction: column; gap: .5rem; }
/* Same scraped-title problem as the dashboard table, and worse here: cards sit in a grid
   row, so one 200-character Amazon title made every card beside it that tall. Three lines
   (not the table's two) — this is the page a gifter shops from, and the store link is the
   way to the full name. */
.item-card .item-title {
    font-weight: 700; color: var(--gray-900);
    display: -webkit-box; -webkit-box-orient: vertical;
    -webkit-line-clamp: 3; line-clamp: 3;
    overflow: hidden; overflow-wrap: anywhere; min-width: 0;
}
/* Explore's tiles are half the size, so they get the tighter clamp. */
.trend-grid .item-card .item-title { -webkit-line-clamp: 2; line-clamp: 2; }
.item-card .item-price { color: var(--gray-600); font-weight: 600; }
.item-card .item-actions { margin-top: auto; display: flex; gap: .5rem; flex-wrap: wrap; }

/* Retailer search links. Deliberately text, not buttons: these are search results we
   haven't verified, so they must not carry the visual weight of "View in store" (a real
   product page) or of "I'll get this" (the actual call to action). Sits under the action
   row, which margin-top:auto has already pushed to the bottom of the card. */
.item-search { font-size: .8rem; margin: .5rem 0 0; display: flex; flex-wrap: wrap; gap: .5rem; align-items: baseline; }
.item-search a { color: var(--gray-700); text-decoration: underline; }
.item-search a:hover, .item-search a:focus-visible { color: var(--primary); }

/* The hidden attribute must beat any display rule below it — .modal-backdrop sets
   display:flex, which would otherwise override the UA stylesheet's [hidden] display:none
   and leave a "hidden" dialog on screen. */
[hidden] { display: none !important; }

/* Modal (htmx-driven) */
.modal-backdrop {
    position: fixed; inset: 0; background: rgba(17,24,39,.55);
    display: flex; align-items: center; justify-content: center; z-index: 100; padding: 1rem;
}
/* Keep touch scrolling INSIDE the dialog — without this, scrolling a modal to its edge
   chains to the page behind it and, on mobile, lets the whole page rubber-band (including
   sideways). */
.modal-backdrop, .modal, .modal-body { overscroll-behavior: contain; }
/* While any modal is open the body is pinned (see the _Layout scroll-lock script that
   toggles .ezw-modal-open): the fixed backdrop covers the screen, but a touch drag would
   otherwise still pan and scroll the page underneath it. position:fixed is the reliable
   cross-browser lock — the script preserves and restores the scroll position. */
html.ezw-modal-open { overflow: hidden; }
html.ezw-modal-open body { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; }
/* dvh over vh where supported: vh is the viewport WITHOUT the soft keyboard, so a focused
   input inside a 90vh dialog pushed the bottom of it — the save button — under the keyboard
   on iOS. dvh tracks the visible area. The vh line stays as the fallback. */
.modal { background: #fff; border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); width: 100%; max-width: 440px; padding: 1.5rem; max-height: 90vh; max-height: 90dvh; overflow-y: auto; }
.modal h3 { margin-bottom: 1rem; }

/* Long dialogs (the item form): the body scrolls and the header and action row don't, so
   the primary button is reachable at any scroll position. Without it the Add button sat
   below eight fields and a slider — on a phone you had to go looking for it, which is what
   made the whole flow feel like work. */
.modal.modal-scroll { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
/* :not(script) matters — the swapped-in partial carries its own <script>, and a bare "> *"
   gave it display:flex, beating the UA stylesheet's display:none. The source printed itself
   into the dialog as text and ate a third of the flex height. */
.modal.modal-scroll > :not(script) { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
.modal-head { padding: 1.25rem 1.5rem .875rem; border-bottom: 1px solid var(--gray-200); }
.modal-head h3 { margin-bottom: 0; }
/* Scroll affordance for the body. The pinned .modal-foot makes a long form look finished
   on a phone — fields, then a Save button — so nothing tells a thumb there is more above
   or below, and mobile scrollbars are overlay-style and fade by design, so they can't be
   relied on to say it.
   Two gradient layers scroll with the content (local) and mask two that stay pinned to the
   container, so each shadow is visible exactly when there IS more content past that edge
   and disappears at the extremes — unlike a static "scroll for more" hint, which starts
   lying the moment you scroll. No JS and no state; unsupported browsers just see no
   shadow. Needs the solid background-color below: the shorthand resets it to transparent,
   and the masking layers only work over an opaque surface. */
.modal-body {
    padding: 1.25rem 1.5rem; overflow-y: auto; flex: 1 1 auto; min-height: 0;
    background:
        linear-gradient(#fff 30%, rgba(255, 255, 255, 0)) top / 100% 28px no-repeat local,
        linear-gradient(rgba(255, 255, 255, 0), #fff 70%) bottom / 100% 28px no-repeat local,
        radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, .13), rgba(0, 0, 0, 0)) top / 100% 11px no-repeat,
        radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, .13), rgba(0, 0, 0, 0)) bottom / 100% 11px no-repeat;
    background-color: #fff;
}
.modal-foot {
    padding: .875rem 1.5rem; border-top: 1px solid var(--gray-200);
    display: flex; flex-direction: row; gap: .5rem; flex: none;
}
.modal-foot .btn { flex: 1; }
/* The ✕ icon button. Styled via .modal-x, NOT .modal-close — modal-close is purely the
   shared close-handler's JS hook, and styling it here clobbered real .btn buttons
   ("Close", "Never mind") that carry the hook class. */
.modal .modal-x {
    float: right; width: 32px; height: 32px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: none; border: none; font-size: 1.1rem; line-height: 1;
    cursor: pointer; color: var(--gray-500);
}
.modal .modal-x:hover { background: var(--gray-100); color: var(--gray-800); }
/* Wider than the 440px default: the item form runs two fields to a row on desktop. */
#item-modal-panel { max-width: 560px; }

/* Loading stand-in for the item form, built by List.cshtml's placeholder(): grey blocks
   laid out in the form's own grid, sized to its labels, inputs and buttons. Only the
   spinner line claims activity; the blocks pulse gently, and hold still under
   reduced-motion — the spinner's own rule already covers itself. */
.modal-wait { display: flex; align-items: center; color: var(--gray-600); font-size: .925rem; }
.skel { display: block; border-radius: var(--radius); background: var(--gray-100); animation: ezw-skel 1.3s ease-in-out infinite; }
@keyframes ezw-skel { 50% { background: var(--gray-200); } }
@media (prefers-reduced-motion: reduce) { .skel { animation: none; } }
.skel-field { margin-top: 1rem; }
.skel-label { height: .8rem; width: 38%; margin-bottom: .5rem; }
.skel-input { height: 2.55rem; }
.skel-btn { height: 2.6rem; flex: 1; }
.modal-wait.center { justify-content: center; text-align: center; }

/* "Looking it up" — a magnifier sweeps across a product placeholder while a URL lookup
   runs (the item modal, and the /start paste box). Pure CSS so it drops straight into
   the loading markup. .ezw-scan is the modal size; .ezw-scan-inline the compact /start one. */
.ezw-scan { position: relative; width: 260px; max-width: 82%; height: 72px; margin: 1.6rem auto .9rem; }
.ezw-scan-inline { position: relative; width: 220px; max-width: 100%; height: 56px; margin: .9rem 0 .25rem; }
.ezw-scan-ph { display: flex; align-items: center; gap: 14px; height: 100%; }
.ezw-scan-img { flex: none; width: 54px; height: 54px; border-radius: 10px; background: var(--gray-100); }
.ezw-scan-inline .ezw-scan-img { width: 44px; height: 44px; }
.ezw-scan-lines { flex: 1; display: flex; flex-direction: column; gap: 9px; }
.ezw-scan-l { display: block; height: 10px; border-radius: 999px; background: var(--gray-100); }
.ezw-scan-l.a { width: 86%; }
.ezw-scan-l.b { width: 58%; }
.ezw-scan-l.c { width: 34px; height: 13px; }
.ezw-scan-glass {
    position: absolute; top: 50%; left: 0; width: 40px; height: 40px; margin-top: -24px;
    border: 4px solid var(--primary); border-radius: 50%;
    box-shadow: 0 0 0 3px rgba(91, 91, 214, .10), inset 0 0 10px rgba(91, 91, 214, .12);
    /* translateX, not left, so the sweep stays on the compositor */
    --scan-travel: 190px;
    animation: ezw-scan-sweep 2.2s cubic-bezier(.5, 0, .5, 1) infinite alternate;
}
.ezw-scan-inline .ezw-scan-glass { width: 34px; height: 34px; margin-top: -21px; --scan-travel: 152px; }
/* the handle, angled off the lower-right of the lens */
.ezw-scan-glass::after {
    content: ""; position: absolute; right: -8px; bottom: -6px; width: 14px; height: 5px;
    background: var(--primary); border-radius: 3px; transform: rotate(45deg);
}
@keyframes ezw-scan-sweep { from { transform: translateX(4px); } to { transform: translateX(var(--scan-travel)); } }
@media (prefers-reduced-motion: reduce) {
    .ezw-scan-glass { animation: none; transform: translateX(calc(var(--scan-travel) / 2)); }
}

/* Item photo. With one set, the thumbnail IS the field and the URL box is hidden — but
   hidden, never removed: an absent input posts nothing, and the image would be wiped on
   every save. */
.photo-field .photo-preview { display: none; }
.photo-field.has-photo .photo-preview { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.photo-field.has-photo > .form-control { display: none; }
.photo-field.has-photo.editing > .form-control { display: block; margin-top: .75rem; }
.photo-preview img {
    width: 96px; height: 96px; object-fit: contain;
    background: var(--gray-100); border: 1px solid var(--gray-200); border-radius: var(--radius);
}
/* contain, not the cover used on item cards: this thumbnail answers "is that the right
   product?", so the whole thing has to be visible rather than a centre crop. */
.photo-actions { display: flex; flex-direction: column; gap: .4rem; }

/* Who can find this list, stated on the list page. Quiet by design — it reports a state,
   it isn't a control, and it sits next to a heading. */
/* The visibility chip and the Settings button that changes it, on one line under the
   list name. Pairing them is the point: the chip reports the state and the button is
   how you change it, so they belong together rather than at opposite ends of the
   header. The spacing above the pair lives here, not on the chip. */
.vis-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-top: .35rem; }

.vis-chip {
    display: inline-flex; align-items: center;
    padding: .2rem .55rem; border-radius: 999px;
    font-size: .8rem; font-weight: 600;
    border: 1px solid var(--gray-200); background: var(--gray-50); color: var(--gray-700);
}
.vis-chip.vis-code { border-color: #fde68a; background: #fffbeb; color: #92400e; }

/* "Use another method" on the passkey-first login screen. Both markers are reset or the
   browser's own triangle shows up beside our chevron. Sized as a real control rather than a
   line of text: it is the recovery route when a passkey has been deleted or the browser is
   shared, so it has to be findable and tappable, not merely present. */
.other-methods > summary {
    display: flex; align-items: center; justify-content: center; gap: .4rem;
    /* -webkit- prefix still required for Safari/iOS, which is most of the phone traffic here */
    min-height: 44px; cursor: pointer; -webkit-user-select: none; user-select: none;
    font-size: .95rem; font-weight: 600; color: var(--gray-700);
    list-style: none;
}
.other-methods > summary::-webkit-details-marker { display: none; }
.other-methods > summary::after {
    content: ''; width: .5rem; height: .5rem; margin-top: -.25rem;
    border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
    transform: rotate(45deg); transition: transform .15s;
}
.other-methods[open] > summary::after { transform: rotate(225deg); margin-top: .15rem; }
.other-methods > summary:hover { color: var(--gray-900); }
.other-methods > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }

/* "Buying doesn't mark it here" line under an unclaimed item's buttons. Quiet — it explains
   the two buttons above it rather than competing with them. */
.item-nudge { font-size: .8rem; color: var(--gray-600); margin-top: .5rem; }
.item-nudge strong { color: var(--gray-800); white-space: nowrap; }

/* Come-back-and-claim prompt (see Wishlist.cshtml). A docked bar, not a modal: they have just
   come back from somewhere else and a dialog demanding dismissal before they can even see the
   list would read as a toll gate. z-index below .modal-backdrop's 100 so a claim modal opened
   from it covers it rather than fighting it. */
#ezw-comeback {
    /* Centred on screen (owner call 2026-08-11: the docked bottom bar was missed), but
       still no backdrop — the page stays clickable, so it never reads as a toll gate. */
    position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%);
    z-index: 90; width: min(30rem, calc(100vw - 1.5rem));
    background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius-lg);
    box-shadow: 0 10px 34px rgba(58, 50, 38, .3), 0 2px 8px rgba(58, 50, 38, .16);
    padding: 1rem 1.125rem;
    display: flex; flex-wrap: wrap; align-items: center; gap: .75rem;
}
#ezw-comeback-img {
    width: 56px; height: 56px; object-fit: contain; flex: none;
    background: #fff; border: 1px solid #e5d8bd; border-radius: 10px; padding: 4px;
}
#ezw-comeback p { font-size: .9rem; color: var(--gray-800); flex: 1 1 14rem; margin: 0; }
.ezw-comeback-actions { display: flex; gap: .5rem; flex: 0 0 auto; }
/* Phones: buttons go full width on their own row rather than squeezing beside the text —
   "I'll get this" is the whole point of the bar and must be an easy thumb target.
   The flex reset on the paragraph is load-bearing: flex-basis follows the MAIN axis, so the
   14rem that reserves sensible text width in a row becomes a 14rem minimum HEIGHT once the
   container turns into a column, and the bar grew to 224px with a dead gap under the text. */
@media (max-width: 480px) {
    #ezw-comeback { flex-direction: column; align-items: stretch; }
    #ezw-comeback p { flex: 0 0 auto; }
    .ezw-comeback-actions > .btn { flex: 1; }
}

/* Failed-request toast (see _Layout). z-index above .modal-backdrop's 100: the save that
   fails most visibly is the one submitted from inside a dialog. */
#ezw-toast {
    position: fixed; left: 50%; bottom: 1.25rem; transform: translate(-50%, 1rem);
    z-index: 200; max-width: min(30rem, calc(100vw - 2rem));
    padding: .75rem 1.1rem; border-radius: var(--radius);
    background: var(--gray-900); color: #fff; font-size: .9rem; box-shadow: var(--shadow-lg);
    opacity: 0; pointer-events: none; transition: opacity 150ms ease, transform 150ms ease;
}
#ezw-toast.is-shown { opacity: 1; transform: translate(-50%, 0); }

/* Admin stat tiles. /admin has always written .stat-grid / .stat-num / .stat-label, but
   the rules were never here — two inline spans with nothing to separate them, so every
   tile on the page read "3today, of 100" with the number welded to its label. */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .75rem; }
/* neutralize the global ".card + .card" top margin — in a stretch grid it left the FIRST
   tile of every stat row 1rem taller than its siblings. Same fix as .feature-grid. */
.stat-grid > .card { margin-top: 0; }
.stat-grid .stat { display: flex; flex-direction: column; gap: .15rem; padding: .9rem 1rem; }
.stat-num { font-size: 1.5rem; font-weight: 700; line-height: 1.15; color: var(--gray-900); }
.stat-label { font-size: .8rem; color: var(--gray-600); line-height: 1.3; }

/* Dashboard */
/* Wishlist-card actions: a 2×2 grid so the four buttons come out equal-width and
   centered instead of ragged inline wrapping. */
.list-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem; }
/* The dashboard's single-column stack. A class rather than a max-width repeated inline on
   each card, so "the same width as the cards below it" is something the markup states once
   instead of three values that have to be kept in step by hand. */
.card-column { max-width: 520px; }
/* One wishlist: contents on the left, actions on the right. The 2×2 action block is already
   the right shape to sit in a column, and keeping it out of the flow leaves the widened card
   room to show the item images without growing a fourth stacked row. */
.list-card-solo { display: flex; align-items: flex-start; gap: 1.5rem; }
.list-card-solo > .list-card-info { flex: 1; min-width: 0; }
.list-card-solo > .list-actions { flex: 0 0 12rem; }
/* Below this the two columns stop fitting: the thumbnail strip needs 194px and the buttons
   ~12rem, which is more than a phone-width card has to give. Actions drop under the contents,
   which is the multi-card arrangement anyway. */
@media (max-width: 560px) {
    .list-card-solo { flex-direction: column; gap: 1rem; }
    .list-card-solo > .list-actions { flex: none; }
}
.dash-layout { display: grid; grid-template-columns: 220px 1fr; gap: 1.5rem; padding: 1.5rem 0; }
.dash-nav { display: flex; flex-direction: column; gap: .25rem; }
.dash-nav a { padding: .5rem .75rem; border-radius: var(--radius); color: var(--gray-600); text-decoration: none; font-weight: 500; }
.dash-nav a.active, .dash-nav a:hover { background: #eef2ff; color: var(--primary); }
.table-wrap { overflow-x: auto; }
table.data { width: 100%; border-collapse: collapse; font-size: .9rem; }
table.data th, table.data td { text-align: left; padding: .6rem .75rem; border-bottom: 1px solid var(--gray-200); }
table.data th { color: var(--gray-500); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; }
table.data td.row-actions { white-space: nowrap; }
/* Scraped product titles run long — Amazon's routinely pass 200 characters — and one of
   them turned a single row into most of a phone screen. Two lines is enough to tell items
   apart; the full title is in the edit form and in the hover tooltip. Clamped at every
   width so row heights stay even, not just on phones. */
table.data .item-name {
    display: -webkit-box; -webkit-box-orient: vertical;
    -webkit-line-clamp: 2; line-clamp: 2;
    overflow: hidden;
    /* A single long word ("THIRDREALITY") sets the column's minimum width, which pushed
       the table 43px wider than the card at 360px — a sideways scroll nobody finds.
       Breaking mid-word costs nothing on a name that's already truncated. */
    overflow-wrap: anywhere;
}
/* Phones: the items table ran off the right edge of the card. Tighter cells, stacked
   action buttons, and no Price column keep every remaining column on screen instead of
   relying on a scroll nobody notices. */
@media (max-width: 640px) {
    table.data { font-size: .85rem; }
    table.data th, table.data td { padding: .5rem .3rem; }
    table.data .col-price { display: none; }
    table.data td.row-actions { white-space: normal; }
    /* width:100% rather than max-content: stacked, each button sized to its own label,
       so "Edit" and "Remove" were visibly different widths in one column. Filling the
       cell makes them match — the cell is still only as wide as the longest label. */
    table.data td.row-actions .btn { display: block; width: 100%; margin: 0 0 .3rem; }
    table.data td img { width: 36px !important; height: 36px !important; }
}

/* "Save from any store" — scannable routes rather than a paragraph */
.ways-list { font-size: .875rem; padding-left: 1.15rem; margin-bottom: .75rem; }
.ways-list li { margin-bottom: .35rem; }
.ways-list strong { color: var(--gray-700); }

.empty-state { text-align: center; padding: 3rem 1rem; color: var(--gray-500); }
.empty-state .big { font-size: 2.5rem; }

/* Utility */
.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; } .mt-3 { margin-top: 1.5rem; } .mt-4 { margin-top: 2rem; }
.mb-2 { margin-bottom: 1rem; }
.text-center { text-align: center; }
.text-muted { color: var(--gray-500); }
.flex-between { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
/* same ".card + .card" neutralization for card children (admin Stores panels): the gap
   is the spacing; the margin only knocked later cards' tops out of line. */
.grid-2 > .card { margin-top: 0; }
.honeypot { position: absolute; left: -9999px; opacity: 0; height: 0; overflow: hidden; }

/* Feature cards (landing) — fixed columns + uniform row height so every card matches */
.feature-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.25rem; grid-auto-rows: 1fr; }
/* neutralize the global ".card + .card" top margin, which otherwise shrinks cards 2-4 */
.feature-grid > .card { margin-top: 0; }
@media (max-width: 900px) { .feature-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .feature-grid { grid-template-columns: 1fr; } }
.feature-icon {
    display: flex; align-items: center; justify-content: center;
    height: 56px; font-size: 1.75rem;
    background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 100%);
    border-radius: var(--radius); margin-bottom: .75rem;
}
/* Three-column variant (/for-teachers, six cards): 3×2 desktop, 2×3 tablet. Must sit
   after the .feature-grid media queries — same specificity, so source order is what
   lets these override at each breakpoint. */
.feature-grid-3col { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 900px) { .feature-grid-3col { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .feature-grid-3col { grid-template-columns: 1fr; } }

/* Draft builder (/start) — sticky reminder that the list isn't saved yet */
.draft-banner {
    position: sticky; top: 64px; z-index: 40;
    background: #eef2ff; border-bottom: 1px solid #c7d2fe;
    padding: .625rem 0; font-size: .925rem; color: #3730a3;
}
.draft-banner .container { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
body.popup .draft-banner { top: 0; } /* chrome-less popup mode: no 64px header to sit under */

/* Blog */
.blog-body h2 { font-size: 1.25rem; margin: 1.5rem 0 .5rem; }
.blog-body h3 { font-size: 1.05rem; margin: 1.25rem 0 .4rem; }
.blog-body p { margin-bottom: .875rem; }
.blog-body ul, .blog-body ol { margin: 0 0 .875rem 1.4rem; }
.blog-body li { margin-bottom: .3rem; }
.blog-body blockquote { border-left: 3px solid var(--primary); padding-left: 1rem; color: var(--gray-600); margin-bottom: .875rem; }
/* display:block so a wide comparison table scrolls inside itself on a phone instead of
   forcing the whole page sideways. Markdig emits a bare <table>, so there's no wrapper
   element to hang the overflow on. */
.blog-body table {
    display: block; max-width: 100%; overflow-x: auto;
    border-collapse: collapse; margin-bottom: .875rem; font-size: .9rem;
}
.blog-body th, .blog-body td { border: 1px solid var(--gray-200); padding: .45rem .6rem; text-align: left; vertical-align: top; }
.blog-body th { background: var(--gray-50); font-weight: 600; }

/* Guides index: jump links to the topic clusters. Every post is listed under each tag it
   carries, so the index is already ~21 cards and lands near 36 once the posts scheduled
   through October publish themselves — far enough that the later topics are invisible
   without a nav telling you they exist. */
.topic-nav { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1.25rem; }
.topic-nav a {
    display: inline-block; padding: .35rem .75rem; border-radius: 999px;
    background: var(--gray-100); border: 1px solid var(--gray-200);
    color: var(--gray-700); text-decoration: none; font-size: .85rem; font-weight: 600;
}
.topic-nav a:hover { background: var(--gray-200); color: var(--gray-900); }
.topic-nav a:focus-visible { outline: 3px solid #c7d2fe; outline-offset: 1px; }
/* The 64px header is sticky, so without an offset every jump parks the <h2> underneath it.
   Phones let that header wrap to a second row (see the 640px block up by .site-header), so
   the offset grows to clear the taller case — overshooting only costs whitespace, while
   undershooting hides the heading you just jumped to. */
.blog-section { scroll-margin-top: 80px; }
@media (max-width: 640px) { .blog-section { scroll-margin-top: 116px; } }

.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline; }
/* Pairs with .htmx-indicator so a button can swap its label for a progress one
   ("Add item" → "Adding…") rather than showing both at once. */
.htmx-request .htmx-indicator-hide { display: none; }
/* hx-disabled-elt should look disabled, or the only feedback is that nothing happens. */
button[disabled] { opacity: .65; cursor: default; }

@media (max-width: 720px) {
    .dash-layout { grid-template-columns: 1fr; }
    .dash-nav { flex-direction: row; overflow-x: auto; }
    .grid-2 { grid-template-columns: 1fr; }
}

/* ===================================================================================
   Rebrand phase 2 — wordmark fonts, kraft ground (home + /start), occasion tiles,
   gift-tag feature cards, /start occasion backdrop. Decisions and measured AA numbers
   live in the phase-1 artifact; kraft-ground text tokens are overridden per page below.
   =================================================================================== */

@font-face {
    font-family: 'Caveat Brand';
    font-weight: 700;
    font-style: normal;
    font-display: block;
    src: url('/fonts/caveat-brand.woff2') format('woff2');
}
@font-face {
    font-family: 'Courier Wordmark';
    font-weight: 400;
    font-style: normal;
    font-display: block;
    src: url('/fonts/courier-wordmark.woff2') format('woff2');
}

.footer-wordmark { font-size: 20px; line-height: 1.1; text-decoration: none; white-space: nowrap; display: inline-block; margin-bottom: .75rem; }
.footer-wordmark .ez { font-family: 'Caveat Brand', cursive; font-weight: 700; color: #9c9cf0; }
.footer-wordmark .rest { font-family: 'Courier Wordmark', 'Courier New', monospace; font-size: .725em; margin-left: .12em; letter-spacing: .035em; color: var(--gray-300); }

/* Kraft pages (home, /start). Ground-sitting secondary text fails AA on kraft at the
   stock grays (measured 3.3:1), so the muted tokens darken to warm equivalents:
   #5e543f = 5.1:1 on the darkest ground edge, and darker-on-white inside cards. */
body.page-kraft {
    background:
        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='220' height='220' filter='url(%23n)' opacity='.055'/%3E%3C/svg%3E"),
        radial-gradient(120% 90% at 50% 0%, #eee2cf 0%, #e3d3b7 100%);
    background-attachment: fixed;
    --gray-500: #5e543f;
    --gray-600: #55503c;
}
body.page-kraft .hero { background: transparent; }
body.page-kraft .hero h1 { color: #362e21; }
body.page-kraft .btn-primary {
    border-radius: 13px;
    box-shadow: 0 6px 14px -6px rgba(91, 91, 214, .55), inset 0 1px 0 rgba(255, 255, 255, .28);
}

/* Hand-drawn underline under the H1's last phrase */
.squig { position: relative; white-space: nowrap; }
.squig svg { position: absolute; left: 2%; bottom: -.12em; width: 96%; height: .14em; }

/* Handwritten margin note (kraft hero only). Sized into WCAG large-text territory
   (≥24px) on purpose: that's what lets it wear the same rose as the step number words
   (#a44f5b = 3.7:1 on the darkest kraft edge; passes at large text, fails small). One
   rule: the hand writes in rose. */
.hand-note {
    font-family: 'Caveat Brand', cursive; font-weight: 700;
    font-size: clamp(2rem, 3.6vw, 2.6rem); line-height: 1.2;
    color: #a44f5b; transform: rotate(-1.6deg); margin-top: 1.4rem;
}

/* Occasion tiles under the hero — each links to /start?occasion= */
.occ-label {
    font-size: .75rem; letter-spacing: .14em; text-transform: uppercase;
    color: var(--gray-500); font-weight: 600; margin-top: 2.25rem;
}
.occ-tiles { display: flex; justify-content: center; gap: .875rem; flex-wrap: wrap; margin-top: .875rem; }
.occ-tile {
    width: 118px; box-sizing: border-box;
    background: #fdfaf2; border: 1px solid #e5d8bd; border-radius: 18px;
    padding: 1rem .625rem .75rem;
    display: flex; flex-direction: column; align-items: center; gap: .5rem;
    text-decoration: none;
    box-shadow: 0 2px 6px rgba(120, 95, 55, .06), 0 16px 30px -20px rgba(120, 95, 55, .5);
}
.occ-tile img { width: 64px; height: 64px; object-fit: contain; }
.occ-tile span { font-size: .78rem; font-weight: 600; color: #3a3226; line-height: 1.25; text-align: center; }
.occ-other { font-size: .8rem; color: var(--gray-500); margin-top: .875rem; }
.occ-other a { color: inherit; text-decoration-color: #d98c94; text-underline-offset: 3px; }

/* Loop clones (script-made copies of edge tiles) exist only while the row slides; in
   wrap mode they'd add phantom width and force sliding everywhere, so default is gone. */
.occ-tile-clone { display: none; }

/* Click arrows for the sliding row: mouse users get a button where touch users swipe.
   Hidden by default; the media block after the sliding windows shows each side only when
   the page script marks more content there (data-scroll-l/r on the wrapper), and only
   for fine pointers. Siblings of the row, so the edge-fade mask never dims them. */
.occ-row { position: relative; }
.occ-arrow {
    display: none; position: absolute; top: 50%; transform: translateY(-50%);
    z-index: 1; width: 38px; height: 38px; border-radius: 50%; padding: 0;
    background: #fdfaf2; border: 1px solid #e5d8bd; color: #3a3226; cursor: pointer;
    align-items: center; justify-content: center;
    box-shadow: 0 2px 6px rgba(120, 95, 55, .12), 0 10px 22px -12px rgba(120, 95, 55, .5);
}
.occ-arrow:hover { background: #fff; }
.occ-arrow-l { left: -8px; }
.occ-arrow-r { right: -8px; }
@media (prefers-reduced-motion: no-preference) {
    .occ-tile { transition: transform .18s ease; }
    .occ-tile:hover { transform: translateY(-3px); }
}
/* Narrow viewports: the Paperless Post move — one snap-scrolling row. The second window
   is the eight-tile orphan band: when the container fits exactly seven tiles, the eighth
   would strand alone on a second row, so the row slides there instead. A second row of
   two is fine; a row of one reads broken. The math: a row of n tiles needs 132n − 14px
   and the container gives min(1100, layout-width) − 32px, so seven-not-eight happens at
   layout widths 942–1073px — but media queries include the scrollbar and layout doesn't,
   so the top edge carries ~18px of slack. "safe center" keeps the row centered in that
   slack when all eight happen to fit, and start-aligns it when they overflow. Recompute
   if the tile count changes. */
@media (max-width: 640px), (min-width: 942px) and (max-width: 1091.98px) {
    .occ-tiles {
        flex-wrap: nowrap; overflow-x: auto; justify-content: safe center;
        padding: 2px 4px 12px; scroll-snap-type: x mandatory;
    }
    .occ-tile { flex: 0 0 auto; scroll-snap-align: start; }
    .occ-tile-clone { display: flex; }
}
@media (max-width: 640px) {
    .occ-tile { flex: 0 0 96px; width: 96px; }
    .occ-tile img { width: 54px; height: 54px; }
}

/* Gift-tag feature cards: clipped corner + mask-punched eyelet. box-shadow dies inside
   clip-path, so the shadow rides filter:drop-shadow and follows the tag silhouette. */
.tag-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .875rem; }
@media (max-width: 820px) { .tag-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .tag-grid { grid-template-columns: 1fr; } }
.gift-tag {
    position: relative;
    background: #fdfaf2; border: 1px solid #e5d8bd; border-radius: 14px;
    padding: .875rem .875rem 1rem;
    clip-path: polygon(24px 0, 100% 0, 100% 100%, 0 100%, 0 24px);
    -webkit-mask: radial-gradient(circle at 22px 22px, transparent 0 4.5px, #000 5px);
    mask: radial-gradient(circle at 22px 22px, transparent 0 4.5px, #000 5px);
    filter: drop-shadow(0 3px 5px rgba(120, 95, 55, .22));
}
.gift-tag::before {
    content: ""; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px;
    box-sizing: border-box; border-radius: 50%; border: 3px solid #e6d9ba;
}
.gift-tag .tag-head {
    display: flex; align-items: center; justify-content: space-between; gap: .5rem;
    padding-left: 22px; min-height: 44px; margin-bottom: .375rem;
}
.gift-tag h3 { font-size: .95rem; margin: 0; color: #362e21; }
.gift-tag .tag-head img { width: 42px; height: 42px; flex: none; }
.gift-tag p { font-size: .84rem; color: var(--gray-500); line-height: 1.5; margin: 0; }
.tag-grid .gift-tag:nth-child(1) { transform: rotate(-1deg); }
.tag-grid .gift-tag:nth-child(2) { transform: rotate(.8deg); }
.tag-grid .gift-tag:nth-child(3) { transform: rotate(-.6deg); }
.tag-grid .gift-tag:nth-child(4) { transform: rotate(1.1deg); }
.tag-grid .gift-tag:nth-child(5) { transform: rotate(-.8deg); }
.tag-grid .gift-tag:nth-child(6) { transform: rotate(.9deg); }

/* /start: clay backdrop for the chosen occasion. Themes decorate the ground; text always
   sits on a plain card above it — the element hides whenever it could crowd content.
   It rides the end of the content column rather than the viewport: ground decoration has
   to stay behind everything (z-index -1), and the footer is opaque and in flow, so a
   viewport-anchored element lost its bottom 80px to the footer at every scroll position —
   the pencil's collar and tip, a third of the cake. Anchoring to main keeps it clear of
   the footer at any viewport height, and main's 4rem gap above the footer absorbs the
   overhang. */
main:has(> .occ-backdrop) { position: relative; }
.occ-backdrop {
    position: absolute; right: clamp(-40px, 2vw, 48px); bottom: -20px;
    width: min(300px, 24vw); z-index: -1; pointer-events: none;
}
.occ-backdrop img { width: 100%; height: auto; display: block; }
@media (max-width: 1200px) { .occ-backdrop { display: none; } }

/* "How it works" — three steps numbered in the brand hand (one/two/three: Caveat's
   subset carries lowercase but no digits, and words read like a note from a person).
   Step rose #a44f5b measures 3.7:1 on the darkest kraft edge (large-text AA is 3:1). */
.steps { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; max-width: 860px; margin-left: auto; margin-right: auto; padding: 0; }
.step { text-align: center; padding: 0 .75rem; }
.step-word {
    display: block; font-family: 'Caveat Brand', cursive; font-weight: 700;
    font-size: 2.1rem; line-height: 1.1; color: #a44f5b; transform: rotate(-2deg);
}
/* A hand-drawn stroke under each number word, in the same rose. It lives inside the
   rotated word, so it tilts with it; em units keep it scaled to the word. */
.step-word::after {
    content: ""; display: block; width: 2.6em; height: .3em; margin: .06em auto 0;
    background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 8'%3E%3Cpath d='M2 5 C 14 2.5, 26 6.5, 38 4 S 52 3.5, 58 5' fill='none' stroke='%23a44f5b' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E") center / 100% auto no-repeat;
}
.step strong { display: block; color: var(--gray-800); font-size: 1rem; margin-top: .15rem; }
body.page-kraft .step strong { color: #362e21; }
.step p { font-size: .875rem; color: var(--gray-500); margin: .25rem 0 0; }
@media (max-width: 640px) {
    .steps { grid-template-columns: 1fr; gap: 1.5rem; }
}

/* Section heading "on tape": one deliberate piece of washi holding the label to the
   page. This is the sanctioned single-use of tape — scattered tape on tiles was tried
   during the texture round and cut. */
.tape-label {
    position: relative; display: table; margin-left: auto; margin-right: auto;
    padding: .3rem 1.4rem .35rem; transform: rotate(-1.2deg);
    color: #362e21; text-align: center;
}
.tape-label::before {
    content: ""; position: absolute; inset: 0; z-index: -1;
    background: rgba(252, 246, 232, .72);
    border-left: 2px dashed rgba(120, 95, 55, .28);
    border-right: 2px dashed rgba(120, 95, 55, .28);
    box-shadow: 0 2px 5px rgba(120, 95, 55, .18);
}
body.page-kraft h2 { color: #362e21; }

/* Sliding tile row (both windows above): hide the OS scrollbar; the affordance is a
   dynamic edge fade (mask-image, widths set by the page script — fades only where more
   content is) plus the peeking partial tile. */
@media (max-width: 640px), (min-width: 942px) and (max-width: 1091.98px) {
    .occ-tiles {
        scrollbar-width: none;
        -webkit-mask-image: linear-gradient(to right,
            transparent 0, #000 var(--fade-l, 0px),
            #000 calc(100% - var(--fade-r, 0px)), transparent 100%);
        mask-image: linear-gradient(to right,
            transparent 0, #000 var(--fade-l, 0px),
            #000 calc(100% - var(--fade-r, 0px)), transparent 100%);
    }
    .occ-tiles::-webkit-scrollbar { display: none; }
}

/* Arrows appear in the same sliding windows, but only for fine pointers (a phone swipes;
   a mouse clicks) and only on a side that actually has more content. */
@media (pointer: fine) and (max-width: 640px),
       (pointer: fine) and (min-width: 942px) and (max-width: 1091.98px) {
    .occ-row[data-scroll-l] .occ-arrow-l { display: flex; }
    .occ-row[data-scroll-r] .occ-arrow-r { display: flex; }
}

/* "A list that lives all year" — the follow story. One quiet centered moment: the
   sprout, a taped label, one paragraph, and the routes to the audience pages. On-kraft
   links inherit the dark warm text color with the rose underline (the indigo link color
   measures under 4.5:1 on the kraft ground). */
.follow-section { text-align: center; }
.follow-section img { width: 84px; height: 84px; object-fit: contain; margin-bottom: .35rem; }
.follow-blurb { max-width: 56ch; margin: .75rem auto 0; color: var(--gray-500); }
.follow-links { font-size: .875rem; color: var(--gray-500); margin-top: .6rem; }
.follow-links a { color: inherit; text-decoration-color: #d98c94; text-underline-offset: 3px; }

/* Kraft pages: plain cards wear the warm surface (the /start form cards were the last
   cool-white surfaces in the flow). */
body.page-kraft .card {
    background: #fdfaf2; border-color: #e5d8bd; border-radius: 18px;
    box-shadow: 0 2px 6px rgba(120, 95, 55, .06), 0 16px 30px -20px rgba(120, 95, 55, .5);
}
.card-inset { background: var(--gray-50); }
body.page-kraft .card-inset { background: #f8f2e5; }

/* Item cards as hang-tags on kraft: eyelet punched straight through (photo included,
   like a real hang tag) plus small alternating tilts. Layout and aspect unchanged.
   Cream ring so the punched hole reads against product photos; drop-shadow instead of
   box-shadow because the mask clips the latter. */
body.page-kraft .item-card {
    position: relative;
    background: #fdfaf2; border-color: #e5d8bd; border-radius: 16px;
    box-shadow: none;
    filter: drop-shadow(0 3px 5px rgba(120, 95, 55, .2));
    -webkit-mask: radial-gradient(circle at 22px 22px, transparent 0 4.5px, #000 5px);
    mask: radial-gradient(circle at 22px 22px, transparent 0 4.5px, #000 5px);
}
body.page-kraft .item-card::before {
    content: ""; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px;
    box-sizing: border-box; border-radius: 50%; border: 3px solid rgba(253, 250, 242, .92);
    z-index: 1; pointer-events: none;
}
body.page-kraft .item-card .item-img { background: #fff; }
body.page-kraft .item-grid .item-card:nth-child(3n+1) { transform: rotate(-.6deg); }
body.page-kraft .item-grid .item-card:nth-child(3n+2) { transform: rotate(.5deg); }
body.page-kraft .item-grid .item-card:nth-child(3n) { transform: rotate(-.3deg); }

/* Kraft brand-conformance pass for /start (audited against the home standards).
   The full ink ramp warms on kraft pages so headings, body text, labels, item titles,
   the wordmark's "wishlist", and the footer all read in the same warm family the home
   page set. All tones re-measured AA on their grounds (darkest kraft edge #e3d3b7:
   #514734 = 6.2:1, #3a3226 = 8.6:1). */
body.page-kraft {
    --gray-700: #514734;
    --gray-800: #3a3226;
    --gray-900: #2c2618;
}
body.page-kraft h1 { color: #362e21; }
body.page-kraft .draft-banner {
    background: rgba(253, 250, 242, .95); border-bottom-color: #e5d8bd; color: #3a3226;
}
body.page-kraft .alert-info { background: #f8f2e5; border-color: #e0d3b2; color: #4a4232; }
/* alert-warning stays amber on purpose: semantic warning color, not a surface. */
body.page-kraft .btn-secondary { background: #fffdf8; border-color: #d9cdb2; color: #514734; }
body.page-kraft .btn-secondary:hover { background: #f8f2e5; }
body.page-kraft .form-control { border-color: #d9cdb2; }
body.page-kraft .ezw-scan-img, body.page-kraft .ezw-scan-l { background: #f0e8d5; }
body.page-kraft .bookmarklet-offer kbd { background: #f8f2e5; border-color: #d9cdb2; color: #514734; }
/* Footer on kraft pages goes warm espresso, matching the decided mock (gray-900 is
   overridden above, which turns the footer ground warm; the text follows). */
body.page-kraft .site-footer { color: #cfc4ae; }
body.page-kraft .site-footer a { color: #ded5c0; }
body.page-kraft .site-footer .disclosure { color: #cfc4ae; }
body.page-kraft .footer-wordmark .rest { color: #ece4d2; }

/* Kraft reaches /explore and /w/: modals, badges, list thumbnails, and the come-back
   bar wear the warm surface. Semantic colors stay untouched on purpose: available
   (green), taken (amber), error (red), warning (amber) are states, not surfaces. */
body.page-kraft .modal { background: #fdfaf2; }
body.page-kraft .modal-backdrop { background: rgba(58, 50, 38, .55); }
body.page-kraft .badge-purchased { background: #f0e8d5; }
body.page-kraft .list-thumbs img,
body.page-kraft .list-thumbs-more,
body.page-kraft .list-thumbs .img-fallback { border-color: #e0d3b2; background: #f3ecdc; }
body.page-kraft #ezw-comeback { background: #fdfaf2; border-color: #e5d8bd; }

/* Kraft pages: horizontal rules and blog hairlines warm with the page. */
body.page-kraft hr { border-top-color: #e5d8bd; }

/* About page: the motto as quoted content, and small element shelves between sections. */
.about-motto { font-size: clamp(1.8rem, 4vw, 2.4rem); margin: .25rem 0 0; }
.about-shelf { display: flex; gap: 1.25rem; align-items: flex-end; justify-content: center; margin: .75rem 0 1rem; }
.about-shelf img { display: block; }
.about-contact { display: flex; align-items: center; gap: .875rem; }

/* No-photo / broken-image fallback is the clay gift mark (an <img> inside the
   .img-fallback span; the old emoji sizing rules above are inert once no text renders). */
.img-fallback img { width: 44px; height: 44px; object-fit: contain; }
.item-img .img-fallback img { width: 84px; height: 84px; }
.list-thumbs .img-fallback img { width: 26px; height: 26px; }
