/* ================= FONTS ================= */
@font-face {
  font-family: 'Rodin';
  src: url("fonts/Rodin.woff2") format('woff2');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'DSBios';
  src: url("fonts/DSBios.ttf") format('truetype');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

:root{
  --screen-bg:#f4f4f4;
  --grid-line: rgba(0,0,0,0.035);
  --bar-bg:#e9e9e9;
  --text-dark:#2a2a2a;
  --card-bg:#ffffff;
  --card-shadow: rgba(0,0,0,0.18);
  --pink: #ff2f92;
  --pink-glow: rgba(255,47,146,0.35);
  --preview-border:#ffffff;
  --preview-fill:#a9a9a9;
  --project-color: #ff2f92;
}

/* ================= RESET ================= */
*{box-sizing:border-box; margin:0; padding:0;}
html,body{
  width:100%;
  /* height:100dvh (where supported) is the ACTUAL currently-visible
     viewport height on mobile Safari/Chrome, which shrinks when the
     browser's own toolbar is showing — height:100vh assumes that
     toolbar is fully collapsed and is taller than what's really
     visible, pushing bottom-anchored content (the menu scrollbar, the
     viewer's back button) below the fold with no way to scroll to it
     (overflow:hidden below). This has to be a `height` override, not
     `min-height` — min-height can only grow something taller, never
     shrink a height that's already set larger by the line above. */
  height:100vh; height:100dvh;
  font-family:'DSBios','Trebuchet MS','Helvetica Neue', Arial, sans-serif;
  background:#000;
  overflow:hidden;
  -webkit-tap-highlight-color:transparent;
}
/* Stop the browser's native "pick up and drag this image" behavior
   from hijacking pointer events meant for the custom carousel drag. */
img{
  -webkit-user-drag:none;
  user-drag:none;
  -webkit-touch-callout:none;
  user-select:none;
}

/* ================= SCREEN SHELL ================= */
#screen{
  position:relative;
  width:100%; height:100vh; height:100dvh; /* see the html,body rule above for why this must be `height`, not `min-height` */
  background:
    repeating-linear-gradient(0deg, var(--grid-line) 0px, var(--grid-line) 1px, transparent 1px, transparent 6px),
    var(--screen-bg);
  display:flex; flex-direction:column;
  overflow:hidden;
}
/* The menu page reserves room for the scrollbar (now much thinner via
   SB_VSCALE) plus a small margin, since #scrollbar is position:absolute
   and no longer takes up space in this flex column. */
body.page-menu #screen{
  padding-bottom: 16px;
}

/* Continues the menu's launch transition on the viewer page: the menu
   fades the screen to white as the project card flies off, so this page
   picks up from white and fades the reveal in, rather than appearing
   instantly. */
#entry-white-overlay{
  position:fixed; inset:0;
  background:#fff;
  opacity:1;
  pointer-events:none;
  z-index:999;
  transition: opacity .5s ease;
  display:flex; align-items:center; justify-content:center;
}

/* ================= STARTUP BOOT LOGO (menu page only — see
   playStartupLogo() in menu.js) =================
   Plays inside #entry-white-overlay, on top of its solid white, before
   the normal card drop-in starts. All positioning below is in %, derived
   directly from the source art's real pixel layout (title canvas
   1086x129; the O-shaped gap sits at columns 591-797; RAHMONE O.png is
   185x129 filling that gap exactly; FLYING SECOND O.png is 159x129,
   centered within that same gap) — so it stays correct at any render
   size without needing per-breakpoint tuning. */
#startup-logo{
  position:relative;
  width:min(70vw, 620px);
  aspect-ratio: 1086 / 129;
  opacity:1;
  transition: opacity .45s ease;
}
#startup-text{
  position:absolute; inset:0;
  width:100%; height:100%;
  opacity:0;
  transition: opacity .55s ease;
}
/* The primary "O" — flies DOWN from above into its resting spot (the
   actual letter-O slot, unchanged). Checked against reference.mp4: the
   two squares do NOT end up concentric/nested on the same center point —
   the lighter square rests clearly offset down-and-right from the bold
   one, overlapping only at one corner, not hidden behind it. */
#startup-o{
  position:absolute; top:0; height:100%;
  left:54.42%; width:17.03%;
  opacity:0;
  transform:translateY(-140%);
  transition: opacity .6s ease, transform .6s cubic-bezier(.22,1,.36,1);
}
/* The lighter "second screen" O — flies UP from below into its OWN
   resting spot, a clear gap below the primary O, not touching it.
   Measured directly off "Website Intro Logo ALTOGETHER.png" (the actual
   target composition): the square sits 10px below the O's bottom edge
   and dead-centered under it, both at the O's own 129px native scale —
   108% down (100% clears the O's own height, +~8% for the gap) and 0%
   sideways, not the overlapping offset the video alone suggested.
   Horizontal left nudged from 55.62% to 56.77%: RAHMONE O.png carries a
   soft motion-blur fringe on its left edge (baked in for its fly-in),
   so its full alpha bbox reads center-symmetric but the solid/visible
   O shape actually sits ~1.15% of the container further right — this
   square (which has no such fringe, symmetric in its own canvas) needs
   to sit under that true visual center, not the fringe-skewed bbox center. */
#startup-o-flying{
  position:absolute; top:0; height:100%;
  left:56.77%; width:14.64%;
  opacity:0;
  transform:translate(0, 230%);
  transition: opacity .6s ease, transform .6s cubic-bezier(.22,1,.36,1);
}
#startup-logo.startup-in #startup-text{
  opacity:1;
}
#startup-logo.startup-in #startup-o{
  opacity:1;
  transform:translateY(0);
}
#startup-logo.startup-in #startup-o-flying{
  opacity:1;
  transform:translate(0, 108%);
}
#startup-ripples{
  position:absolute; inset:0;
  pointer-events:none;
}
/* Spawned/removed from JS: one big ripple behind each O as they land,
   followed by six smaller ones (three per O, see playStartupLogo() for the
   exact directions/timing). Each ripple's own animation-duration is set
   per-instance in JS (--ripple-op/--ripple-scale too), since the big and
   mini ripples run at different speeds/sizes.

   Opacity and motion are deliberately TWO separate animations sharing the
   same duration, not one keyframe set doing both: opacity needs its own
   thin -> heavy -> thin hump (its own eased segments), while the
   scale/translate needs one continuous, un-interrupted easing the whole
   way through. Driving both from a single multi-stop keyframe made the
   motion visibly pause at the stop shared with the opacity hump — ease-out
   decelerating into it and ease-in accelerating back out of it stack into
   a dead moment right at the opacity peak. Splitting them out means the
   square keeps flying outward smoothly no matter what opacity is doing. */
.startup-ripple{
  position:absolute; top:0; height:100%;
  opacity:0;
  transform:scale(.85) translate(0, 0);
  animation:
    startup-ripple-opacity 1.3s ease-in-out forwards,
    startup-ripple-move 1.3s ease-out forwards;
}
/* Echoes from the primary (top) O — centered on its own resting slot in
   the word (same 62.935% center as #startup-o), but sized to FLYING
   SECOND O.png's own aspect ratio (width:14.64%, not 17.03%) — every
   ripple, top or bottom, renders that same asset (see ASSET_STARTUP_O_
   FLYING in menu.js), so using #startup-o's wider box here (sized for a
   *different* image, RAHMONE O.png) stretched it into a visibly different
   shape from the bottom echoes. */
.startup-ripple-top{
  left:55.615%; width:14.64%;
}
/* Echoes from the flying (bottom) O — its own resting slot below the word
   (same translateY(108%) offset as #startup-o-flying) — a separate origin
   point, not the same spot the top echoes fan from. */
.startup-ripple-bottom{
  left:56.77%; width:14.64%;
  transform:translate(0, 108%) scale(.85) translate(0, 0);
  animation-name: startup-ripple-opacity, startup-ripple-move-bottom;
}
@keyframes startup-ripple-opacity{
  0%{ opacity:0; }
  32%{ opacity:var(--ripple-op, .5); }
  100%{ opacity:0; }
}
@keyframes startup-ripple-move{
  from{ transform:scale(.85) translate(0, 0); }
  to{ transform:scale(var(--ripple-scale, 2.6)) translate(var(--ripple-dx, 0), var(--ripple-dy, 0)); }
}
@keyframes startup-ripple-move-bottom{
  from{ transform:translate(0, 108%) scale(.85) translate(0, 0); }
  to{ transform:translate(0, 108%) scale(var(--ripple-scale, 2.6)) translate(var(--ripple-dx, 0), var(--ripple-dy, 0)); }
}
/* The big ripple only: the primary and flying O's own resting slots sit
   just ~6px apart, so growing two squares that large right on that exact
   spot (no drift) makes them bleed into one merged blob well before they
   reach full size. A small FIXED offset — present for the ripple's whole
   life, not animated — gives each one room to grow in place as its own
   square without the pair ever reading as "splitting apart". */
.startup-ripple-big.startup-ripple-top{
  animation-name: startup-ripple-opacity, startup-ripple-move-big-top;
}
.startup-ripple-big.startup-ripple-bottom{
  animation-name: startup-ripple-opacity, startup-ripple-move-big-bottom;
}
@keyframes startup-ripple-move-big-top{
  from{ transform:translateY(-55%) scale(.85) translate(0, 0); }
  to{ transform:translateY(-55%) scale(var(--ripple-scale, 2.6)) translate(0, 0); }
}
@keyframes startup-ripple-move-big-bottom{
  from{ transform:translate(0, 108%) translateY(55%) scale(.85) translate(0, 0); }
  to{ transform:translate(0, 108%) translateY(55%) scale(var(--ripple-scale, 2.6)) translate(0, 0); }
}

/* Everything but the gallery image itself starts faded/offset while
   #entry-white-overlay is still opaque, then eases into place as the
   white wash clears — arrows slide in from the sides, back/caption bar
   rise up from below. viewer.js removes body.viewer-entering at the
   same moment it starts fading the overlay out on entry, so both happen
   together rather than the chrome just popping in already-visible
   underneath the white — and re-adds it on the way out (back button),
   playing the same states/transitions in reverse: arrows slide back out,
   bars sink back down, in sync with the fade back to white. */
body.viewer-entering .gallery-arrow.left{
  opacity:0;
  transform:translateY(-50%) translateX(-48px);
}
body.viewer-entering .gallery-arrow.right{
  opacity:0;
  transform:translateY(-50%) translateX(48px);
}
body.viewer-entering #info-bar,
body.viewer-entering #back-bar{
  opacity:0;
  transform:translateY(44px);
}
#info-bar, #back-bar{
  transition: opacity .55s ease, transform .6s cubic-bezier(.22,1,.36,1);
}

/* ================= STATUS BAR (shared) ================= */
#statusbar{
  display:flex; align-items:center; justify-content:space-between;
  padding:11px 12px;
  background:transparent;
  font-size:22px; color:var(--text-dark);
  flex-shrink:0;
}
/* gap trimmed from 12px to 8px specifically to free up a bit more room
   for the title on narrow phones — every px here comes straight out of
   .title's own available width (it gets whatever's left over). */
#statusbar .left, #statusbar .right{
  display:flex; align-items:center; gap:8px;
}
#statusbar .left{ min-width:0; } /* lets .title shrink/truncate instead of wrapping or overflowing */
#statusbar .title{
  font-weight:normal; margin-left:4px;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  /* .title is itself a flex item (of .left's own flex context), which
     defaults to min-width:auto — meaning without this, it would refuse
     to shrink below its TEXT's full natural width and grow past .left's
     available space instead of ever actually clipping, overlapping the
     date/battery on the right at narrow widths. min-width:0 + flex:1 is
     what actually lets overflow:hidden (and the marquee below) mean
     anything. */
  min-width:0; flex:1 1 auto;
  /* .left gets ALL leftover space up to exactly where .right begins (see
     justify-content:space-between on #statusbar), so without this the
     clipped/marqueed text runs flush against the date with zero gap —
     barely noticeable for a fixed "…" ellipsis glyph, but very visible
     once the marquee is scrolling real characters right up to the edge. */
  margin-right:28px;
}
/* .title-track is the actual text — a separate inline-block from .title
   (the fixed-width clipping box) so JS can slide it via transform without
   fighting .title's own overflow:hidden/ellipsis. Static (no animation)
   whenever the label fits; viewer.js/menu.js only add the marquee
   animation, via --marquee-distance and --marquee-duration, when the
   track's own scrollWidth actually exceeds its container — so most
   labels never move at all. */
#statusbar .title-track{
  display:inline-block;
}
#statusbar .title-track.marquee{
  animation: title-marquee var(--marquee-duration, 6s) linear 1 forwards;
}
/* Holds at the start long enough to read the opening words, then glides
   left ONCE until the clipped tail is fully revealed and stays there —
   no scrolling back, no looping. Runs fresh from the top again only when
   the label itself changes or the viewport is resized (see
   updateTitleMarquee() in viewer.js/menu.js), not on any fixed interval. */
@keyframes title-marquee{
  0%, 15%   { transform:translateX(0); }
  100%      { transform:translateX(var(--marquee-distance, 0)); }
}
.icon{ width:18px; height:18px; display:inline-block; }
.statusicon{
  height:20px; width:auto; display:block;
  image-rendering:-webkit-optimize-contrast;
  image-rendering:crisp-edges;
}
#icon-battery{ height:16px; }

/* ================= VIEWER: DARK MODE (opt-in per project) ================= */
/* Set via a project's JSON "theme":"dark" (see viewer.js renderStaticInfo)
   for projects whose own art is dark-background — PLAYSTYLE's screenshots
   sit oddly on the default light-gray screen otherwise. Only touches the
   screen backdrop and status bar; the info/back bar's own DS-chrome
   texture assets are shared across every project and stay as-is. */
body.viewer-dark #screen{
  background:
    repeating-linear-gradient(0deg, rgba(255,255,255,0.035) 0px, rgba(255,255,255,0.035) 1px, transparent 1px, transparent 6px),
    #121212;
}
body.viewer-dark #statusbar{
  color:#eaeaea;
}
/* Only the speaker icon needs flipping — it's solid dark linework with
   no color of its own, so it'd vanish on the dark background. The wifi
   and battery icons read fine as-is and are deliberately left alone. */
body.viewer-dark #icon-speaker{
  filter: invert(1);
}

/* ================= MENU: PREVIEW SCREEN ================= */
#preview-wrap{
  flex:1;
  display:flex; align-items:center; justify-content:center;
  padding: 2vh 0 1vh;
  min-height:0;
}
#preview{
  width:min(60vw, 560px);
  height:80%;
  max-height:min(48vh, 420px);
  background:transparent;
  display:flex; align-items:center; justify-content:center;
  color:#eee; font-size:20px; letter-spacing:1px;
  overflow:hidden;
  position:relative;
  /* Governs the fade for a still-wrapped gift card (see updateStyles() in
     menu.js) — the intro sequence manages its OWN inline transition
     during the initial page-load reveal, overriding this one while it
     runs, then clears its inline override afterward so this becomes the
     only thing driving opacity changes from then on. */
  transition:opacity .25s ease;
}
#preview .preview-static-img{
  width:100%; height:100%; object-fit:cover;
  opacity:0; transition:opacity .25s ease;
}
#preview .preview-static-img.show{ opacity:1; }
#preview .banner-layer{ opacity:1; }

/* Pop-in reveal, like a 3DS icon banner appearing: starts invisible/
   tiny, scales up to full size with a slight overshoot bounce. Applied
   to a wrapper around whatever content renders (static/parallax/sprite)
   so it doesn't fight with each type's own internal animation. */
.banner-reveal{
  width:100%; height:100%;
  display:flex; align-items:center; justify-content:center;
  position:relative;
  transform-origin:center center;
  animation: banner-pop-in 300ms cubic-bezier(.22,1,.36,1);
}
@keyframes banner-pop-in{
  0%   { transform:scale(0.6); opacity:0; }
  70%  { transform:scale(1.02); opacity:1; }
  100% { transform:scale(1); opacity:1; }
}

/* ================= MENU: ANIMATED BANNERS ================= */
.banner-parallax{
  position:absolute; inset:0; width:100%; height:100%;
  overflow:hidden;
  perspective:900px; /* gives spin3d layers real 3D depth instead of a flat squash */
}
.banner-layer{
  position:absolute; inset:0;
  width:100%; height:100%;
  object-fit:contain;
  image-rendering:pixelated;
  animation-name: banner-bob;
  animation-duration: var(--dur, 2200ms);
  animation-iteration-count: infinite;
  animation-timing-function: ease-in-out;
  --amp: 6px;
}
@keyframes banner-bob{
  0%, 100% { transform: translateY(calc(var(--amp) * -1)); }
  50%      { transform: translateY(var(--amp)); }
}
/* Continuous 3D turn around the vertical axis, like a spinning coin/logo
   — reads as 3D (foreshortens toward the edge) rather than the flat
   pinwheel look of a plain rotate(). Overrides the bob animation above. */
.banner-layer.banner-spin3d{
  animation-name: banner-spin3d;
  animation-duration: var(--spin-dur, 3200ms);
  animation-timing-function: linear;
  /* rotateY + perspective visually bulges slightly at the corners as it
     turns (perspective foreshortening) even though the flat plane itself
     never exceeds its own rectangle — inset:0 (inherited from
     .banner-layer above) leaves zero room for that bulge before
     .banner-parallax's overflow:hidden clips it. A bit of breathing room
     fixes it for any spin3d asset, not just ones whose own art happens to
     have padding baked in already.
     Explicit top/left/width/height (not inset + width/height:auto) — img
     is a replaced element, and giving a replaced element auto width AND
     height under inset constraints falls back to its native intrinsic
     pixel size instead of solving for the inset-implied box, which made
     it balloon far past the wrap rather than shrink into it. Spelling out
     the exact percentages sidesteps that sizing algorithm entirely. */
  top:14%;
  left:14%;
  width:72%;
  height:72%;
}
@keyframes banner-spin3d{
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}
.banner-sprite{
  width:100%; height:100%;
  background-repeat:no-repeat;
  background-position: 0 0;
  image-rendering:pixelated;
}
.banner-party-scene{
  display:flex; align-items:flex-end;
  gap:10px;
}
.banner-party-char{
  background-repeat:no-repeat;
  image-rendering:pixelated;
  flex-shrink:0;
}
.banner-party-wrap{
  display:flex; flex-direction:column; align-items:center;
  justify-content:center;
  gap:36px;
}
.banner-party-logo{
  image-rendering:pixelated;
  display:block;
}
.banner-spin-wrap{
  position:relative;
  width:100%; height:100%;
}
/* ring/icon/center are all sized+positioned entirely from JS (see the
   'spin' branch of renderBanner() in menu.js) — one shared scale factor
   applied to each layer's own real native pixel dimensions, with explicit
   negative margins centering each on the wrap's exact center point.
   That's deliberate: these three are designed together like stacked
   Photoshop layers meant to overlay at their true relative sizes, not
   independent art each free to be stretched to its own arbitrary %
   width/height (which was quietly distorting their sizes relative to
   each other). transform is left free for .banner-spin-ring's own
   rotation animation below, hence margin-based centering instead of the
   more usual translate(-50%,-50%) (which the animation would overwrite). */
.banner-spin-ring, .banner-spin-icon, .banner-spin-center{
  position:absolute;
  top:50%; left:50%;
  pointer-events:none;
}
/* Soft shaded dots, not hard-edged pixel art, hence smooth (not
   pixelated) rendering — matches how pixelated:false is used for
   line-drawing/photographic banner layers elsewhere. */
.banner-spin-ring{
  animation: banner-spin var(--spin-duration, 9000ms) linear infinite;
  transform-origin:center center;
}
@keyframes banner-spin{
  from { transform:rotate(360deg); }
  to   { transform:rotate(0deg); }
}
.banner-spin-icon{ z-index:0; }
.banner-spin-center{ z-index:1; }

/* ================= MENU: TOOLTIP ================= */
#tooltip-wrap{
  display:flex; justify-content:center;
  padding:1vh 0 0;
  flex-shrink:0;
  position:relative;
  z-index:5; /* must paint above .card-selector-frame (z-index:3) so the
    pointer stays visible once transformed down into its territory */
  /* Purely decorative — no click handler anywhere touches tooltip or
     tooltip-wrap — but its own box genuinely overlaps down into the
     selector frame's territory (see the z-index comment above), and
     without this a press landing in that overlap (particularly just
     above the frame, where the tooltip's own bottom edge extends past
     the frame's top) was silently swallowed here instead of reaching
     #track-viewport's drag listener underneath. Matches the same
     pointer-events:none already used on .card-selector-frame and
     .card-selector-glow for the identical reason. */
  pointer-events:none;
  /* Governs the fade for a still-wrapped gift card (see updateStyles() in
     menu.js) — the intro sequence manages its OWN inline transition
     during the initial page-load reveal, overriding this one while it
     runs, then clears its inline override afterward so this becomes the
     only thing driving opacity changes from then on. */
  transition:opacity .25s ease;
}
#tooltip{
  position:relative;
  background-image:url("assets/ui/tooltip.png");
  background-repeat:no-repeat;
  background-position:center top;
  background-size:100% 100%;
  /* aspect ratio matches the exported tooltip asset (391 x 138). The
     asset's bottom ~20% is the pointer notch, so padding-bottom nudges
     the text up to sit in the actual bubble body, not the notch. */
  --tt-w: min(70vw, 391px);
  width: var(--tt-w);
  height: calc(var(--tt-w) * (138/391));
  padding: 16px 24px calc(var(--tt-w) * (138/391) * 0.22);
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:2px;
  text-align:center;
  box-sizing:border-box;
}
#tooltip-title{
  font-family:'Rodin', 'DSBios', 'Trebuchet MS', sans-serif;
  font-weight:normal;
  font-size:19px;
  color:#5c5c5c;
  line-height:1.15;
  user-select:none;
  -webkit-user-select:none;
  -webkit-font-smoothing:none;
  -moz-osx-font-smoothing:grayscale;
  font-smooth:never;
  text-rendering:optimizeSpeed;
}
#tooltip-category{
  font-family:'Rodin', 'DSBios', 'Trebuchet MS', sans-serif;
  font-weight:normal;
  font-size:13px;
  color:#9a9a9a;
  line-height:1.15;
  user-select:none;
  -webkit-user-select:none;
  -webkit-font-smoothing:none;
  -moz-osx-font-smoothing:grayscale;
  font-smooth:never;
  text-rendering:optimizeSpeed;
}
/* "New!" flag ahead of the category text (see p.isNew in menu.js) —
   same font/rendering treatment as the rest of #tooltip-category, just
   its own colour and a bit of weight so it actually reads as a badge. */
.tooltip-new-badge{
  color:#63c3e9;
}

/* ================= MENU: CARD ROW ================= */
#row-wrap{
  position:relative;
  padding: 0 0 1.5vh 0;
  flex-shrink:0;
}
#track-viewport{
  position:relative;
  overflow:hidden;
  /* Padding sized to comfortably contain the selector frame's full
     extent above/below a card, even in the worst case (a project using
     a full-size custom plate, like About Me, whose frame extends
     further than the smaller windowed-thumb cards). This avoids ever
     needing the frame to overflow this box at all — overflow-x:hidden +
     overflow-y:visible was tried earlier, but mismatched overflow-x/y
     values get silently converted to overflow-y:auto by the CSS spec,
     which creates a real scrollable region (hence a stray scrollbar). A
     single overflow:hidden value with enough padding avoids that
     pitfall entirely. No fixed horizontal padding — centering is
     handled in JS via dead-space spacers at the start/end of the
     track, DSi-menu style, so the very first and very last cards can
     still reach dead-center. */
  padding: 6px 0 34px 0;
}
#track{
  position:relative; /* containing block for .folder-tray's absolute left/width (see menu.js updateFolderTray()) */
  display:flex;
  align-items:flex-end;
  will-change:transform;
  cursor:grab;
  touch-action:pan-y;
  user-select:none;
  -webkit-user-select:none;
}
#track.dragging{ cursor:grabbing; }

/* DSi "new gift" open animation overlay — position:fixed (not absolute)
   so it's positioned against the viewport itself, immune to
   #track-viewport's overflow:hidden regardless of DOM nesting. left/top/
   width/height are set in JS to match the real card's rect at trigger
   time; the card itself is hidden underneath for the duration. */
#gift-open-overlay{
  position:fixed;
  /* Above #tooltip-wrap (z-index:5) too now — the box rises high enough
     (see GIFT_JUMP_PX in menu.js) to pass well above the tooltip bubble
     entirely, matching the DSi reference where the floating cartridge
     clearly reads in front of everything else in the menu while it's up
     there. */
  z-index:6;
  pointer-events:none;
  display:none;
}
#gift-open-overlay.active{ display:block; }
#gift-open-img, #gift-open-img-b{
  position:absolute;
  left:50%; bottom:0;
  transform:translate(-50%, 0);
  image-rendering:pixelated;
  /* transform/transition are driven entirely from playGiftUnwrap() in
     menu.js — the burst, hold, and fall each need their own duration/
     easing, so there's no single static value that fits all of them. */
}
/* #gift-open-img-b exists only to crossfade in the mid-hold pose change
   (frame 3 -> frame 4) while the box is stationary, instead of a hard
   image swap — the two share identical positioning so either can be "the
   visible one" at any moment; see crossfadeTo() in menu.js. */
#gift-open-img-b{ opacity:0; }
/* The real project icon, revealed inside the box as it opens — same
   window percentages as .card .thumb (so it lines up exactly with where
   the real card's icon sits once this animation hands off to it), but
   positioned relative to #gift-open-overlay's own static rect (the card's
   footprint) rather than a moving/frame-varying element. Its opacity and
   translateY/scale are driven from playGiftUnwrap() in lockstep with
   #gift-open-img, so it rises and falls together with the box while
   fading in as the wrapping fades away. */
#gift-open-icon{
  position:absolute;
  left:16.89%; top:12.84%;
  width:66.22%; height:57.47%;
  object-fit:cover;
  opacity:0;
}

.card{
  flex-shrink:0;
  width:138px; height:159px;   /* ~15% bigger, same aspect ratio as the box/card art */
  margin-right:25px;
  display:flex; align-items:center; justify-content:center;
  position:relative;
  transform:scale(0.86);
  opacity:0.85;
  /* width/margin-right transition specifically for folder cards (see
     openFolder()/closeFolder() in menu.js) growing in/out from 0 —
     harmless everywhere else since nothing else ever changes a card's
     width. #track is a real flex row, so animating just these two
     properties on the newly-inserted cards is what pushes every later
     sibling card rightward, via ordinary flex reflow — no JS position
     math needed for them at all. */
  transition:transform .3s cubic-bezier(.22,1,.36,1), opacity .3s ease, filter .3s ease,
    width .32s cubic-bezier(.22,1,.36,1), margin-right .32s cubic-bezier(.22,1,.36,1);
  user-select:none;
}
/* the plate: box.png for blank cards, or a project's own full card art (e.g. ds-card.png) */
.card .plate{
  position:absolute; inset:0;
  width:100%; height:100%;
  object-fit:contain;   /* contain, not cover — keeps the art's own baked-in shadow intact */
  pointer-events:none;
  z-index:2; /* paints OVER .thumb — the box plate's 104x104 middle is a
    genuine transparent cutout (confirmed via its alpha channel, not
    just a light-colored fill), so the icon sits behind it and shows
    through with the box's own hand-drawn pixel-rounded edge as its
    border, rather than a smooth CSS border-radius layered on top. */
}
/* The bow's ribbon tips reach into the canvas corners more aggressively
   than box.png's own artwork does, so at the same object-fit:contain size
   they poke slightly past the selector frame's window — scaled down a
   touch, centered, to sit fully inside it. */
.card .gift-wrap-plate{
  transform:scale(0.88);
}
.card .fallback{
  position:relative; z-index:1;
  font-size:16px; font-weight:600; color:#5a5a5a; text-align:center;
  padding:0 12px;
  text-shadow:0 1px 0 rgba(255,255,255,0.6);
}
/* a flat, unframed image (no baked-in card border/shadow) windowed
   inside the box plate, roughly where its lighter inner area is */
.card .thumb{
  position:absolute; z-index:1;
  /* Sized/positioned to fill the box plate's 104x104 cutout once the box
     (160x190 native) is letterboxed via object-fit:contain into this
     138x159 card, plus a 5% safety enlargement so it comfortably
     overfills the hole instead of just exactly matching it (any excess
     is hidden behind the opaque bezel now that .plate paints on top).
     Uses absolute position (explicit top/left) rather than flex-
     centering + margin-top — percentage margins resolve against the
     containing block's WIDTH even for margin-top/bottom, which made
     the old approach easy to get subtly wrong on the vertical axis.
     See THUMB_WIDTH_FRAC etc. in menu.js, which positionSelectorFrame()
     must match exactly to keep the selector frame aligned around this
     same thumb. */
  left:16.89%; top:12.84%;
  width:66.22%; height:57.47%;
  object-fit:cover;
}

.card.active{
  transform:scale(1);
  opacity:1;
  /* the selection highlight is now the real selector-frame asset, added
     as a child of whichever card is active — see positionSelectorFrame()
     in menu.js */
}

/* "These boxes belong to the folder" backing shadow — spans the folder
   card itself plus all its open items (see updateFolderTray() in
   menu.js). A direct child of #track, a SIBLING of the cards rather than
   nested inside the folder card — nesting it inside a .card was tried
   first, but that card's own transform:scale(0.86) when inactive (i.e.
   the moment the user navigates away from the folder) shrank the tray
   along with it, around that card's own center, visibly cutting off the
   far end. As a plain #track child it still inherits #track's shared
   translateX (scrolls in sync with every card) but no individual card's
   scale, so its computed left/width always render at true size.
   z-index:-1 (rather than relying on DOM insertion order) guarantees it
   paints behind every card regardless of exactly when it was created. */
.folder-tray{
  position:absolute;
  top:50%;
  z-index:-1;
  border-radius:18px;
  background:var(--bar-bg);
  box-shadow:inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.08);
  opacity:0;
  pointer-events:none;
  transition:opacity .3s ease;
}
.folder-tray.visible{ opacity:1; }

/* ================= MENU: SELECTOR FRAME (real DSi-style asset) ================= */
.card-selector-frame{
  position:absolute;
  display:block;
  image-rendering:pixelated;
  pointer-events:none;
  z-index:3;
  transform-origin:center center;
  /* Real DSi behavior: the very first/main cartridge (About Me) shows
     no selector at all — it only appears once you've moved to a
     different card, and disappears again if you come back. See
     updateFrameVisibility(). This transition is what makes that a
     quick fade rather than an instant snap; it's independent of (and
     much quicker than) the chrome fade-in that plays once at page
     load, since by the time that plays the frame is either already
     meant to be hidden (starting on About Me) or was never touched by
     it at all. */
  transition: opacity .18s ease;
}
/* Plays every time the selection lands on a new card, like the DSi
   menu's cursor: a quick shrink-in followed by a springy overshoot back
   out to full size, rather than a static instant snap. */
.card-selector-frame.landing{
  animation: frame-land 100ms ease-out;
}
@keyframes frame-land{
  0%   { transform:scale(1); }
  40%  { transform:scale(0.97); }
  100% { transform:scale(1); }
}

/* ================= MENU: APP-LAUNCH TRANSITION (DSi-style) ================= */
/* Phase 1 (fired together): a quick blue glow washing over the entire
   inside of the selector frame's transparent window — covering the
   project art itself, not just a thin ring around it — plus a more
   pronounced squash on the frame than the gentle landing bounce above.
   Together these read as "the cursor reacting to a press", not just
   settling into place. Modeled on the real DSi's own green selection
   highlight: a solid, saturated border ring is what actually reads as
   "selected" at a glance, while the fill itself stays light enough that
   the art underneath is still clearly visible through it. */
.card-selector-glow{
  position:absolute;
  pointer-events:none;
  z-index:2; /* above card art (.plate/.thumb, scoped to their own card)
    but below .card-selector-frame's own opaque bezel (z-index:3), so
    the frame's border stays crisp on top of the glow */
  border-radius:12%;
  box-sizing:border-box; /* keeps the border INSIDE the window rect JS already computed, rather than growing past it */
  border:3px solid rgba(140,210,255,0);
  background: rgba(130,195,255,0);
}
@keyframes launch-glow{
  0%   { border-color: rgba(140,210,255,0); background: rgba(130,195,255,0); box-shadow:0 0 0 0 rgba(130,195,255,0); }
  50%  { border-color: rgba(140,210,255,0.95); background: rgba(130,195,255,0.28); box-shadow:0 0 14px 6px rgba(130,195,255,0.7); }
  100% { border-color: rgba(140,210,255,0.95); background: rgba(130,195,255,0.28); box-shadow:0 0 14px 6px rgba(130,195,255,0.7); }
}
.card-selector-glow.launch-glow{
  animation: launch-glow 150ms ease-out forwards;
}
/* Same idea as launch-glow above, but white — the flash before a gift
   card's open animation plays (see playGiftUnwrap() in menu.js), not a
   launch, so it gets its own distinct colour rather than reusing the
   normal blue "this card was pressed" one. */
@keyframes gift-glow{
  /* border-color deliberately left out (stays fully transparent, per the
     base .card-selector-glow rule) — animating it to solid white the way
     launch-glow does its blue border read as a hard bright rim rather
     than a soft glow. Just the diffuse background+box-shadow instead. */
  0%   { background: rgba(255,255,255,0); box-shadow:0 0 0 0 rgba(255,255,255,0); }
  50%  { background: rgba(255,255,255,0.35); box-shadow:0 0 14px 6px rgba(255,255,255,0.8); }
  100% { background: rgba(255,255,255,0.35); box-shadow:0 0 14px 6px rgba(255,255,255,0.8); }
}
.card-selector-glow.gift-glow{
  animation: gift-glow 150ms ease-out forwards;
}
@keyframes launch-squish{
  0%   { transform:scale(1,1); }
  40%  { transform:scale(1.12,0.82); }
  70%  { transform:scale(0.94,1.08); }
  100% { transform:scale(1,1); }
}
.card-selector-frame.launch-squish{
  animation: launch-squish 150ms ease-out;
}
/* Phase 2: the card flies up off the top of the screen while the
   selector frame disappears (via its existing opacity transition) and
   the white overlay below fades in, all at once. */
.card.launch-fly{
  transition: transform .45s cubic-bezier(.4,0,.2,1);
  transform: translateY(-120vh);
}
#launch-white-overlay{
  position:fixed; inset:0;
  background:#fff;
  opacity:0;
  pointer-events:none;
  z-index:999;
  transition: opacity .45s ease;
}

/* ================= MENU: DS-STYLE SCROLLBAR (real pixel-art assets) ================= */
#scrollbar{
  position:absolute; left:0; right:0; bottom:0;
  width:100%;
  display:flex; align-items:flex-end;
  box-sizing:border-box;
}
/* The bar itself always stretches to fill 100% of the available width —
   independent of the size of everything else — so it's always corner-
   to-corner regardless of how thin we make it. Every other piece (caps,
   thumb, arrows, pips) is a small fixed-size image, sized in JS via a
   single SB_VSCALE factor so they all shrink/grow together in
   proportion to each other. */
.sb-corner{
  position:relative;
  flex-shrink:0;
  cursor:pointer;
}
.sb-arrow-img{
  position:absolute; top:50%; left:50%;
  transform:translate(-50%,-50%);
  display:block;
  image-rendering:pixelated;
  cursor:pointer;
  z-index:3;
}
#sb-arrow-right-img{ transform:translate(-50%,-50%) scaleX(-1); }
#sb-track{
  position:relative;
  flex:1; min-width:0;
  line-height:0; /* avoids extra inline-image gap under sb-bar-bg */
}
#sb-bar-bg{
  display:block;
  width:100%;
  image-rendering:pixelated;
}
#sb-pips{
  position:absolute; inset:0;
  pointer-events:none;
  /* Pips are positioned individually in JS (positionPips) rather than
     via flex space-between — that distributed pips by their LEFT
     EDGES, not centers, which put each pip's actual center off from
     the thumb's own center by up to half a pip's width (worst at the
     first/last stops, zero only at dead-center). Explicit left+
     translate(-50%) here matches the thumb's center-of-travel formula
     exactly, at every stop. */
}
.sb-pip-img{
  position:absolute; top:50%;
  transform:translate(-50%, -50%);
  display:block;
  image-rendering:pixelated;
  transition: opacity .15s ease;
  opacity:0.5;
}
.sb-pip-img.passed{ opacity:1; }
.sb-pip-filler{
  display:block; flex-shrink:0;
  image-rendering:pixelated;
  opacity:0.35;
}
/* Fixed decorative pieces sitting OUTSIDE the arrows, at the far
   corners — the arrow overlaps in front of part of each cap (negative
   margin pulls the arrow over it; z-index keeps the arrow on top),
   rather than being inside the silver bar. */
.sb-cap-img{
  display:block; flex-shrink:0;
  image-rendering:pixelated;
  pointer-events:none;
  position:relative; z-index:1;
}
#sb-thumb{
  position:absolute; top:50%; left:0;
  transform:translateY(-50%);
  display:block;
  image-rendering:pixelated;
  cursor:grab;
  z-index:2;
  transition: left .18s ease; /* smooths the final settle-into-place on release */
}
#sb-thumb.dragging{
  cursor:grabbing;
  transition: none; /* instant 1:1 tracking while actually dragging */
}
/* Lights up the area behind the thumb's transparent middle — like the
   real DSi scrollbar, where the pip under the thumb stays visible, just
   sitting on a highlighted light panel rather than the plain gray
   track. No z-index here (relies on DOM order, placed before
   #sb-pips): it needs to paint BEHIND the pips so they still show on
   top of it, while #sb-thumb's bezel (z-index:2) paints in front of
   everything, framing the visible window. Sized/positioned in JS (see
   syncThumbHighlight) to match the thumb's actual transparent hole,
   measured directly from its PNG's alpha channel. */
#sb-thumb-highlight{
  position:absolute; top:50%; left:0;
  transform:translateY(-50%);
  display:block;
  background:#eee;
  border-radius:2px;
  pointer-events:none;
  transition: left .18s ease; /* mirrors #sb-thumb's own settle transition */
}
#sb-thumb-highlight.dragging{
  transition: none;
}

/* ================= MENU: INTRO SEQUENCE ================= */
/* Drop-and-bounce for each card's intro entrance: falls from above,
   hits its resting position, bounces up a little, a smaller second
   bounce, then settles — like a cartridge physically dropping in,
   rather than a smooth single-ease slide. Two variants only because
   each card's own resting transform includes a different scale (0.86
   normally, 1 for whichever starts active) — everything else about the
   motion is identical between them. Driven via animation-name/delay/
   duration set inline per-card in playIntroAnimation(), not a class, so
   the stagger delay can be computed per index. */
@keyframes card-drop-bounce{
  0%   { opacity:0; transform:translateY(-40px) scale(0.86); }
  8%   { opacity:1; }
  45%  { transform:translateY(0) scale(0.86); }      /* first impact */
  60%  { transform:translateY(-8px) scale(0.86); }   /* bounce up */
  75%  { transform:translateY(0) scale(0.86); }      /* second impact */
  85%  { transform:translateY(-3px) scale(0.86); }    /* smaller second bounce */
  100% { opacity:0.85; transform:translateY(0) scale(0.86); } /* settled — 0.85 matches .card's own resting opacity, so clearing this animation afterward doesn't pop/snap */
}
@keyframes card-drop-bounce-active{
  0%   { opacity:0; transform:translateY(-40px) scale(1); }
  8%   { opacity:1; }
  45%  { transform:translateY(0) scale(1); }
  60%  { transform:translateY(-8px) scale(1); }
  75%  { transform:translateY(0) scale(1); }
  85%  { transform:translateY(-3px) scale(1); }
  100% { opacity:1; transform:translateY(0) scale(1); }
}
/* Both present from first paint (body has both classes in the raw
   HTML) so there's no flash of the finished page before JS runs. Split
   into two separate classes rather than one, because they get removed
   at different times: intro-hidden comes off the instant
   playIntroAnimation() starts (it immediately takes over opacity via
   inline styles instead) — if it lingered, its opacity:0 would fight
   every subsequent inline opacity change and the reveal would never
   become visible. intro-locked stays until the whole sequence
   (including the chrome fade-in) finishes, since its job — blocking
   mouse/touch — should last the full duration, not just the card
   drop-in. */
body.intro-hidden .card,
body.intro-hidden .card-selector-frame,
body.intro-hidden #tooltip-wrap,
body.intro-hidden #preview{
  opacity:0;
}
body.intro-locked #row-wrap,
body.intro-locked #scrollbar{
  pointer-events:none;
}
/* Same lock, while the gift-open animation plays (see playGiftUnwrap() in
   menu.js) — #gift-open-overlay is positioned once from the card's rect
   at the start and doesn't track the carousel, so dragging/scrolling
   mid-animation would desync the two. Blocking input here is simpler and
   safer than trying to keep that overlay's position live. */
body.gift-unwrapping #row-wrap,
body.gift-unwrapping #scrollbar{
  pointer-events:none;
}

/* ================= VIEWER: GALLERY ================= */
#gallery-wrap{
  flex:1;
  display:flex; align-items:center; justify-content:center;
  padding: 1vh 48px;
  min-height:0;
}
/* This exists purely so the arrows have something to anchor to that
   actually matches the image's real rendered size — #gallery-wrap
   itself spans the FULL width of the screen (it's a flex:1 column
   child), so positioning the arrows relative to it put them miles off
   from the actual image, off the edge of the screen entirely. This
   wrapper hugs #gallery-viewport's own dimensions and has no
   overflow:hidden of its own, so the arrows can sit in the "gutter"
   just outside the image without being clipped. */
#gallery-frame{
  position:relative;
  /* Sized in JS (viewer.js fitGalleryFrame()), not via aspect-ratio +
     max-width/max-height here — that combination breaks on tall/narrow
     or square viewports (an explicit height plus a max-width that clamps
     the derived width doesn't get recomputed by the browser, silently
     losing the intended ratio). These are just a pre-JS fallback so
     there's no flash of an unsized box before the script runs. */
  max-height:82vh;
  max-width:100%;
}
#gallery-viewport{
  position:relative;
  width:100%; height:100%;
  overflow:hidden;
  border-radius:3px;
  background:transparent;
  cursor:grab;
  touch-action:pan-y;
  -webkit-user-select:none; user-select:none;
}
/* A slide "layer" — a plain positioning container, not an <img> itself,
   since a slide's content varies by gallery entry kind: a single image
   or video fills it directly, while a "row" entry (several images
   playing side by side under one shared pip) needs an inner flex
   wrapper. See viewer.js buildSlideContent(). */
.gallery-image{
  position:absolute; inset:0;
  width:100%; height:100%;
  display:flex; align-items:center; justify-content:center;
}
.gallery-image > img,
.gallery-image > video{
  max-width:100%; max-height:100%;
  width:100%; height:100%;
  object-fit:contain; display:block;
}
.gallery-row{
  display:flex; align-items:center; justify-content:center;
  width:100%; height:100%;
  gap:0;
}
.gallery-row img{
  flex:1 1 0; min-width:0;
  max-height:100%;
  object-fit:contain; display:block;
}
/* A "book" entry (kind:"book") is a many-page publication read via
   realistic drag-to-turn page flipping — a fundamentally different,
   stateful interaction from the crossfading .gallery-image layers used
   for everything else, so it's a separate persistent element that gets
   shown/hidden rather than rebuilt on every slide change (see viewer.js
   showImage()). StPageFlip (vendor/page-flip.browser.js) renders into
   this via a <canvas> it creates itself and injects its own shadow/
   style CSS, so this just needs to size to the same box the image
   layers use. Note: showing/hiding this element is done via
   style.display in JS, not a CSS class or the `hidden` attribute —
   StPageFlip's own constructor sets an inline style.display on it
   directly, which outranks any stylesheet rule (short of !important),
   so toggling it any other way silently does nothing once the library
   has initialized. */
#gallery-book{
  position:absolute; inset:0;
  width:100%; height:100%;
}
.gallery-arrow{
  position:absolute; top:50%;
  transform:translateY(-50%);
  z-index:2;
  background:none; border:none; padding:0; margin:0;
  -webkit-appearance:none; appearance:none;
  outline:none; box-shadow:none;
  cursor:pointer;
  opacity:.55;
  transition:opacity .35s ease, left .15s ease, right .15s ease, transform .6s cubic-bezier(.22,1,.36,1);
  line-height:0;
  /* Two quick taps on the same spot (rapid arrow-tapping) otherwise reads
     as a native double-tap-to-zoom gesture on mobile browsers. This opts
     the button out of that specific gesture (not zoom in general — pinch
     zoom elsewhere on the page is untouched) and also drops the ~300ms
     tap delay some mobile browsers add while waiting to see if a second
     tap is coming, so taps register faster too. */
  touch-action:manipulation;
}
/* outline:none here prevents an unwanted browser focus ring persisting
   after a click-then-keyboard interaction */
.gallery-arrow:focus, .gallery-arrow:active{ outline:none; box-shadow:none; }
.gallery-arrow:hover, .gallery-arrow:focus-visible{ opacity:1; }
.gallery-arrow img{
  display:block; width:34px; height:auto;
  image-rendering:pixelated;
}
.gallery-arrow.left{ left:-58px; }
.gallery-arrow.right{ right:-58px; }

/* Two different arrow designs, swapped by breakpoint rather than by JS —
   the newer thin corner-bracket art (arrow-left.png/arrow-right.png)
   reads well overlaid directly on the image on mobile, but felt too
   faint sitting in the open desktop gutter; the original chunky
   triangle-in-a-tab art (kept at arrow-left-desktop.png/
   arrow-right-desktop.png, recovered from before the swap) stays there
   instead. Both <img> tags are always in the DOM; only one is ever
   display:block at a given width. The `.gallery-arrow img` rule above
   sets display:block with class+element specificity — a bare
   `.gallery-arrow-mobile{display:none}` (class-only, lower specificity)
   would silently lose to it regardless of source order, so these match
   that same class+element specificity (and beat it on mobile via source
   order, same specificity, later rule). */
img.gallery-arrow-mobile{ display:none; }
@media (max-width: 720px){
  img.gallery-arrow-desktop{ display:none; }
  img.gallery-arrow-mobile{ display:block; }
}

/* Narrow viewports: no gutter to float in, so arrows move onto the
   image itself, inset from the edge, with a dark backing so they stay
   legible against busy artwork. */
@media (max-width: 720px){
  #gallery-wrap{ padding: 1vh 12px; }
  .gallery-arrow{
    padding:8px;
    opacity:.75;
  }
  .gallery-arrow::before{ opacity:1; }
  .gallery-arrow img{ width:22px; }
  .gallery-arrow.left{ left:10px; }
  .gallery-arrow.right{ right:10px; }
  /* #gallery-frame's size on narrow viewports is handled by the same
     JS sizing (fitGalleryFrame in viewer.js) as everywhere else now —
     see the comment on the base #gallery-frame rule. */
}

/* ================= VIEWER: LIGHTBOX ================= */
/* Opened by tapping the MIDDLE third of a "single"/"group" image (see
   the gallery-viewport pointerup handler in viewer.js — left/right
   thirds still navigate prev/next, same as always). No visible trigger
   button needed since the tap zone itself is the affordance. */
/* Same show/hide pattern as #entry-white-overlay/#launch-white-overlay —
   opacity + pointer-events, always present in the layout, rather than
   toggling `hidden` (which can't transition). */
#lightbox{
  position:fixed; inset:0;
  z-index:1000;
  background:rgba(10,10,10,0.94);
  display:flex; align-items:center; justify-content:center;
  opacity:0;
  pointer-events:none;
  transition:opacity .25s ease;
}
#lightbox.open{
  opacity:1;
  pointer-events:auto;
}
/* The scroll container is what actually enables panning once zoomed in —
   the image itself just grows past the viewport's size via its own
   width/height, and this container's overflow:auto is what lets a drag/
   swipe (or a trackpad/scrollwheel) move around inside it, entirely
   through native scrolling rather than any custom pan-gesture code. */
#lightbox-scroll{
  width:100%; height:100%;
  overflow:auto;
  display:flex; align-items:center; justify-content:center;
  -webkit-overflow-scrolling:touch;
}
#lightbox-img{
  max-width:94vw; max-height:94vh;
  width:auto; height:auto;
  object-fit:contain;
  cursor:zoom-in;
  transition:max-width .3s ease, max-height .3s ease;
  -webkit-user-drag:none;
}
/* Zoomed: the max-width/height CAP lifts entirely, so the image renders
   at its own real (large) pixel size — #lightbox-scroll's overflow:auto
   then makes the overflow scrollable/pannable, and centering no longer
   applies once content exceeds the container (the browser just anchors
   scroll at the top-left), which is the expected/normal way a zoomed
   image behaves in a scrollable viewer. */
#lightbox-img.zoomed{
  max-width:none; max-height:none;
  cursor:zoom-out;
}
#lightbox-close{
  position:absolute;
  top:18px; right:18px;
  width:38px; height:38px;
  background:rgba(0,0,0,0.5);
  border:none; border-radius:4px;
  cursor:pointer;
  padding:0;
  z-index:1001;
  touch-action:manipulation; /* same double-tap-zoom fix as .gallery-arrow */
}
#lightbox-close:hover{ background:rgba(0,0,0,0.7); }
#lightbox-close span{
  position:absolute; top:50%; left:50%;
  width:18px; height:2px;
  background:#fff;
}
#lightbox-close span:first-child{ transform:translate(-50%,-50%) rotate(45deg); }
#lightbox-close span:last-child{ transform:translate(-50%,-50%) rotate(-45deg); }

/* ================= VIEWER: PIPS ================= */
#gallery-pips{
  display:flex; align-items:center; justify-content:center;
  gap:9px;
  padding: 1.4vh 0;
  flex-shrink:0;
}
.gallery-pip{
  display:block;
  image-rendering:pixelated;
  transition: width .12s ease, height .12s ease;
  cursor:pointer;
  touch-action:manipulation; /* same double-tap-zoom fix as .gallery-arrow */
}

/* ================= VIEWER: INFO BAR ================= */
#info-bar{
  flex-shrink:0;
  display:flex; flex-direction:column;
}
/* The color-changing strip — a flat CSS background, not a baked image,
   so it can be set per-project in JS (see project.color). Height ratio
   (22:34 ≈ 62:28) matches the two bands measured from the info-bar
   asset. */
#info-color-strip{
  height:6px;
  background: var(--project-color);
  transition: background .3s ease;
}
#info-caption-bar{
  min-height:78px; /* grows when a long caption is expanded (viewer.js) */
  box-sizing:border-box;
  background-image:url("assets/ui/info-bar-tile.png");
  background-repeat:repeat-x;
  background-size:auto 100%;
  display:flex; align-items:center; justify-content:space-between;
  padding:9px 20px;
  gap:18px;
  font-family:'Rodin', 'Trebuchet MS', sans-serif;
}
/* Collapsed height and the "…" are set by viewer.js (updateCaptionClamp),
   which also adds .expandable only when the caption is actually cut off —
   max-height here is just the pre-JS fallback (3 lines). */
#info-caption-text{
  font-size:14px; line-height:1.4; color:#333;
  max-width:58%;
  display:block;
  overflow:hidden;
  max-height:58.8px;
  transition:max-height .45s cubic-bezier(.34,1.56,.64,1);
}
#info-caption-text.expandable{
  cursor:pointer;
}
#info-project{
  display:flex; align-items:center; gap:12px;
  flex-shrink:0;
}
#info-project-title{
  font-size:19px; color:#222; white-space:nowrap;
}
.info-icon-box{
  position:relative;
  width:56px; height:57px;
  flex-shrink:0;
}
.info-icon-img{
  position:absolute; left:18.1%; top:16.5%;
  width:63.8%; height:62.8%;
  object-fit:cover;
  z-index:1;
}
.info-icon-plate{
  position:absolute; inset:0;
  width:100%; height:100%;
  z-index:2;
  image-rendering:pixelated;
  pointer-events:none;
}

/* On a narrow screen, the side-by-side layout (caption text capped at
   58% width, squeezed further by the title+icon's fixed-width share)
   leaves the caption only ~100px to work with — nowhere near enough to
   read more than a few words per line. Stacking the bar into a column
   gives the caption the full screen width to work with instead. A clamp
   is still applied (2 lines, smaller than desktop's 3) — at 58%-width
   the old clamp genuinely wasn't enough room to read anything, but at
   full width it comfortably fits a real sentence or two per line; without
   it, a long caption (e.g. strayscapes', blossom's) wraps to 5-6+ lines
   and eats space that should go to the gallery image above it. */
@media (max-width: 720px){
  #info-caption-bar{
    height:auto;
    flex-direction:column;
    align-items:flex-start;
    padding:12px 16px;
    gap:8px;
  }
  /* -webkit-line-clamp can't be animated (it's not a real height, just a
     box-model hack), so the collapsed/expanded states here are driven by
     max-height instead (set inline by viewer.js's updateCaptionClamp() /
     truncateCaptionToLines()) — that's what makes the tap-to-expand
     bounce possible. The collapsed state's trailing "…" is genuine text
     content (computed by measuring how much actually fits, see
     truncateCaptionToLines in viewer.js), not an overlay — an
     absolutely-positioned "..." patch sat visibly on top of the real
     text, and a fade-to-background gradient read as anti-aliased blur
     against this site's pixel-art chrome. No ::after needed for either
     anymore. */
  #info-caption-text{
    max-width:100%;
    font-size:12px;
    line-height:1.4;
    overflow:hidden;
    cursor:pointer;
    /* Resets the base (desktop) rule's -webkit-line-clamp:3 / display:
       -webkit-box — a later media-query rule only OVERRIDES properties
       it explicitly sets, it doesn't clear ones from an earlier still-
       matching rule, so without this the old 3-line clamp kept silently
       truncating the text underneath the max-height mechanism below
       (confirmed via Range.getClientRects(): the DOM had all 5 real
       lines, but only 3 ever painted). display:block hands text flow
       back to plain block layout, which max-height/overflow can clip. */
    display:block; -webkit-line-clamp:unset; line-clamp:unset;
    max-height:33.6px; /* pre-JS fallback: 2 lines at this font-size/line-height */
    transition:max-height .45s cubic-bezier(.34,1.56,.64,1); /* springy overshoot = the "jump" */
  }
  #info-project{
    align-self:flex-end;
  }
}

/* ================= VIEWER: BACK BAR ================= */
#back-bar{
  flex-shrink:0;
  margin:0;
  border:none;
  -webkit-appearance:none; appearance:none;
  display:flex; align-items:center; justify-content:center;
  width:100%; height:36px;
  background-image:url("assets/ui/back-bar-tile.png");
  background-repeat:repeat-x;
  background-size:auto 100%;
  cursor:pointer;
  touch-action:manipulation; /* same double-tap-zoom fix as .gallery-arrow */
  image-rendering:pixelated;
}
#back-bar img{
  height:17px;
  width:auto;
  display:block;
  image-rendering:pixelated;
}
#back-bar:hover{ filter:brightness(1.12); }

/* ================= MENU: ABOUT ME CARTRIDGE ================= */
/* Unlike every other card, About Me never navigates to project-viewer.html
   — it opens in place over the menu (see openAboutMe()/closeAboutMe() in
   menu.js). #info-bar/#back-bar below are the SAME ids project-viewer.html
   uses (index.html has no other use for them), so this footer gets their
   existing styling/entrance transition for free — --project-color just
   needs a value here since nothing else on this page sets it. */
#about-me-overlay{
  position:absolute; inset:0;
  z-index:500; /* #about-me-overlay is #screen's LAST child, so normal DOM-order stacking already paints it above the real #statusbar earlier in #screen — no z-index carve-out needed to also dim the status bar along with everything else, matching the reference (it deliberately does NOT stay bright/undimmed). #about-me-sheet's own padding-top below is what keeps the heading clear of it instead. */
  display:flex; flex-direction:column;
  opacity:0;
  pointer-events:none;
  transition:opacity .3s ease;
  --project-color:#7b93ab;
}
#about-me-overlay.open{
  opacity:1;
  pointer-events:auto;
}

#about-me-scrim{
  position:absolute; inset:0;
  z-index:0;
  background:rgba(6,7,9,0.88);
}

/* #about-me-scrim is position:absolute, so — regardless of DOM order — it
   would otherwise paint above #info-bar/#back-bar below (both plain
   position:static flex children): positioned elements always paint above
   static siblings in the same stacking context. Explicit z-index:1 here
   (and on #info-bar/#back-bar's own rule further down) is what makes the
   footer actually clickable instead of the scrim silently eating the
   click — caught by an automated click timing out on "element intercepts
   pointer events" during testing, not visible just from a screenshot. */
#about-me-sheet{
  position:relative;
  z-index:1;
  flex:1;
  min-height:0;
  display:flex; flex-direction:column;
  align-items:center;
  /* Fixed 64px top clearance (not 3vh) — #about-me-overlay now covers the
     real #statusbar too (dimming it, per the reference), so this is the
     one thing keeping the heading below it instead of overlapping; a
     viewport-relative value would shrink below the status bar's own
     ~42px height on a short viewport and stop being enough. */
  padding:64px 24px 2vh;
  opacity:0;
  transform:translateY(10px);
  transition:opacity .5s ease, transform .5s ease;
  /* Safety net, not the primary fix: on a short/narrow viewport the
     heading+box+portrait can collectively be taller than the space
     between the dividers even after the mobile font/padding cutbacks
     below — rather than that overflow silently clipping against
     #screen's own overflow:hidden with no way to reach it, the whole
     sheet scrolls. */
  overflow-y:auto;
}
#about-me-overlay.content-in #about-me-sheet{
  opacity:1;
  transform:translateY(0);
}
/* Rodin is the headline/label font here — the page title AND the box's
   own section headers ("About Me"/"Education"/"Other interests") both
   use it. DSBios is reserved for the box's BODY copy specifically (see
   #about-me-box-text below) — the reverse of an earlier attempt at this.
   Per the source Photoshop file, Rodin sits at 18pt there against DSBios
   at 24pt — DSBios needs the larger declared size to read at a
   comparable visual weight, since it's the more compact of the two. */
#about-me-heading{
  margin:0 0 14px;
  font-family:'Rodin', 'Trebuchet MS', sans-serif;
  font-weight:400;
  font-size:30px;
  color:rgba(95,115,134,0.85);
  text-align:center;
}
/* A plain `border-top:dashed` lets the browser silently resize every dash
   on the line (not just clip the last one) to make the pattern land
   evenly at the box's own width — visually inconsistent dash lengths,
   which is what this replaced. Capped at the SAME max-width as
   #about-me-content below (not #about-me-sheet's near-full-screen width)
   so it frames the box + portrait specifically, rather than spanning
   edge to edge.
   The dash itself is drawn centered WITHIN its own 14px repeat tile (a
   3px gap either side of the 8px dash, rather than the dash filling the
   whole tile) and background-position:center centers that tiling on the
   element — together, if the line's width isn't an exact multiple of
   14px, the leftover space lands as a bit of extra (invisible) gap at
   each end rather than a visibly truncated half-dash, and the pattern
   stays symmetric instead of always starting flush at the left edge. */
.about-me-divider{
  width:100%;
  max-width:1250px;
  height:2px;
  border:none;
  background-image:linear-gradient(to right,
    transparent 0, transparent 3px,
    rgba(255,255,255,0.4) 3px, rgba(255,255,255,0.4) 11px,
    transparent 11px, transparent 14px);
  background-size:14px 2px;
  background-repeat:repeat-x;
  background-position:center;
  margin:2.2vh 0;
  flex-shrink:0;
}
#about-me-content{
  flex:1;
  min-height:0;
  width:100%;
  max-width:1250px;
  display:flex;
  align-items:stretch;
  justify-content:center;
  gap:3vw;
}
#about-me-box{
  position:relative;
  flex:1 1 820px;
  width:100%;
  max-width:920px; /* About Me Box.png's own native width (922px) — this is as big as it should ever need to render */
  /* Locked to About Me Box.png's own 922:642 proportions — unlike the
     earlier "stretch to fill whatever height is left" attempt, this keeps
     the box exactly as sent, never distorted. align-self:flex-start
     overrides #about-me-content's own align-items:stretch specifically
     for this item — align-items:stretch gives a flex item a DEFINITE
     (not auto) cross-size, and aspect-ratio only ever fills in a size
     that's still auto, so left as "stretch" the box silently ignored its
     own ratio and got pulled to the container's full height regardless.
     flex-start leaves height auto, so aspect-ratio governs it from the
     box's resolved width instead — capped by max-height so a short
     viewport still shrinks it (via the width-from-ratio-from-max-height
     resolution browsers already do for a sized+aspect-ratio box) rather
     than overflowing #about-me-content. The mobile media query below is
     the one deliberate exception ("squish" only on small viewports, per
     explicit request). */
  align-self:flex-start;
  max-height:100%;
  aspect-ratio:922/642;
}
#about-me-box-art{
  position:absolute; inset:0;
  width:100%; height:100%;
  z-index:0;
  pointer-events:none;
  -webkit-user-drag:none;
}
/* Both top AND bottom of About Me Box.png are the same blue bezel
   (measured: ~4% of the box's height at each end, white in between) —
   padding alone only pushes where TEXT STARTS, it doesn't stop scrolled
   content from later passing behind that bezel as the user scrolls
   (padding is inside the scrollable box, so overflow just keeps flowing
   past it). Cutting the scrollable region itself down to top:4.1%/
   bottom:4.1% (a real clip, since overflow-y:auto clips at the element's
   own box) is what actually confines scrolled text to the white area at
   both ends. Percentages here resolve against the nearest positioned
   ancestor's HEIGHT (this is `top`/`bottom` on an absolutely positioned
   element, not padding, which would resolve against width instead) —
   #about-me-box is that ancestor, so this tracks correctly whether its
   height comes from the fixed 922:642 aspect-ratio (desktop) or flex:1
   (mobile, where the art itself is being non-uniformly stretched too,
   so the bezel is still ~4% of whatever height results either way). */
#about-me-box-text{
  position:absolute;
  z-index:1;
  top:4.1%; bottom:4.1%; left:0; right:0;
  overflow-y:auto;
  padding:8px 36px;
  font-family:'DSBios', 'Trebuchet MS', sans-serif;
  color:#333;
}
.about-me-section + .about-me-section{
  margin-top:18px;
}
.about-me-section h2{
  margin:0 0 6px;
  font-family:'Rodin', 'Trebuchet MS', sans-serif;
  font-size:20px;
  font-weight:400;
  color:#222;
}
.about-me-section-divider{
  height:1px;
  border:none;
  /* Same centered-tile technique as .about-me-divider above — avoids a
     truncated half-dash at either end. */
  background-image:linear-gradient(to right,
    transparent 0, transparent 2px,
    #b9b9b9 2px, #b9b9b9 7px,
    transparent 7px, transparent 9px);
  background-size:9px 1px;
  background-repeat:repeat-x;
  background-position:center;
  margin:0 0 9px;
}
/* DSBios's strokes are noticeably thinner/lighter than Rodin's, so even
   at a size that numerically exceeds the 20px Rodin headers it still
   reads as smaller/quieter next to them — confirmed by a zoomed-in
   crop of an actual render, not just a guess. Pushed well past parity
   with the headers specifically to compensate for that lower visual
   weight, not just to hit a matching pt-derived number. */
.about-me-section p{
  margin:0 0 10px;
  font-size:26px;
  line-height:1.4;
}
.about-me-section ul{
  margin:0; padding:0;
  list-style:none;
}
/* line-height kept tight (not the ~1.45 that reads comfortably for a
   paragraph) specifically so the 3-item bullet lists sit close enough
   together to fit the box without scrolling — font-size is unchanged
   from the p rule above, only the vertical rhythm between list lines. */
.about-me-section li{
  position:relative;
  padding-left:24px;
  font-size:26px; line-height:1.15;
}
.about-me-section li::before{
  content:'–';
  position:absolute; left:0;
}
.about-me-section a{
  color:#2f6fae;
  text-decoration:underline;
  text-underline-offset:3px;
}
/* fitAboutMeText() in menu.js picks between these at open/resize time:
   a little zoom first, then two columns on wide-but-short screens (e.g.
   1280x720 laptops), and only as a last resort leaves it scrollable with
   .has-more fading the bottom edge so the hidden content is discoverable. */
#about-me-fit.two-col{
  column-count:2;
  column-gap:44px;
}
#about-me-fit.two-col .about-me-section{
  break-inside:avoid;
  margin:0 0 18px;
}
#about-me-fit.two-col .about-me-section:last-child{
  margin-bottom:0;
}
#about-me-box-text.has-more{
  -webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - 56px), transparent);
          mask-image:linear-gradient(to bottom, #000 calc(100% - 56px), transparent);
}
#about-me-portrait{
  flex:0 0 auto;
  align-self:center;
  max-width:280px;
  max-height:100%;
  width:auto; height:auto;
  object-fit:contain;
  image-rendering:pixelated;
  -webkit-user-drag:none;
}

/* Reuses the same rise-from-below entrance the viewer page defines for
   these two elements (styles.css "VIEWER: INFO BAR"/"BACK BAR" sections
   already declare their permanent transition) — .footer-in just supplies
   the starting state to transition FROM, mirroring body.viewer-entering. */
#about-me-overlay #info-bar,
#about-me-overlay #back-bar{
  position:relative;
  z-index:1;
}
#about-me-overlay:not(.footer-in) #info-bar,
#about-me-overlay:not(.footer-in) #back-bar{
  opacity:0;
  transform:translateY(44px);
}

/* Every other project shows a flat square icon behind the shared white
   .info-icon-plate bezel — About Me shows the actual DS cartridge art
   (ds-card.png) instead, deliberately with NO plate, so it reads as the
   cartridge itself rather than a photo in a frame. .info-icon-img's own
   rule (styles.css "VIEWER: INFO BAR") positions it to sit INSIDE that
   bezel's inner window (absolute, 63.8%/62.8% of the box, object-fit:
   cover) — since there's no bezel here to sit inside, this overrides it
   back to filling the whole icon box directly. */
#about-me-icon-box .info-icon-img{
  position:static;
  width:100%; height:100%;
  object-fit:contain;
}

@media (max-width:720px){
  #about-me-heading{ font-size:22px; margin-bottom:8px; }
  /* Tighter than the desktop 2.5vh — every bit of vertical room reclaimed
     up here goes straight to the box below on a short phone screen. */
  .about-me-divider{ margin:1.3vh 0; }
  /* column, NOT column-reverse: reversing the main axis also reverses
     which end overflow spills past once content is taller than the
     space available (a real bug hit here — with column-reverse, content
     that didn't fit spilled UPWARD past #about-me-content's own top edge,
     overlapping the heading above it, rather than flowing down into the
     scrollable area #about-me-sheet's overflow-y:auto provides). Portrait-
     above-box is achieved with `order` instead, which reorders visually
     without touching which direction overflow flows. */
  /* justify-content:center is inherited from the desktop rule above,
     where it centers the box+portrait ROW horizontally — but with
     flex-direction switched to column here, that same "center" instead
     centers them VERTICALLY, and once combined content is taller than
     the container (routine on a short phone), centering means it
     overflows equally in BOTH directions — portrait (order:-1, first)
     was found rendering entirely above #about-me-content's own top edge,
     overlapping the heading, because of exactly this. flex-start makes
     overflow spill only downward, into the scrollable area
     #about-me-sheet's overflow-y:auto actually provides. */
  #about-me-content{ flex-direction:column; justify-content:flex-start; align-items:center; gap:2vh; }
  /* Only place the box's ratio is allowed to "squish" off its native
     922:642 proportions — aspect-ratio:auto hands sizing back to content
     (height:auto on desktop; here flex:1 instead, see below), since a
     small screen genuinely doesn't have room to keep the full box at its
     natural ratio.
     Portrait is hidden entirely on mobile (not just shrunk) — with it
     gone, #about-me-box is the ONLY child of #about-me-content, so
     flex:1 (rather than the old flex:0 1 auto + height:auto, sized purely
     off its own text) lets it claim the full height #about-me-content
     has to give, instead of leaving that space empty above the box. This
     was the actual fix for "scrolling both the box AND the page" — more
     height for the box means more of the bio text fits without needing
     the box's own internal scroll at all, on top of already not needing
     to compete with the portrait for room. */
  #about-me-box{ width:100%; max-width:420px; flex:1; min-height:0; aspect-ratio:auto; }
  /* Bezel clearance itself is handled by #about-me-box-text's own
     top:4.1%/bottom:4.1% now (see the base rule) — this is just the
     small breathing-room padding inside that already-clipped white area. */
  #about-me-box-text{ padding:6px 16px; }
  /* The desktop sizes above (22px headers, 16px body) were tuned for the
     much bigger box a full-width viewport can afford — at this width the
     squished box has nowhere near that room, so this scales both back
     down to what actually fits without relying on the scroll fallback
     above for the common case. */
  .about-me-section h2{ font-size:16px; margin-bottom:4px; }
  .about-me-section p, .about-me-section li{ font-size:18px; line-height:1.28; }
  .about-me-section-divider{ margin-bottom:6px; }
  .about-me-section + .about-me-section{ margin-top:10px; }
  #about-me-portrait{ display:none; }
}
