/* ===== Harry Potter school trunks =====
   Aged leather over wooden battens, brass fittings, a domed hinged lid.

   The material technique here is the same one the polished-wood desk theme in
   style.css uses: stack many low-alpha gradients — a specular highlight, fine
   repeating grain, a vignette, then the base colour ramp — so a flat div reads
   as a lit solid rather than a coloured rectangle. */

/* ---------- Flight and lid timings ----------
   The single source of truth for the three durations that trunks.js also has to
   know: the lid slam is *scheduled in JS* to land the instant the last photo is
   home, so a duration changed in one place and not the other silently desyncs
   the lid from the photos. trunks.js reads these back off the computed style
   instead of carrying its own copies. */
:root {
  --erupt-ms: 720ms;
  --absorb-ms: 600ms;
  --lid-drop-ms: 340ms;

  /* How long a personality takes to carry itself home when it is interrupted
     — see the @property block and Trunks.settleMotion. Short: this is a jolt
     coming to rest, not a journey, and the pointer has already left. */
  --settle-ms: 260ms;
  --settle-ease: cubic-bezier(0.22, 0.85, 0.3, 1);
}

/* ---------- The personality offset, as three numbers instead of a transform ----
   A trunk's `transform` carries TWO unrelated things: where the trunk is on the
   desk (--x/--y, written by the drag and by every remote update) and how far the
   personality has currently shaken it off that spot. Baking both into one
   `transform` declaration is what made them impossible to separate, and it cost
   two visible defects:

     - a hovered enchanted trunk sat 4.5px above its parked spot at the peak of
       its bob, and the instant the pointer left — which removes .hot, which
       stops the animation — it JUMPED that 4.5px. Measured on the live page.
     - the same jump, at pointerdown, when .dragging stood the personality down.

   The obvious fix, a `transition` on `transform`, does not work, and it is worth
   writing down why so nobody spends an afternoon on it again: a transition
   starts when a property's *specified* value changes between style change
   events, and removing an animation does not change one. `transform` here is
   always `translate3d(var(--x), var(--y), 0)`. Verified in Chrome: adding
   `transition: transform 0.4s` and dropping .hot mid-bob produced NO transition
   at all (`getAnimations()` empty) and the same 4.5px snap.

   So the offset is lifted out into three REGISTERED custom properties. Registered
   means typed, which means interpolable, which means (a) the keyframes below can
   animate them and (b) they can be *transitioned* — and unlike `transform`,
   their specified value is something JS can change, which is exactly what
   Trunks.settleMotion does: it commits the animation's current offset inline,
   drops the class, and then releases the inline value under .settling so the
   trunk eases home instead of teleporting.

   --x/--y stay unregistered on purpose. They are position, not motion; they must
   apply the instant they are written or a drag would lag the pointer by the
   transition duration. Keeping them out of this block is what makes the drag
   exact and the settle smooth at the same time.

   The var() fallbacks are not decoration: in a browser that does not support
   @property the whole `transform` would be invalid without them, and every trunk
   would pile up at the desk's top-left corner. */
@property --bob-x { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --bob-y { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --bob-r { syntax: '<angle>';  inherits: false; initial-value: 0deg; }

/* ---------- Layer ----------
   Children of .desk: above the desk's opaque background, below every polaroid
   (which start at z-index 1). NEVER move this beneath .desk — .desk paints an
   opaque parchment fill and the trunks would vanish.
   The layer itself is click-through; only the trunks take pointer events.

   z-index: auto is load-bearing, not an omission. A positioned element with a
   numeric z-index creates a stacking context, and a stacking context at 0 would
   trap every trunk inside it: no z-index on a trunk could then beat a polaroid
   outside it, and a trunk could never rise above the pile to be clicked (see
   .trunk.lifted). The 0 lives on the trunks themselves instead. */
.trunk-layer {
  position: absolute;
  inset: 0;
  z-index: auto;
  pointer-events: none;
}

/* ---------- Trunk shell ---------- */
.trunk {
  /* --u scales every part in one place, so the mobile size is one override. */
  --u: 1;
  --tw: 180px;
  --th: 120px;
  --x: 0px;
  --y: 0px;

  /* Leather ramp: lit face, mid body, shadowed base. */
  --leather-0: #8a4040;
  --leather-1: #6b2f2f;
  --leather-2: #351616;

  /* Lining ramp: the fabric INSIDE the trunk — 0 in deepest shadow, 2 where the
     light from the box reaches it. It is the leather's own hue dropped well
     below --leather-2; the long note under "Leather colourways" says why it is
     no longer a contrasting fabric. Defaults to oxblood's, which is also the
     default leather, so a trunk with no data-leather is dressed consistently
     rather than half in one scheme. */
  --lining-0: #150a0a;
  --lining-1: #2b1413;
  --lining-2: #4d2523;

  /* Quilt geometry, shared by the lid's underside and the back wall so the two
     panels are visibly the same padded fabric.

     THE ANGLES ARE NOT 45deg AND THAT IS THE WHOLE TRICK. Both surfaces are
     foreshortened vertically and only vertically — the lid because it is a
     rotateX face (measured: the composed matrix's m22 is +0.6157, so local Y is
     squashed to 61.6% on screen and local X is untouched), the back wall
     because it is a 34px band standing in for a panel much taller than it is
     deep. A diamond drawn at 45deg in local space therefore arrives on screen
     as a wide, flat lozenge and reads as corrugation, which is exactly what the
     previous vertical-stripe lining read as: a tin roof.

     So the lattice is drawn tall in local space and lands square on screen.
     Local slope wanted = 1 / 0.6157 = 1.624; a repeating-linear-gradient's
     bands run perpendicular to its axis, so an axis at atan(1.624) = 58deg puts
     the bands on that slope, and 122deg is its mirror. Scaled by --u so the
     count of diamonds is the same at every size — a fixed pixel pitch would
     give the 0.667 mobile trunk half again as many, on a panel with fewer
     pixels to draw them in. */
  --quilt-a: 58deg;
  --quilt-b: 122deg;
  --quilt-pitch: calc(11px * var(--u));

  /* Metal ramp: specular, face, shade, deep shade. */
  --metal-hi: #ffefbd;
  --metal-1: #d9ab52;
  --metal-2: #8a6520;
  --metal-lo: #4e380e;

  /* Glow colour for the emblem and the seam, as raw channels so keyframes can
     re-alpha it. Deliberately the *same warm gold for every hardware finish*:
     this is the light coming out of the trunk, and one trunk cannot leak gold
     from its seam and cold blue-white from its crest at the same time. Silver
     hardware still reads as silver — the metal itself is tinted by
     --metal-1, which the emblem picks up through `color`. Tying --glow to the
     hardware instead made silver crests glow at 220,232,240, which is
     near-white: invisible on pale leather and barely there on dark. */
  --glow: 255, 214, 122;

  /* trunks.js gives every trunk a random --wobble-seed in [-4s, 0). Task 7
     named this --wobble-delay and intended it for `animation-delay`; it is a
     *period* jitter here, so it was renamed rather than left lying about what
     it does. (These loops are triggered by .hot and restart from 0% each
     time, so a negative delay would drop the trunk into a random phase —
     either mid-jolt, a visible pop the instant your cursor lands, or
     mid-silence, where you hover and nothing happens for a second. Jittering
     the period desynchronises two trunks within one cycle and keeps the
     reaction immediate.) 0 -> +0.56s. */
  --wobble-jit: calc(var(--wobble-seed, 0s) * -0.14);
  --dur-restless: calc(2.9s + var(--wobble-jit));
  --dur-feral: calc(2.15s + var(--wobble-jit));
  --dur-enchanted: calc(3.6s + var(--wobble-jit));

  /* A metal *sheet*: two highlight bands rather than one flat ramp, which is
     what stops brass reading as a yellow rectangle. */
  --metal-face: linear-gradient(157deg,
    var(--metal-hi) 0%,
    var(--metal-1) 17%,
    var(--metal-2) 43%,
    var(--metal-1) 57%,
    var(--metal-2) 74%,
    var(--metal-lo) 100%);

  /* The name plate uses the same sheet without the specular top: starting at
     --metal-hi made it the brightest brass on the trunk, above the latch and
     the corner caps, which is what made it read as a label stuck on rather
     than a plate screwed in. */
  --metal-plate: linear-gradient(157deg,
    var(--metal-1) 0%,
    var(--metal-2) 26%,
    var(--metal-1) 52%,
    var(--metal-2) 74%,
    var(--metal-lo) 100%);

  /* Shared leather layers, reused by the lid face and the body.
     Three grain passes at unrelated angles with coprime periods (3/5/8px):
     matched periods would beat into a regular diamond weave and read as
     fabric instead of hide. The mottle layers are old scuffing and polish. */
  --grain:
    radial-gradient(ellipse 34% 42% at 17% 72%, rgba(255, 240, 212, 0.07) 0%, transparent 72%),
    radial-gradient(ellipse 26% 34% at 84% 28%, rgba(0, 0, 0, 0.13) 0%, transparent 72%),
    radial-gradient(ellipse 22% 30% at 62% 88%, rgba(0, 0, 0, 0.10) 0%, transparent 74%),
    repeating-linear-gradient(74deg, rgba(255, 244, 224, 0.05) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(-16deg, rgba(0, 0, 0, 0.07) 0 1px, transparent 1px 5px),
    repeating-linear-gradient(41deg, rgba(0, 0, 0, 0.045) 0 1px, transparent 1px 8px);
  --battens:
    repeating-linear-gradient(90deg,
      rgba(0, 0, 0, 0.20) 0 2px,
      rgba(255, 236, 205, 0.045) 2px 3px,
      transparent 3px 27px);
  --leather-base:
    linear-gradient(180deg, var(--leather-0) 0%, var(--leather-1) 45%, var(--leather-2) 100%);

  position: absolute;
  top: 0;
  left: 0;
  width: var(--tw);
  height: var(--th);
  /* Under the pile at rest — the scattered-photos-over-a-trunk look is the
     whole point — and above .desk's opaque background. Polaroids are 1..300;
     see the z-index ladder below .trunk.hot. */
  z-index: 0;
  /* Position PLUS personality offset, from two sets of variables that are
     deliberately different kinds of thing — see the @property block at the top
     of this file. --x/--y are unregistered and therefore instant, which is what
     a drag needs; --bob-* are registered and therefore interpolable, which is
     what an interrupted personality needs. */
  transform:
    translate3d(calc(var(--x) + var(--bob-x, 0px)), calc(var(--y) + var(--bob-y, 0px)), 0)
    rotate(var(--bob-r, 0deg));
  /* Perspective lives here, not on <body>: .desk has no preserve-3d, so the
     body-level perspective never reaches the lid. Without this the lid would
     squash flat instead of swinging back. */
  perspective: calc(760px * var(--u));
  perspective-origin: 50% 58%;
  pointer-events: auto;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
  /* `translate` is the independent transform property, not `transform`: pure
     translations commute, so `translate: 0 -7px` composes cleanly on top of the
     parked `transform` without disturbing the rotation origin. That gives the
     enchanted lift a real transition — smooth up on hover and, more
     importantly, smooth *down* on leave, which a keyframe animation cannot do
     because removing an animation snaps. --x/--y are untouched by it. */
  transition: filter 0.28s ease, translate 0.9s cubic-bezier(0.2, 0.76, 0.3, 1);
}

/* ---------- .hot and .lifted: hover, for a trunk that can be buried ----------
   Both classes are set by trunks.js from a pointer hit-test against the trunk's
   own footprint, NOT by :hover — see the long note at the top of the hover
   section in trunks.js. The short version: a trunk under the pile is never the
   topmost element anywhere on itself, so :hover never matches it and a click
   never reaches it. Measured on a 19x19 grid, a trunk under the pile was
   reachable on 0% of its footprint.

   .hot    — the pointer is over the footprint. Immediate, no delay. It replaces
             every :hover this file used to key off, and it is a strict superset
             of it, so nothing that used to react stopped reacting.
   .lifted — .hot for a dwell, or open. z-index only. */
.trunk.hot { filter: brightness(1.07) saturate(1.04); }

/* ---------- Coming to rest ----------
   Worn for --settle-ms after anything stands a personality down: the pointer
   leaving, the lid opening, a photo being filed, the trunk being picked up.
   Trunks.settleMotion has just committed the animation's last offset inline;
   these transitions are what carry it back to zero when it lets go again.

   The list repeats `filter` and `translate` because `transition` is a shorthand
   and would otherwise cancel the base rule's — the hover brightness and the
   enchanted lift both have to keep fading out while the body settles. */
.trunk.settling {
  transition: filter 0.28s ease,
              translate 0.9s cubic-bezier(0.2, 0.76, 0.3, 1),
              --bob-x var(--settle-ms) var(--settle-ease),
              --bob-y var(--settle-ms) var(--settle-ease),
              --bob-r var(--settle-ms) var(--settle-ease);
}
.trunk.settling .trunk-shadow {
  transition: translate 0.9s cubic-bezier(0.2, 0.76, 0.3, 1),
              transform var(--settle-ms) var(--settle-ease),
              opacity var(--settle-ms) ease;
}

/* ...and every other part a personality moves. Task 15 caught the body and its
   shadow; the lid, the straps, the latch, the buckles and the seam went on
   snapping to their parked pose in one frame, and the lid is the loudest of
   them by a distance — feral holds it at `rotateX(-21deg)` and restless at
   -12deg, against 4.5px and 6px of body travel. Measured before the fix:
   dropping `.hot` mid-buck took the lid from -21deg to 0 with `getAnimations()`
   empty, i.e. no transition generated at all. Trunks.settleMotion now commits
   each part's live transform/opacity inline, drops the class, flushes, and
   releases them under this rule — the shadow's mechanism above, applied to the
   rest of the trunk.

   THE FOUR :not()s ARE THE WHOLE REASON THIS IS A SEPARATE RULE, and getting
   them wrong is expensive. `.settling` also goes on when the lid OPENS and when
   a photo is FILED — settleMotion is what stands the personality down for those
   too — and both of those move the very same lid, on their own curves: a 340ms
   spring that overshoots past -128deg, and a 15deg crack. Those selectors are
   `.trunk.open .trunk-lid` and `.trunk.filing .trunk-lid`, at the same (0,2,1)
   specificity this rule would otherwise have, so a plain
   `.trunk.settling .trunk-lid` would beat them on source order and open every
   trunk on a 260ms ease with no spring at all. The :not()s take this to (0,6,1)
   AND make it stop matching, which is belt and braces on the one mistake here
   that would be visible on every single open.

   `.closing` and `.drop-target` are the other two co-occupants of this lid, and
   both were missed when this rule was written. Each was measured on the live
   page:

     - `.closing` (added in the Task 16 fix round): a close removes `.open`
       partway through, so a pointer leaving the trunk after that — click to
       close, then move away, an entirely ordinary gesture — matched this rule
       and finished the fall on 0.26s cubic-bezier(0.22, 0.85, 0.3, 1) instead
       of the fall's own 0.34s cubic-bezier(0.55, 0, 0.85, 0.42), with the thud
       still scheduled for the slower curve. See closeTrunk in trunks.js.
     - `.drop-target`: `.trunk.drop-target .trunk-lid` cracks the lid open on
       `0.26s cubic-bezier(0.34, 1.45, 0.5, 1)` — same duration, so the timing
       survived, but the 1.45 overshoot is what makes the lid read as LIFTING
       to meet the photo rather than sliding. Reachable by dragging a photo back
       over a trunk within 260ms of leaving it. Measured: without this :not(),
       `.trunk.drop-target.settling .trunk-lid` computed the settle's curve.

   The straps and the latch are listed once each and cover both copies (lid and
   body); `.trunk-emblem` is here for the enchanted glow's opacity — its filter
   is deliberately not settled, see SETTLE_PARTS in trunks.js. */
.trunk.settling:not(.open):not(.filing):not(.closing):not(.drop-target) .trunk-lid,
.trunk.settling:not(.open):not(.filing):not(.closing):not(.drop-target) .trunk-strap,
.trunk.settling:not(.open):not(.filing):not(.closing):not(.drop-target) .trunk-latch,
.trunk.settling:not(.open):not(.filing):not(.closing):not(.drop-target) .buckle,
.trunk.settling:not(.open):not(.filing):not(.closing):not(.drop-target) .trunk-seam,
.trunk.settling:not(.open):not(.filing):not(.closing):not(.drop-target) .trunk-emblem {
  transition: transform var(--settle-ms) var(--settle-ease),
              opacity var(--settle-ms) ease;
}

/* ---------- Carried ----------
   A trunk under the pointer is lifted OFF the desk, shadow left behind, exactly
   as the enchanted personality lifts — which is also what makes picking up an
   enchanted trunk a 2px change of height instead of the 7px drop it used to be.
   That drop was instant because this rule used to read `transition: none`, on
   the assumption that the drag needed it. It never did: the drag writes --x/--y,
   which are unregistered custom properties and can no more be transitioned than
   they can be animated. Deleting it cost nothing and fixed the jump.

   After .settling in source order, and repeating the same four transitions, so a
   trunk picked up mid-wobble keeps settling while it rises. */
.trunk.dragging {
  cursor: grabbing;
  translate: 0 calc(-9px * var(--u));
  transition: filter 0.28s ease,
              translate 0.22s var(--settle-ease),
              --bob-x var(--settle-ms) var(--settle-ease),
              --bob-y var(--settle-ms) var(--settle-ease),
              --bob-r var(--settle-ms) var(--settle-ease);
}
.trunk.dragging .trunk-shadow {
  translate: 0 calc(9px * var(--u));
  transition: translate 0.22s var(--settle-ease),
              transform var(--settle-ms) var(--settle-ease),
              opacity 0.22s ease;
  opacity: 0.72;
}

/* ---------- The z-index ladder ----------
   .trunk.lifted's number is not free, and it is worth writing the whole ladder
   down because the constraint is a squeeze from both sides.

   `.desk` is `position: absolute; z-index: auto` **on purpose** — style.css
   explains that a stacking context there would trap the focused polaroid's 1000
   inside .desk and put it behind the scrim. .trunk-layer is z-index: auto for
   its own reason (above). Between them, nothing contains a trunk: a lifted
   trunk's z-index lands in the ROOT stacking context, alongside the scrim, the
   switcher and the lock screen. So it does not only have to beat the pile, it
   has to lose to everything the pile loses to.

     0        .trunk                  at rest, under the pile
     1..300   .polaroid               inline, bringToFront clamped at
                                      Z_CEILING = 300 in polaroids.js
     400      .trunk.lifted           hovered (after the dwell) or open
     400      .trunk.drop-target      a dragged photo is over it (Task 11)
     400      .trunk.filing           it is swallowing a photo (Task 11)
     400      .trunk.dragging         it is being carried (Task 15) — the same
                                      slot, because a trunk in hand has to clear
                                      the pile for exactly the same reason a
                                      hovered one does, and must lose to the
                                      scrim for exactly the same reason too
     450      .bg-switcher            a fixed control: above the desk furniture
     500      .scrim                  focus dimmer, and its click target
     1000     .polaroid.focused
     4000     .polaroid.flying        a photo mid-flight clears an open lid
     5000     .magic-layer            particles over everything on the desk
     6000     .cursor-layer
     6500     .color-badge
     7000     .trunk-tag              the focused photo's brass tag (Task 12)
     7500     .trunk-form-backdrop    the trunk form, which stays mounted
                                      behind the studio
     7800     .st-backdrop            the emblem studio (studio.css), which
     7900     .st-panel               carries its own copy of these three
     7950     .st-cursors             rungs and points back at this list
     7960     .hint.notice            the one line of prose the page ever says
                                      to the user, so it has to clear every
                                      surface it can be raised over — including
                                      the studio, which it did not until it was
                                      moved up from 7600. See .hint.notice
     9999     .polaroid.dragging
     10000    .lock-screen

   The pile's ceiling came down from 900 to 300 to open the 400 slot. It had to
   move rather than the trunk: with the pile at 900 the only numbers above it
   were already above the scrim, and a lifted trunk that outranks the scrim
   renders crisp over the dimmed desk and swallows the click that dismisses the
   focused photo. */
.trunk.lifted,
.trunk.dragging { z-index: 400; }

/* ---------- Leather colourways ----------

   Each leather carries its LINING with it. --lining-0/1/2 is a three-stop ramp
   for the fabric inside the trunk — 0 in deepest shadow, 2 where the light
   reaches it — and it dresses the lid's underside, the back wall and the floor
   from one place, which is what makes the open trunk read as one lined box
   rather than as a lid and a hole that happen to be adjacent.

   THESE LININGS USED TO CONTRAST WITH THE HIDE, AND DELIBERATELY NO LONGER DO.
   The earlier pass dressed each trunk in the fabric its real-world counterpart
   would be lined with, chosen *against* its leather — aged ivory silk in the
   oxblood, oxblood silk in the black, forest baize in the tan, and each house's
   second colour in the four house trunks — on the argument that a bold colour
   inside a lid you have to open is a lining, while the same colour on the
   outside would be a costume.

   That argument is right about real trunks and wrong about these ones, and the
   site's owner said so having looked at them: at the size these actually
   render, a pale interior is not a lining glimpsed inside a box, it is the
   largest and brightest shape on the whole object. The eye arrived at the ivory
   slab first and at the trunk second — measured as the brightest region in the
   frame in all seven colourways, front panel included. The photograph these are
   drawn against does the exact opposite: its interior is dark and sits firmly
   in the leather's own family, and every bit of its depth comes from light
   falling off toward the bottom rather than from any change of colour.

   So each lining is now ITS OWN HIDE IN DEEP SHADE — the same hue, dropped well
   below --leather-2, the darkest stop the outside ever uses. That keeps the
   value separation that stops an open trunk reading as a shut one (the interior
   is far darker than the front panel, never lighter), while leaving the warm
   --glow washing up the back wall and pooling on the floor as the only bright
   thing in the box, which is where the reference puts it too.

   Because the fabric is now near-black instead of mid-toned, every black
   shading layer painted ON it had to come down to match — see the notes on
   .trunk-void and .trunk-void-floor. The old occlusion over a dark ramp was
   just a hole again, which is the failure the fabric existed to fix. */
.trunk[data-leather="oxblood"]    { --leather-0: #8a4040; --leather-1: #6b2f2f; --leather-2: #351616;
                                    --lining-0: #150a0a; --lining-1: #2b1413; --lining-2: #4d2523; }
.trunk[data-leather="black"]      { --leather-0: #4c4845; --leather-1: #33302e; --leather-2: #161514;
                                    --lining-0: #0b0a0a; --lining-1: #191817; --lining-2: #302d2a; }
.trunk[data-leather="tan"]        { --leather-0: #c08a4e; --leather-1: #a2703c; --leather-2: #5a381a;
                                    --lining-0: #1d1209; --lining-1: #3b2512; --lining-2: #653f1d; }
.trunk[data-leather="gryffindor"] { --leather-0: #9d2b31; --leather-1: #7d1f24; --leather-2: #3e0c0f;
                                    --lining-0: #170608; --lining-1: #300d11; --lining-2: #57191e; }
.trunk[data-leather="slytherin"]  { --leather-0: #2d6c4a; --leather-1: #1f4d35; --leather-2: #0b2317;
                                    --lining-0: #071410; --lining-1: #13291d; --lining-2: #234a33; }
.trunk[data-leather="ravenclaw"]  { --leather-0: #30487f; --leather-1: #22335e; --leather-2: #0d162e;
                                    --lining-0: #070b16; --lining-1: #111c35; --lining-2: #223a63; }
.trunk[data-leather="hufflepuff"] { --leather-0: #b28a28; --leather-1: #8a6a1e; --leather-2: #45350d;
                                    --lining-0: #161104; --lining-1: #2d2209; --lining-2: #513e12; }

/* ---------- Hardware ---------- */
.trunk[data-hardware="silver"] {
  --metal-hi: #ffffff;
  --metal-1: #d7dade;
  --metal-2: #8b9196;
  --metal-lo: #474d52;
  /* --glow is intentionally *not* overridden here — see the note on it above. */
}

/* ---------- Contact shadow: puts the trunk *on* the desk ---------- */
.trunk-shadow {
  position: absolute;
  left: -5%;
  right: -5%;
  bottom: calc(-9px * var(--u));
  height: calc(20px * var(--u));
  background: radial-gradient(ellipse 48% 46% at 50% 50%,
    rgba(46, 28, 10, 0.46) 0%,
    rgba(46, 28, 10, 0.20) 55%,
    transparent 78%);
  filter: blur(calc(2.5px * var(--u)));
  z-index: 0;
  pointer-events: none;
  /* Counter-translated when an enchanted trunk lifts, so the trunk rises *off*
     its shadow instead of carrying it up the desk. Same curve as the lift.

     `opacity` is here for the RETURN journey. `.trunk.dragging .trunk-shadow`
     fades it to 0.72 while the trunk is carried and transitions it at 0.22s —
     but that transition lives in the .dragging rule, so on release there was
     none: measured immediately after pointerup, the shadow's computed opacity
     was 1 with no opacity transition in getAnimations(), i.e. 0.72 -> 1 in a
     single frame, while its `translate` was still gliding 9px -> 0 over the
     0.9s above it. Half the shadow arriving 880ms before the other half is
     exactly the family of snap this whole pass exists to remove, and .settling
     is no help — its 260ms timer has long since fired for any drag worth the
     name. Matched to the .dragging rule's own 0.22s so the fade out and the
     fade back are the same gesture. */
  transition: translate 0.9s cubic-bezier(0.2, 0.76, 0.3, 1),
              opacity 0.22s ease;
}

/* ---------- Body ---------- */
.trunk-body {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(34px * var(--u));
  bottom: 0;
  z-index: 2;
  border-radius: calc(3px * var(--u));
  background-color: var(--leather-1);
  background-image:
    /* upper-left key light, falling off downward */
    linear-gradient(176deg,
      rgba(255, 240, 214, 0.18) 0%,
      rgba(255, 240, 214, 0.06) 20%,
      transparent 44%,
      rgba(0, 0, 0, 0.34) 100%),
    var(--grain),
    /* edge vignette — this is what makes it read as a solid, not a card */
    radial-gradient(ellipse 112% 106% at 44% 36%, transparent 34%, rgba(0, 0, 0, 0.52) 100%),
    var(--battens),
    var(--leather-base);
  box-shadow:
    inset 0 0 0 1px rgba(0, 0, 0, 0.38),
    inset 0 1px 0 rgba(255, 238, 210, 0.20),
    inset 5px 0 12px rgba(0, 0, 0, 0.40),
    inset -5px 0 12px rgba(0, 0, 0, 0.48),
    inset 0 -4px 8px rgba(0, 0, 0, 0.60),
    0 8px 15px rgba(44, 26, 8, 0.34),
    /* The drop-target rim (Task 11), threaded in as a variable rather than by
       re-declaring this whole list in the drop-target rule: these layers are
       what make leather read as leather, and a second copy of them would be a
       copy that silently rots. Transparent until a photo is over the trunk. */
    var(--drop-glow, 0 0 0 rgba(0, 0, 0, 0));
}

/* Bottom plinth batten */
.trunk-rail {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(13px * var(--u));
  border-radius: 0 0 calc(3px * var(--u)) calc(3px * var(--u));
  background-image:
    linear-gradient(180deg, rgba(255, 238, 210, 0.14) 0%, transparent 30%, rgba(0, 0, 0, 0.34) 100%);
  box-shadow: inset 0 1px 0 rgba(255, 240, 212, 0.20);
}

/* ---------- Lid ----------
   Hinged on its own back (top) edge and swung away from the viewer. */
.trunk-lid {
  position: absolute;
  /* The lid overhangs the body by a hair, the way a real trunk's does. */
  left: calc(-3px * var(--u));
  right: calc(-3px * var(--u));
  top: 0;
  height: calc(42px * var(--u));
  transform-origin: 50% 0%;
  transform: rotateX(0deg);
  transform-style: preserve-3d;
  transition: transform 0.62s cubic-bezier(0.34, 1.4, 0.5, 1);
  z-index: 3;
}
/* -128deg, not -108: at 108 the 42px lid foreshortens to ~9px on screen, which
   is not enough pixel budget for the lining to read as an inside surface — every
   gradient collapses into bands. 128 leaves ~22px. */
.trunk.open .trunk-lid { transform: rotateX(-128deg); }

.trunk-lid-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  /* Elliptical top corners wide enough to meet in the middle: the top edge
     stops being a straight line and becomes a shallow arch. That silhouette,
     more than any gradient, is what makes the lid read as domed. */
  border-radius: 46% 46% calc(2px * var(--u)) calc(2px * var(--u)) / 22% 22% calc(2px * var(--u)) calc(2px * var(--u));
  background-color: var(--leather-1);
  background-image:
    /* the dome: a narrow specular running along the lid's crown, then a fast
       falloff. One bright band beats an even wash for reading as curvature. */
    linear-gradient(180deg,
      rgba(255, 250, 234, 0.10) 0%,
      rgba(255, 250, 234, 0.44) 16%,
      rgba(255, 244, 216, 0.14) 34%,
      transparent 52%,
      rgba(0, 0, 0, 0.22) 100%),
    var(--grain),
    /* cylindrical falloff at both ends */
    radial-gradient(ellipse 120% 160% at 50% 26%, transparent 32%, rgba(0, 0, 0, 0.55) 100%),
    var(--battens),
    linear-gradient(180deg, var(--leather-0) 0%, var(--leather-1) 48%, var(--leather-2) 100%);
  box-shadow:
    inset 0 0 0 1px rgba(0, 0, 0, 0.34),
    inset 0 1px 0 rgba(255, 246, 224, 0.34),
    inset 6px 0 12px rgba(0, 0, 0, 0.40),
    inset -6px 0 12px rgba(0, 0, 0, 0.48),
    inset 0 -2px 5px rgba(0, 0, 0, 0.50),
    /* the parting shadow the lid casts onto the body */
    0 5px 9px rgba(30, 16, 4, 0.55),
    var(--drop-glow, 0 0 0 rgba(0, 0, 0, 0));      /* see .trunk-body */
}

/* Lid rim: the lip that overhangs the body. ::before so it paints beneath the
   straps, corners and studs that sit on top of it. */
.trunk-lid-face::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(10px * var(--u));
  border-radius: 0 0 calc(2px * var(--u)) calc(2px * var(--u));
  background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.02) 0%, rgba(0, 0, 0, 0.50) 100%);
  box-shadow: inset 0 1px 0 rgba(255, 238, 210, 0.22);
}

/* Underside of the lid — quilted lining, revealed as the lid swings back.
   Orientation, because it is not obvious: this panel is rotateX(180deg) inside
   a lid that is itself rotateX(-128deg), and the two compose to rotateX(52deg)
   — a POSITIVE cosine. So local Y is NOT reversed on screen: local 0% renders
   at the top (the lid's far rim, tipped away from you) and local 100% renders
   at the bottom, hard against the hinge. Verified on the page with a red/blue
   test ramp; reasoning about the 180deg flip alone gets this backwards.

   It is also inset 3px each side. The lid overhangs the body by 3px so the
   closed rim has a lip, but the *open* lid must line up with the box or it
   reads as 3px of bare desk down each side of the opening. Insetting the
   lining rather than resizing .trunk-lid on .open matters: left/right are not
   transitioned, so resizing the lid would snap 3px on each side during the
   first frames of opening, while the face is still visible. */
.trunk-lid-lining {
  position: absolute;
  left: calc(3px * var(--u));
  right: calc(3px * var(--u));
  top: 0;
  bottom: 0;
  transform: rotateX(180deg);
  backface-visibility: hidden;
  /* Arch on the LOCAL top == the far rim on screen. The hinge edge (local
     bottom) stays square so the lining meets the interior without the arch
     opening a gap at the corners. */
  border-radius:
    46% 46% calc(2px * var(--u)) calc(2px * var(--u)) /
    22% 22% calc(2px * var(--u)) calc(2px * var(--u));
  /* --- THIS ELEMENT IS THE OUTSIDE OF THE LID, NOT THE LINING ---------------
     Not a figure of speech, and worth stating plainly because the name says
     otherwise. Swung back to -128deg this panel is the ONLY face of the lid
     still pointing at the viewer: .trunk-lid-face composes to a backface and is
     culled, which was confirmed by hit-testing straight down the trunk's centre
     line on the live page — every point above the hinge returns
     .trunk-lid-lining, and every point below it returns .trunk-void. So the
     whole top band of an open trunk is this one element, and dressing all of it
     in fabric is what made that band belong to no particular trunk.

     What a real trunk shows you up there — and what the reference photograph
     shows — is the HIDE, wrapping over the rim, framing a dark interior. So the
     element itself now carries the same leather ramp as .trunk-lid-face, and
     the lining proper has moved into the inset ::before below. The frame of
     leather that leaves around the opening is the single biggest thing between
     the old open trunk and the photograph it is drawn against. */
  background-color: var(--leather-1);
  background-image:
    var(--grain),
    /* The rolled rim of the lid, catching the light along its crown. The strip
       of this that stays uncovered by the ::before is about five screen pixels
       tall at --u 1, so this is one ridge placed inside those five pixels, not
       a ramp spread over the panel: stops beyond ~20% are painted over. */
    linear-gradient(180deg,
      var(--leather-2) 0%,
      var(--leather-0) 9%,
      var(--leather-1) 20%,
      var(--leather-1) 100%);
  box-shadow:
    /* Hairline round the outside, so the lid keeps a defined silhouette against
       the pale parchment desk behind it. This replaces the heavy black scrim
       the fabric version ran over its top third — that scrim existed to hold
       the silhouette, and a 1px line does the same job without darkening the
       leather it is supposed to be showing off. */
    inset 0 0 0 calc(1px * var(--u)) rgba(0, 0, 0, 0.55),
    inset 0 1px 0 rgba(255, 246, 224, 0.26);
}

/* The lining proper: upholstery set INTO the lid, inside the leather frame.
   Open at the bottom (local bottom == the hinge, see the orientation note
   above) because that edge is the mouth of the box — the lid's interior and the
   trunk's interior are one continuous dark space there, and any margin across
   it would draw a leather bar over the opening.

   9px at the top against 6px at the sides is not an inconsistency: local Y is
   foreshortened to about 0.52 by the lid's rotation and local X is not, so 9
   and 6 are what arrive on screen as the same width of frame. Measured off the
   rendered band rather than assumed. */
.trunk-lid-lining::before {
  content: '';
  position: absolute;
  left: calc(6px * var(--u));
  right: calc(6px * var(--u));
  top: calc(9px * var(--u));
  bottom: 0;
  border-radius:
    40% 40% 0 0 / 16% 16% 0 0;
  background-color: var(--lining-0);
  background-image:
    /* --- the quilt ---------------------------------------------------------
       Two crossed sets of stitch valleys at --quilt-a / --quilt-b (see the
       geometry note on .trunk: they are 58/122deg rather than ±45 so the
       diamonds come out square AFTER the lid's vertical foreshortening). Each
       band is a dark seam with a lit shoulder immediately after it, which is
       what a pulled stitch actually looks like — the padding rises away from
       the thread.

       The dark half is now nearly gone and the lit half does the work. On the
       old mid-toned fabric a black seam was the readable half; on a lining this
       close to black it paints nothing at all, and leaving it at strength only
       muddied the panel. What survives being darkened is the thread catching
       the light, which is the correct half to keep — it is the reason this
       still reads as padded rather than as a flat dark card. */
    repeating-linear-gradient(var(--quilt-a),
      rgba(0, 0, 0, 0.14) 0 calc(1px * var(--u)),
      rgba(255, 250, 236, 0.11) calc(1px * var(--u)) calc(2.5px * var(--u)),
      transparent calc(2.5px * var(--u)) var(--quilt-pitch)),
    repeating-linear-gradient(var(--quilt-b),
      rgba(0, 0, 0, 0.14) 0 calc(1px * var(--u)),
      rgba(255, 250, 236, 0.11) calc(1px * var(--u)) calc(2.5px * var(--u)),
      transparent calc(2.5px * var(--u)) var(--quilt-pitch)),
    /* --- the light ---------------------------------------------------------
       Warm bounce up out of the lit box, strongest at the hinge (local bottom,
       which renders at the bottom). Tinted with --glow rather than a hard-coded
       amber so the lid's underside, the seam and the emblem are all lit by the
       same colour of light; a lid lit one gold and a seam leaking another is
       two light sources in a box that has one.

       Stronger than it was (0.40 -> 0.52). With the fabric under it dropped to
       near-black this is now the only thing separating the top of the lid's
       interior from the bottom of it, and at the old strength the panel read as
       one flat dark shape. */
    radial-gradient(ellipse 76% 128% at 50% 114%, rgba(var(--glow), 0.52) 0%, rgba(var(--glow), 0.13) 46%, transparent 78%),
    /* The far end of the interior, deepest where the lid meets its own rim. */
    linear-gradient(180deg, rgba(0, 0, 0, 0.50) 0%, rgba(0, 0, 0, 0.18) 34%, transparent 66%),
    linear-gradient(180deg, var(--lining-0) 0%, var(--lining-1) 68%, var(--lining-2) 100%);
  box-shadow:
    /* The rebate where the fabric meets the hide: a dark hairline, then a warm
       one just inside it standing in for the brass tack line that holds a real
       liner down. */
    inset 0 0 0 calc(1px * var(--u)) rgba(0, 0, 0, 0.68),
    inset 0 0 0 calc(2px * var(--u)) rgba(var(--glow), 0.10),
    inset 0 0 calc(5px * var(--u)) rgba(0, 0, 0, 0.40);
}

/* The lid's brass, seen from the other side.
   .trunk-lid-face carries corner caps and a stud line, and an open trunk shows
   NONE of it: the face is culled, so all that hardware swings away with it and
   the raised lid comes back as a bare band. On a real trunk it does not, because
   corner caps wrap the rim and studs stand proud of it — they are visible from
   whichever side you are on. The reference photograph is unambiguous about this;
   the brass around the outside of the raised lid is most of what says "school
   trunk" rather than "dark box".
   So this is the SAME hardware as .trunk-lid-face's, drawn on the face that is
   actually pointing at the viewer, and it is deliberately built from the same
   --metal-face sheet and the same stud expression rather than a second brass of
   its own — two brasses on one trunk is the sort of thing nobody can name but
   everybody sees.
   Confined to the leather strip above the lining (the ::before starts at 9px),
   and inset 4px from each side so nothing runs off the arch onto bare desk. */
.trunk-lid-lining::after {
  content: '';
  position: absolute;
  left: calc(4px * var(--u));
  right: calc(4px * var(--u));
  top: 0;
  height: calc(9px * var(--u));
  pointer-events: none;
  /* Clipped to the lid's own arch. The panel's top corners are a wide, shallow
     ellipse (46%/22% of a 174x42 box == about 80x9 at --u 1), so a rectangular
     strip of brass laid across the top has its two ends hanging in mid-air
     beyond where the leather has already curved down — measured on the page as
     a pair of gold tabs floating on bare desk, which is the same failure that
     made .trunk-lid-face inset its corner caps by 5px.
     Backgrounds are clipped by border-radius, so restating the arch here trims
     the caps to follow the curve instead, which is also what a real corner cap
     wrapped over an arched lid does. 48% rather than 46% because this strip is
     inset 4px each side and so is narrower than the panel it sits on; both
     resolve to the same ~80px horizontal radius. */
  border-radius: 48% 48% 0 0 / 100% 100% 0 0;
  background-image:
    /* The two corner caps. Listed FIRST so they paint over the ends of the stud
       line below — the alternative is arithmetic that keeps the last stud clear
       of the cap at every --u, and a layer that covers it cannot drift. */
    var(--metal-face),
    var(--metal-face),
    /* The stud line, lifted verbatim from .trunk-studs. */
    radial-gradient(circle calc(2.4px * var(--u)) at 50% 50%,
      var(--metal-hi) 0%, var(--metal-1) 40%, var(--metal-2) 72%, var(--metal-lo) 92%, transparent 96%);
  background-size:
    calc(15px * var(--u)) calc(7px * var(--u)),
    calc(15px * var(--u)) calc(7px * var(--u)),
    calc(14px * var(--u)) calc(6px * var(--u));
  background-position:
    left calc(1px * var(--u)),
    right calc(1px * var(--u)),
    center calc(1px * var(--u));
  background-repeat: no-repeat, no-repeat, repeat-x;
  /* Lifts the brass off the hide. Same drop-shadow .trunk-studs uses, so the
     light on this hardware falls the way it does everywhere else. */
  filter: drop-shadow(0 1px 0.5px rgba(0, 0, 0, 0.55));
}

/* ---------- Interior ----------
   Sits between the contact shadow and the body panel, so only the strip above
   the body's top edge is visible — exactly the opening you'd see. */
.trunk-void {
  position: absolute;
  /* Flush to every edge. Any inset here leaves a column of bare desk down the
     side of the opening and a gap under the lid, which is exactly what made the
     interior read as a shape sitting *behind* the trunk instead of inside it.
     Spans the hinge line (top) down to the body's top edge (bottom), so the
     opening is continuous with the swung-back lid. */
  left: 0;
  right: 0;
  top: 0;
  height: calc(34px * var(--u));
  z-index: 1;
  border-radius: 0;
  /* THIS ELEMENT IS THE BACK WALL AND THE TWO SIDE WALLS. The floor is its own
     child (.trunk-void-floor) because the one cue no stack of gradients can
     fake is the floor's far edge being NARROWER than its near edge, and that
     single trapezoid is what turned this from a dark rectangle into a box you
     are looking into. Everything here is therefore drawn as if the floor were
     not yet in front of it, and the triangles the trapezoid leaves uncovered in
     the bottom corners are read as the inner faces of the side walls — which is
     why those corners are the darkest part of the whole interior.

     Lined in the trunk's own --lining fabric rather than in black. A near-black
     void is unambiguous — nobody mistakes it for a closed trunk — but it is a
     HOLE, and a hole has no inside. The fabric is what gives it one; the light
     and the shadow layers on top of it are what stop it looking like a shelf. */
  background-color: var(--lining-0);
  background-image:
    /* Ambient occlusion. Crushes the corners where three surfaces meet and
       leaves the middle of the wall open, so the eye reads a cavity rather than
       a panel. Deliberately the FIRST layer: it goes over the light too, and a
       glow that survives into the corners is a glow with no geometry. */
    radial-gradient(ellipse 78% 104% at 50% 90%, transparent 32%, rgba(0, 0, 0, 0.42) 100%),
    /* Inner face of the left / right walls, turning away from the light. Their
       own background-size below, so the wedge stays a fixed width in --u and
       does not stretch with the trunk. */
    linear-gradient(90deg, rgba(0, 0, 0, 0.74) 0%, rgba(0, 0, 0, 0.44) 38%, rgba(0, 0, 0, 0.12) 74%, transparent 100%),
    linear-gradient(270deg, rgba(0, 0, 0, 0.74) 0%, rgba(0, 0, 0, 0.44) 38%, rgba(0, 0, 0, 0.12) 74%, transparent 100%),
    /* The lid's own shadow. The deepest dark in the box is immediately under
       the hinge, not at the bottom — that is where the lid is closest to the
       wall and where nothing can reach.
       Note the stops crowd into the top HALF. Everything below 48% of this
       element is covered by the floor, so a ramp spread evenly over 0-100%
       spends half its stops where nothing can see them and arrives at the
       crease still two thirds black — which is what kept the back wall reading
       as a hole no matter how much fabric was put behind it. */
    linear-gradient(180deg, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.40) 20%, rgba(0, 0, 0, 0.10) 46%, transparent 62%),
    /* The light itself, rising from the floor and washing up the back wall.
       --glow, the same channels the seam and the emblem use, because §5 says
       the interior is "lit warm when open" and §6 has that light flooding out
       onto the desk — .trunk-spill is the outside of this same lamp. It is a
       STATIC gradient: there is deliberately no fourth animated thing inside an
       open trunk, whose lid, spill and erupting photos are already moving.

       Centred at 48%, NOT below the box. The floor is a child painted over the
       bottom 52% of this element, so a glow centred at the trunk's true floor
       level is a glow nobody ever sees — the only part of this wall still
       showing is the 48% above the crease, and that is where the light has to
       land. 48% puts its hottest point exactly ON the crease, with the whole
       upper half of the ellipse washing up the wall. Measured by moving it and
       looking; the first two attempts (104%, then 58%) were both underneath the
       floor and painted nothing at all. */
    radial-gradient(ellipse 66% 52% at 50% 48%, rgba(var(--glow), 0.44) 0%, rgba(var(--glow), 0.15) 46%, transparent 92%),
    /* The same quilt as the lid, at roughly a third of its contrast: it is the
       same fabric, but this panel is in shade and a wall as crisply quilted as
       the lid would read as being nearer than the lid, which is backwards.
       Dark half all but removed for the same reason as on the lid — a black
       seam on near-black fabric is a layer that costs a paint and shows
       nothing. */
    repeating-linear-gradient(var(--quilt-a),
      rgba(0, 0, 0, 0.10) 0 calc(1px * var(--u)),
      rgba(255, 250, 236, 0.06) calc(1px * var(--u)) calc(2.5px * var(--u)),
      transparent calc(2.5px * var(--u)) var(--quilt-pitch)),
    repeating-linear-gradient(var(--quilt-b),
      rgba(0, 0, 0, 0.10) 0 calc(1px * var(--u)),
      rgba(255, 250, 236, 0.06) calc(1px * var(--u)) calc(2.5px * var(--u)),
      transparent calc(2.5px * var(--u)) var(--quilt-pitch)),
    linear-gradient(180deg, var(--lining-0) 0%, var(--lining-1) 100%);
  background-size:
    100% 100%,
    calc(26px * var(--u)) 100%,
    calc(26px * var(--u)) 100%,
    100% 100%,
    100% 100%,
    100% 100%,
    100% 100%,
    100% 100%;
  background-position: 0 0, 0 0, 100% 0, 0 0, 0 0, 0 0, 0 0, 0 0;
  background-repeat: no-repeat;
  /* The lid's contact shadow along the hinge. Softened from 4/7/0.9 when the
     wall stopped being black: at that strength it swallowed the top third of a
     fabric wall whole, and a lining you cannot see is not a lining. */
  box-shadow: inset 0 calc(3px * var(--u)) calc(6px * var(--u)) rgba(0, 0, 0, 0.58);
  opacity: 0;
  transition: opacity 0.35s ease 0.16s;
}
.trunk.open .trunk-void { opacity: 1; }

/* ---------- The floor ----------
   A trapezoid, and that is the entire reason it is an element instead of two
   more gradient stops on .trunk-void. Perspective narrows the far edge of a
   floor and widens the near one; gradients can shade a rectangle but they
   cannot change its shape, and a rectangle of lit floor under a rectangle of
   dark wall is a diagram of a box, not a box. Clipped rather than transformed
   because a real rotateX floor would need its own preserve-3d parent and would
   land in the lid's 3D context, and because a clip-path is not animated and so
   cannot regress anything on the compositor.

   8% in on each side at the back is a shallow read of the 760px perspective the
   trunk already sets — enough for the eye to catch the taper, little enough
   that the box does not read as a funnel. It was 14% first, and 14% over a
   floor only 18px tall put the side walls at nearly 55 degrees, which read as a
   lampshade rather than as depth. */
.trunk-void-floor {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 52%;
  /* Nothing in the interior is a target. The trunk's own click handler lives on
     .trunk and still receives everything through this, but a decorative panel
     should never be the thing an event names. */
  pointer-events: none;
  clip-path: polygon(8% 0%, 92% 0%, 100% 100%, 0% 100%);
  background-color: var(--lining-1);
  background-image:
    /* The front lip, shaded by the body's rim standing over it. Without some
       darkening here the floor runs bright all the way to the bottom edge and
       the box loses its front wall — the interior stops being a container and
       becomes a lit ledge. Gentle, though: the first pass ran to 0.76 black
       from 20% and drew a hard bar across the mouth instead of a lip. */
    linear-gradient(180deg, transparent 46%, rgba(0, 0, 0, 0.14) 76%, rgba(0, 0, 0, 0.34) 100%),
    /* The pool of light lying ON the floor — an ellipse flattened hard on Y so
       it lies down instead of hanging in the air, and pushed to the back where
       the lamp is. Kept to 0.26: at 0.50 the floor blew out to a flat cream
       plank and stopped being fabric, which is the failure that made the FIRST
       version of this interior read as a shelf rather than a bottom.
       Raised 0.26 -> 0.36 with the fabric under it now near-black: the ceiling
       that mattered was "does the floor stop looking like fabric", and against
       a dark lining that ceiling is a good deal higher than it was against a
       cream one. This pool is now most of what tells you the box has a bottom
       at all. */
    radial-gradient(ellipse 44% 66% at 50% 14%, rgba(var(--glow), 0.36) 0%, rgba(var(--glow), 0.12) 56%, transparent 88%),
    /* Boards running across the box. Their spacing OPENS toward the viewer —
       hand-placed stops rather than a repeating gradient, because evenly spaced
       lines are the one thing that reads as flat. */
    linear-gradient(180deg,
      transparent 0%,
      rgba(0, 0, 0, 0.12) 21%, transparent 23%,
      transparent 47%,
      rgba(0, 0, 0, 0.10) 50%, transparent 53%,
      transparent 100%),
    /* The same quilt again, now at about a third of even the back wall's. The
       floor of a trunk is lined too, and without any of this the surface came
       out as a flat sheet — once the lightest, largest, emptiest area in the
       whole trunk. Foreshortened far harder than the lid, so the diamonds
       arrive as a fine weave rather than as a pattern, which is what is wanted
       here: a reason for the eye to believe the surface, not another thing to
       look at.
       Halved again when the lining went dark. Diamonds legible on BOTH the lid
       and the floor made the open trunk read as two quilted panels stacked one
       above the other rather than as a lid over a cavity — the floor is the
       furthest surface in the box and the most steeply foreshortened, so it is
       the one that has to give its texture up first. */
    repeating-linear-gradient(var(--quilt-a),
      rgba(0, 0, 0, 0.05) 0 calc(1px * var(--u)),
      rgba(255, 250, 236, 0.032) calc(1px * var(--u)) calc(2.5px * var(--u)),
      transparent calc(2.5px * var(--u)) var(--quilt-pitch)),
    repeating-linear-gradient(var(--quilt-b),
      rgba(0, 0, 0, 0.05) 0 calc(1px * var(--u)),
      rgba(255, 250, 236, 0.032) calc(1px * var(--u)) calc(2.5px * var(--u)),
      transparent calc(2.5px * var(--u)) var(--quilt-pitch)),
    /* The floor is the one surface in the box facing the light, so it is the
       LIGHTEST large area in here. Running this ramp the other way (dark at the
       front) is what made the floor read as a shelf: a floor darker than the
       wall behind it has no explanation.
       It peaks in the MIDDLE and falls off at both ends. A floor that is
       brightest exactly where it is also widest — the front lip — reads as a
       ledge sticking out of the trunk rather than as a bottom lying inside it;
       pulling the highlight back off the near edge is what puts it back in. */
    linear-gradient(180deg, var(--lining-1) 0%, var(--lining-2) 52%, var(--lining-1) 100%);
  /* The crease where the floor meets the back wall, lit rather than occluded:
     the lamp is inside the box and low, so the join is where its light is
     brightest and where it starts climbing the wall. Kept faint — at full
     strength it stops being a crease and becomes a drawn line across the box.
     Which is exactly what happened when the interior went dark: against the old
     mid-toned fabric 0.26 was a hint, against a near-black one the same line was
     the brightest thing in the box and sawed it into two stacked panels, so the
     wall above read as one quilted slab and the floor below as another. It was
     briefly raised to 0.34 on the theory that a darker box needed more light at
     the join; looking at it at 2.4x said the opposite. The depth wants a
     FALLOFF, not an edge — the glow pool above does that job — so this is now
     lower than it has ever been and only just separates the two planes. */
  box-shadow: inset 0 calc(1px * var(--u)) 0 rgba(var(--glow), 0.16);
}

/* Warm spill catching the very top of the front wall's inner face. Kept low:
   the floor above it is already lit, and a bright band here would blur the
   boundary between the inside of the box and the body panel below it. */
.trunk-void-rim {
  position: absolute;
  left: calc(10px * var(--u));
  right: calc(10px * var(--u));
  bottom: 0;
  height: calc(3px * var(--u));
  background: linear-gradient(180deg, transparent, rgba(var(--glow), 0.18));
  filter: blur(calc(1.5px * var(--u)));
  pointer-events: none;
}

/* Gold light leaking from the lid gap — a hint that something is inside. */
.trunk-seam {
  position: absolute;
  left: calc(14px * var(--u));
  right: calc(14px * var(--u));
  top: calc(33px * var(--u));
  height: calc(3px * var(--u));
  z-index: 4;
  background: linear-gradient(90deg, transparent, #ffd77a 35%, #fff0c4 50%, #ffd77a 65%, transparent);
  filter: blur(calc(1.6px * var(--u)));
  opacity: 0;
  pointer-events: none;
  /* transform as well as opacity: the drop-target state scales this bar to
     1.6 (Task 11) and with opacity alone the light faded in over 0.3s while
     the bar itself jumped. */
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.trunk:not(.open).hot .trunk-seam { opacity: 0.9; }

/* Open, the same element becomes the hinge. The lid pivots on .trunk-lid's top
   edge (transform-origin: 50% 0%, top: 0), so the pivot line is y = 0 — that is
   where a piano hinge and its brass knuckles belong, and painting something
   there is what stops the lid reading as a slab floating above the box. */
.trunk.open .trunk-seam {
  left: 0;
  right: 0;
  top: calc(-1px * var(--u));
  height: calc(5px * var(--u));
  border-radius: 0;
  filter: none;
  opacity: 1;
  background-color: transparent;
  background-image:
    /* hinge knuckles */
    repeating-linear-gradient(90deg,
      transparent 0 calc(9px * var(--u)),
      rgba(255, 226, 164, 0.18) calc(9px * var(--u)) calc(11.5px * var(--u)),
      transparent calc(11.5px * var(--u)) calc(20px * var(--u))),
    linear-gradient(180deg,
      rgba(104, 72, 26, 0.80) 0%,
      rgba(0, 0, 0, 0.88) 52%,
      rgba(0, 0, 0, 0.96) 100%);
  box-shadow: 0 1px calc(2px * var(--u)) rgba(0, 0, 0, 0.6);
}

/* ---------- Brass corner caps ----------
   Real caps wrap the corner as an L bracket, so clip-path cuts the inner
   square out. box-shadow is clipped away with it — drop-shadow is not. */
.corner {
  position: absolute;
  width: calc(18px * var(--u));
  height: calc(18px * var(--u));
  background-image: var(--metal-face);
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
}
.corner::before {
  content: '';
  position: absolute;
  width: 26%;
  height: 26%;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 28%, var(--metal-hi) 0%, var(--metal-1) 45%, var(--metal-lo) 100%);
}

.corner.tl {
  left: 0; top: 0;
  border-radius: calc(6px * var(--u)) 0 0 0;
  clip-path: polygon(0 0, 100% 0, 100% 34%, 34% 34%, 34% 100%, 0 100%);
}
.corner.tr {
  right: 0; top: 0;
  border-radius: 0 calc(6px * var(--u)) 0 0;
  clip-path: polygon(0 0, 100% 0, 100% 100%, 66% 100%, 66% 34%, 0 34%);
}
.corner.bl {
  left: 0; bottom: 0;
  border-radius: 0 0 0 calc(3px * var(--u));
  clip-path: polygon(0 0, 34% 0, 34% 66%, 100% 66%, 100% 100%, 0 100%);
}
.corner.br {
  right: 0; bottom: 0;
  border-radius: 0 0 calc(3px * var(--u)) 0;
  clip-path: polygon(100% 0, 100% 100%, 0 100%, 0 66%, 66% 66%, 66% 0);
}
.corner.tl::before { left: 12%; top: 12%; }
.corner.tr::before { right: 12%; top: 12%; }
.corner.bl::before { left: 12%; bottom: 12%; }
.corner.br::before { right: 12%; bottom: 12%; }

/* Only the four corners you'd actually see from the front: the lid's top pair
   and the body's bottom pair. */
.trunk-lid-face .corner.bl,
.trunk-lid-face .corner.br,
.trunk-body .corner.tl,
.trunk-body .corner.tr { display: none; }

/* The lid's top corners sit on an arch, not on a square corner. Left flush at
   0,0 a square cap's outer triangle hangs off the leather onto bare desk and
   blunts the dome. Inset them onto the hide and round the outer corner into a
   wide ellipse that follows the arch. */
.trunk-lid-face .corner.tl,
.trunk-lid-face .corner.tr {
  top: calc(5px * var(--u));
}
.trunk-lid-face .corner.tl {
  left: calc(5px * var(--u));
  border-radius: calc(16px * var(--u)) 0 0 0 / calc(9px * var(--u)) 0 0 0;
}
.trunk-lid-face .corner.tr {
  right: calc(5px * var(--u));
  border-radius: 0 calc(16px * var(--u)) 0 0 / 0 calc(9px * var(--u)) 0 0;
}

/* ---------- Stud lines ---------- */
.trunk-studs {
  position: absolute;
  left: calc(22px * var(--u));
  right: calc(22px * var(--u));
  height: calc(6px * var(--u));
  z-index: 2;
  background-image: radial-gradient(circle calc(2.4px * var(--u)) at 50% 50%,
    var(--metal-hi) 0%, var(--metal-1) 40%, var(--metal-2) 72%, var(--metal-lo) 92%, transparent 96%);
  background-size: calc(14px * var(--u)) 100%;
  background-repeat: repeat-x;
  filter: drop-shadow(0 1px 0.5px rgba(0, 0, 0, 0.6));
  opacity: 0.92;
}
.trunk-lid-face .trunk-studs {
  bottom: calc(2px * var(--u));
  left: calc(25px * var(--u));
  right: calc(25px * var(--u));
}
.trunk-body .trunk-studs { bottom: calc(4.5px * var(--u)); }

/* ---------- Leather straps ---------- */
.trunk-strap {
  position: absolute;
  top: 0;
  bottom: 0;
  width: calc(18px * var(--u));
  z-index: 1;
  background-color: var(--leather-2);
  background-image:
    linear-gradient(90deg,
      rgba(0, 0, 0, 0.68) 0%,
      rgba(0, 0, 0, 0.20) 20%,
      rgba(255, 240, 214, 0.16) 40%,
      rgba(0, 0, 0, 0.14) 62%,
      rgba(0, 0, 0, 0.68) 100%),
    repeating-linear-gradient(58deg, rgba(255, 244, 224, 0.06) 0 1px, transparent 1px 4px),
    linear-gradient(180deg, var(--leather-1), var(--leather-2));
  box-shadow: 1px 0 3px rgba(0, 0, 0, 0.45), -1px 0 3px rgba(0, 0, 0, 0.35);
}
.trunk-strap-l { left: calc(30px * var(--u)); }
.trunk-strap-r { right: calc(30px * var(--u)); }
/* The lid is 3px wider on each side than the body, so its straps and studs
   need the same 3px offset or they step sideways at the seam. */
.trunk-lid-face .trunk-strap-l { left: calc(33px * var(--u)); }
.trunk-lid-face .trunk-strap-r { right: calc(33px * var(--u)); }

/* Saddle stitching down both edges */
.trunk-strap::before,
.trunk-strap::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: repeating-linear-gradient(180deg,
    rgba(255, 232, 194, 0.45) 0 3px, transparent 3px 7px);
  opacity: 0.7;
}
.trunk-strap::before { left: calc(3.5px * var(--u)); }
.trunk-strap::after { right: calc(3.5px * var(--u)); }

/* The buckle belongs to the body strap; the lid strap just passes over. */
.trunk-lid-face .buckle { display: none; }

.buckle {
  position: absolute;
  left: calc(-4px * var(--u));
  top: calc(24px * var(--u));
  width: calc(26px * var(--u));
  height: calc(14px * var(--u));
  border-radius: calc(2.5px * var(--u));
  background-image: var(--metal-face);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1px 0 rgba(0, 0, 0, 0.55),
    0 1px 3px rgba(0, 0, 0, 0.6);
}
.buckle::before {
  content: '';
  position: absolute;
  inset: calc(3.5px * var(--u));
  border-radius: 1px;
  background: rgba(18, 10, 4, 0.78);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.85);
}
.buckle::after {
  content: '';
  position: absolute;
  left: 50%;
  top: calc(1px * var(--u));
  bottom: calc(1px * var(--u));
  width: calc(2.2px * var(--u));
  margin-left: calc(-1.1px * var(--u));
  border-radius: 1px;
  background-image: var(--metal-face);
}

/* ---------- Central hasp latch ----------
   A direct child of .trunk so it can straddle the lid/body seam: .trunk-body
   carries a z-index and would trap it in its own stacking context, beneath the
   lid. z-index 4 beats the lid's 3, so it reads as fastened *over* the rim. */
.trunk-latch {
  position: absolute;
  left: 50%;
  top: calc(25px * var(--u));
  width: calc(30px * var(--u));
  height: calc(21px * var(--u));
  margin-left: calc(-15px * var(--u));
  z-index: 4;
  border-radius: calc(3px * var(--u)) calc(3px * var(--u)) calc(7px * var(--u)) calc(7px * var(--u));
  background-image: var(--metal-face);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    inset 0 -2px 3px rgba(0, 0, 0, 0.45),
    0 2px 5px rgba(0, 0, 0, 0.55);
}
/* The fold line where the hasp hinges over the lid rim */
.trunk-latch::before {
  content: '';
  position: absolute;
  left: calc(3px * var(--u));
  right: calc(3px * var(--u));
  top: calc(9px * var(--u));
  height: 1px;
  background: rgba(0, 0, 0, 0.55);
  box-shadow: 0 1px 0 rgba(255, 242, 206, 0.28);
}
/* Keyhole */
.trunk-latch::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: calc(3px * var(--u));
  width: calc(5.5px * var(--u));
  height: calc(8px * var(--u));
  margin-left: calc(-2.75px * var(--u));
  border-radius: 50% 50% 25% 25%;
  background: #170e05;
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.9), 0 1px 0 rgba(255, 242, 206, 0.30);
}
/* When open the hasp has swung free — hide it rather than leave it floating.
   The spring it does on the way out lives with the rest of the open/close
   choreography, further down. */

/* ---------- Emblem ---------- */
.trunk-emblem {
  position: absolute;
  left: 50%;
  top: calc(13px * var(--u));
  width: calc(30px * var(--u));
  height: calc(30px * var(--u));
  margin-left: calc(-15px * var(--u));
  z-index: 2;
  color: var(--metal-1);
  opacity: 0.94;
  /* Warm halo on every finish, matching the seam. The crest's own colour comes
     from `color: var(--metal-1)` above, which is what makes silver look silver;
     the light it sheds is the trunk's light, not the metal's. */
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.65))
          drop-shadow(0 0 calc(3px * var(--u)) rgba(var(--glow), 0.28));
  pointer-events: none;
}
.trunk-emblem svg { width: 100%; height: 100%; display: block; }

/* A DRAWN EMBLEM IS NOT TINTED.
   The sixteen presets are line art in `currentColor` — that is the entire
   reason they are inlined rather than <img>'d — so `color: var(--metal-1)`
   above is what turns a crest brass on one trunk and silver on the next. A
   drawn emblem is the opposite kind of thing: the owner chose "full colour,
   exactly as drawn", so every mark drawings.js emits carries its own explicit
   `stroke`/`fill`, and the trunk's hardware must not get a say.
   In practice the attributes already win — an explicit `stroke="#6d2833"`
   never consults `color` — so this rule is the belt to that pair of braces: it
   takes the metal out of the inheritance chain entirely, so a drawing that
   somehow does emit a `currentColor` mark (a traced preset line, a future
   tool) still cannot come out brass. Stated on the slot AND on the <svg>,
   because either one is a place a later rule could reintroduce it. */
.trunk-emblem.is-drawn { color: inherit; }
.trunk-emblem.is-drawn svg { color: inherit; }

/* ---------- Stencilled brass name plate (also the edit affordance) ---------- */
.trunk-plate {
  position: absolute;
  left: 50%;
  bottom: calc(19px * var(--u));
  transform: translateX(-50%);
  z-index: 3;
  min-width: calc(78px * var(--u));
  /* 132px on a 180px body was 73% of the trunk's width and 2.5x the emblem's
     area — the plate, not the crest, was the first thing you read. */
  max-width: calc(112px * var(--u));
  padding: calc(1.5px * var(--u)) calc(7px * var(--u));
  font-family: 'Kalam', cursive;
  font-weight: 700;
  /* --u-scaled, not a fixed 10px: at the 120px mobile size a fixed size made
     the plate ~72% of the body and clipped the name to an ellipsis. */
  font-size: calc(10px * var(--u));
  line-height: 1.3;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: #2b1d06;
  text-shadow: 0 1px 0 rgba(255, 246, 214, 0.5);
  background-image: var(--metal-plate);
  border: 1px solid rgba(0, 0, 0, 0.42);
  border-radius: calc(2px * var(--u));
  /* An inset bezel plus a hairline seat, not an outer drop shadow: a plate
     screwed flush into leather does not float above it. */
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 1px 0 rgba(255, 240, 210, 0.18);
  cursor: pointer;
  opacity: 0.95;
  transition: opacity 0.25s ease, box-shadow 0.25s ease;
}
.trunk[data-hardware="silver"] .trunk-plate {
  color: #24282b;
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55);
}
.trunk.hot .trunk-plate {
  opacity: 1;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 0 9px rgba(255, 214, 122, 0.55);
}
/* The plate under the cursor, as distinct from the TRUNK under the cursor.
   .trunk.hot above already brightens the plate, but it fires whenever the
   pointer is anywhere on the trunk, so crossing from the leather onto the brass
   changed nothing and the plate never announced itself as a separate control —
   which is half of why nobody found the way to rename or delete a trunk. This
   is the other half of that fix, with the `title` and the hint line: a plate
   that lights on its own says "I am not a label" before the tooltip has even
   appeared, and says it to the mouse, which is the instrument doing the looking.

   Deliberately a different gesture from .trunk.hot's soft amber bloom rather
   than more of it: the ring tightens and the bezel sharpens, the way a physical
   plate catches the light when you lean toward it. `filter: brightness` would
   have been one line, but the plate's own --metal-plate ramp is what makes it
   read as brass and lifting it uniformly flattens that ramp toward yellow.

   Focus gets the same treatment as hover, so tabbing to a plate looks like
   pointing at one — the keyboard route to the form is otherwise invisible. It
   keeps the outline on top of that: the ring alone is an aesthetic change, not
   an accessible focus indicator.

   :hover is safe to lean on here even though this is a desk full of drag
   gestures — at <=640px trunks.css sets `pointer-events: none` on the plate, so
   a phone cannot land in this state and be left stuck in it. */
.trunk-plate:hover,
.trunk-plate:focus-visible {
  opacity: 1;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.55),
    inset 0 -1px 1px rgba(0, 0, 0, 0.34),
    0 0 0 1px rgba(0, 0, 0, 0.55),
    0 0 0 calc(2px * var(--u)) rgba(255, 214, 122, 0.5),
    0 0 10px rgba(255, 214, 122, 0.4);
}
.trunk-plate:focus-visible { outline: 2px solid #ffd77a; outline-offset: 2px; }

/* ---------- The brass luggage tag ----------
   Under a focused photo: the name of the trunk holding it, and the way back out.
   This is the ONLY place membership is visible — a photo lying on the desk looks
   the same whether it belongs to a trunk or to nobody — so it has to be legible
   at a glance and unmistakably part of the same set of fittings as the trunk it
   names.

   Same material as .trunk-plate above, deliberately: the stencilled ramp that
   starts at --metal-1 rather than --metal-hi, the inset bezel, the hairline
   seat. The ramp is spelled out again instead of reused through --metal-plate
   because this element is a child of <body> — it is position: fixed and has to
   escape .desk's stacking context — so no .trunk's custom properties reach it.
   If the trunk's brass is ever re-mixed, re-mix these three stops with it.

   The one departure from the plate is the drop shadow. A name plate is screwed
   flush into leather and must not float; a luggage tag is tied on and hangs, and
   it is floating over a blurred, dimmed desk with nothing behind it to seat
   against. */
.trunk-tag {
  --metal-1: #d9ab52;
  --metal-2: #8a6520;
  --metal-lo: #4e380e;

  position: fixed;
  left: 50%;
  top: 0;                       /* real value set by Polaroids.showTrunkTag */
  /* The lift is part of the transform, so the entrance animates transform and
     opacity only. translateX(-50%) has to be repeated in .visible — a transform
     is one value, not a list, and dropping the centring in the second rule would
     slide the tag half its width to the right as it appeared. */
  transform: translateX(-50%) translateY(-5px);
  /* Above the scrim (500) and the focused polaroid (1000) — it is furniture
     belonging to the focused photo and has to be readable and clickable over
     both. Below .polaroid.dragging (9999) and the lock screen (10000). See the
     full ladder at .trunk.lifted; this adds a rung and moves none. */
  z-index: 7000;

  display: flex;
  align-items: center;
  gap: 9px;
  max-width: min(74vw, 360px);
  padding: 5px 11px 5px 9px;

  font-family: 'Kalam', cursive;
  font-weight: 700;
  font-size: 12px;
  line-height: 1.35;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: #2b1d06;
  text-shadow: 0 1px 0 rgba(255, 246, 214, 0.5);

  background-image: linear-gradient(157deg,
    var(--metal-1) 0%,
    var(--metal-2) 26%,
    var(--metal-1) 52%,
    var(--metal-2) 74%,
    var(--metal-lo) 100%);
  border: 1px solid rgba(0, 0, 0, 0.42);
  border-radius: 3px;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 6px 18px rgba(0, 0, 0, 0.5);

  opacity: 0;
  /* pointer-events alone only takes it off the mouse. Without visibility the
     hidden tag keeps its ✕ in the tab order forever — a phantom tab stop with
     an invisible focus ring, on every page whether anything was ever filed or
     not. visibility is not animatable but it *is* transitionable: it flips at
     the end of the fade-out and at the start of the fade-in, so the tag still
     fades away rather than blinking out. */
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity 0.28s ease,
    transform 0.28s cubic-bezier(0.2, 0.76, 0.3, 1),
    visibility 0.28s;
}
.trunk-tag.visible {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
  pointer-events: auto;
}

/* The punched eyelet. It is what makes this a luggage tag rather than a chip:
   a hole through the brass, dark at the bottom with a lit lower rim. */
.trunk-tag::before {
  content: '';
  flex: 0 0 auto;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: radial-gradient(circle at 42% 34%, #150d02 0%, #2a1c06 60%, #47320d 100%);
  box-shadow:
    inset 0 1px 1px rgba(0, 0, 0, 0.9),
    0 1px 0 rgba(255, 242, 208, 0.4);
}

.trunk-tag .trunk-tag-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Scored groove before the ✕: a dark line with the lit edge on its far side,
   which is how an engraved cut reads on metal. */
.trunk-tag button {
  flex: 0 0 auto;
  border: 0;
  border-left: 1px solid rgba(0, 0, 0, 0.32);
  background: transparent;
  box-shadow: inset 1px 0 0 rgba(255, 244, 214, 0.22);
  padding: 1px 1px 1px 9px;
  margin: -1px 0;
  font: inherit;
  font-size: 14px;
  letter-spacing: 0;
  line-height: 1;
  color: #2b1d06;
  text-shadow: 0 1px 0 rgba(255, 246, 214, 0.45);
  opacity: 0.62;
  cursor: pointer;
  transition: opacity 0.18s ease;
}
.trunk-tag button:hover { opacity: 1; }
.trunk-tag button:focus-visible { outline: 2px solid #ffd77a; outline-offset: 2px; }

/* ---------- Particle canvas (Task 8 draws here) ----------
   Above the desk, below the lock screen. */
.magic-layer {
  position: fixed;
  inset: 0;
  z-index: 5000;
  pointer-events: none;
}

/* ===== Hover personalities =====
   Three characters, driven entirely off [data-personality] and .hot.

   .hot was :hover until Task 10's review: a trunk under the pile is never the
   topmost element on any of its own pixels, so :hover could not see it. .hot is
   set by trunks.js from a hit-test against the trunk's footprint and matches
   everywhere :hover used to, plus the buried case. Nothing else changed — no
   dwell, no delay, and it still clears the moment the pointer leaves.

   Rules that hold for all three:
   - Only transform / translate / opacity / filter are animated, so nothing
     leaves the compositor and nothing triggers layout.
   - Every trunk keyframe opens and closes on var(--park). A trunk that ends a
     cycle anywhere else would walk across the desk, one pixel per loop.
   - All amplitudes are multiplied by var(--u), so the 120px mobile trunk shakes
     proportionally rather than 50% harder than the desktop one.
   - Per-keyframe animation-timing-function does the heavy lifting. A trunk is a
     heavy, latched object: it loads up slowly against the catch
     (cubic-bezier(.5,0,.9,.3)), lets go all at once (cubic-bezier(.08,.9,.28,1))
     and settles. Uniform ease-in-out on the whole loop is what makes wobbles
     read as a bouncing div.
   - The rhythms are deliberately not symmetric and the three periods (2.9s /
     2.15s / 3.6s) are unrelated, so two personalities side by side never beat
     against each other. */

/* ---------- restless: something in there wants out ----------
   A shove against the latch, a long pause, two knocks with a beat between them,
   and — long after you have written it off — one small tick.

   Rhythm map (percentages; x2.9s for the base period):
     0-15    shove and settle
     15-44   0.84s of absolute stillness  <- the longest silence in the loop,
                                             and it sits *inside* the cycle,
                                             where a pause reads as suspense
     44-53   first knock
     53-66   0.38s beat
     66-73   second knock, held back so the loop does not end on dead air
     73-89   the tick: 1px, plus a blip in the seam
     89-100  0.32s

   Every stop resolves to zero offset at 0% and 100%, so the loop cannot walk.
   Amplitudes are deliberately about half of feral's (peak 4.5px of body travel
   against feral's 10.4px, -12deg of lid against -21deg) — restless has to be
   legible on its own, but the moment the two read as the same intensity the
   personalities stop being personalities. Most of the authority lives in the
   lid, not the box: a latched trunk strains against its catch, it does not
   slide around the desk. */
@keyframes trunk-restless {
  0% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
    animation-timing-function: cubic-bezier(0.55, 0, 0.85, 0.32);
  }
  /* the shove — it throws its weight at the left wall */
  4% {
    --bob-x: calc(-4.2px * var(--u)); --bob-y: calc(-1.4px * var(--u)); --bob-r: -1.7deg;
    animation-timing-function: cubic-bezier(0.08, 0.92, 0.28, 1);
  }
  8% {
    --bob-x: calc(3.1px * var(--u)); --bob-y: 0px; --bob-r: 1.1deg;
  }
  12% {
    --bob-x: calc(-1px * var(--u)); --bob-y: 0px; --bob-r: -0.32deg;
    animation-timing-function: cubic-bezier(0.25, 1, 0.4, 1);
  }
  /* ... and then it holds still and listens */
  15%, 44% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
    animation-timing-function: cubic-bezier(0.6, 0, 0.9, 0.3);
  }
  /* knock */
  47% {
    --bob-x: calc(3.5px * var(--u)); --bob-y: calc(-2.7px * var(--u)); --bob-r: 1.4deg;
    animation-timing-function: cubic-bezier(0.1, 0.9, 0.3, 1);
  }
  50% {
    --bob-x: calc(-1.2px * var(--u)); --bob-y: calc(-0.6px * var(--u)); --bob-r: -0.36deg;
  }
  53% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
    animation-timing-function: cubic-bezier(0.62, 0, 0.9, 0.28);
  }
  /* knock — held back a beat rather than following straight on. Separating the
     pair, instead of stretching the burst, is what moves the dead air off the
     end of the loop without spending any of the pause. */
  66% {
    --bob-x: calc(-4px * var(--u)); --bob-y: calc(-2px * var(--u)); --bob-r: -1.45deg;
    animation-timing-function: cubic-bezier(0.1, 0.9, 0.3, 1);
  }
  69% {
    --bob-x: calc(1.5px * var(--u)); --bob-y: 0px; --bob-r: 0.48deg;
  }
  73% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
    animation-timing-function: cubic-bezier(0.62, 0, 0.9, 0.28);
  }
  /* the tick: barely a pixel. A single quiet knock after a long silence is
     exactly what "it is still in there" sounds like, and it costs nothing. */
  84% {
    --bob-x: calc(-1px * var(--u)); --bob-y: 0px; --bob-r: -0.3deg;
    animation-timing-function: cubic-bezier(0.1, 0.9, 0.3, 1);
  }
  86.5% {
    --bob-x: calc(0.4px * var(--u)); --bob-y: 0px; --bob-r: 0.12deg;
  }
  89%, 100% { --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg; }
}

/* The lid lifting against the catch and dropping back. Negative rotateX on the
   lid's own hinge (transform-origin: 50% 0%) is the same axis .open uses, so it
   reads as the same lid cracking open a few degrees. This is where restless
   buys its legibility: -12deg of lid is far more visible per unit of motion
   than another 2px of body travel, and it is still comfortably short of
   feral's -21deg slam. */
@keyframes trunk-lid-strain {
  0%, 15%, 44%, 53%, 73%, 89%, 100% { transform: rotateX(0deg); }
  4%    { transform: rotateX(-8.5deg); }
  9%    { transform: rotateX(-2deg); }
  47%   { transform: rotateX(-12deg); }
  50%   { transform: rotateX(-3deg); }
  66%   { transform: rotateX(-8deg); }
  69%   { transform: rotateX(-1.6deg); }
  84%   { transform: rotateX(-1.8deg); }
}

/* Gold light flaring out of the gap each time the lid lifts. Never fully off
   between flares — a dim base line reads as a lit interior, an off/on blink
   reads as a broken bulb. */
@keyframes trunk-seam-flicker {
  0%, 15%, 44%, 53%, 73%, 89%, 100% { opacity: 0.16; transform: scaleY(1); }
  3.5%  { opacity: 0.95; transform: scaleY(1.55); }
  6%    { opacity: 0.42; transform: scaleY(1.1); }
  9%    { opacity: 0.7;  transform: scaleY(1.25); }
  46.5% { opacity: 1;    transform: scaleY(1.85); }
  50%   { opacity: 0.4;  transform: scaleY(1.1); }
  65.5% { opacity: 0.92; transform: scaleY(1.5); }
  69%   { transform: scaleY(1.1); opacity: 0.34; }
  /* the tick's blip — small enough that it registers as a glint, not a flare */
  83.5% { opacity: 0.5;  transform: scaleY(1.2); }
  86%   { opacity: 0.24; transform: scaleY(1.05); }
}

/* Loose brass on a leather strap: it lags the box and rings a moment longer. */
@keyframes trunk-buckle-rattle {
  0%, 15%, 44%, 53%, 73%, 89%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
  5%  { transform: translate3d(0, calc(1.5px * var(--u)), 0) rotate(-3deg); }
  9%  { transform: translate3d(0, calc(-0.7px * var(--u)), 0) rotate(1.7deg); }
  12% { transform: translate3d(0, calc(0.4px * var(--u)), 0) rotate(-0.7deg); }
  48% { transform: translate3d(0, calc(1.7px * var(--u)), 0) rotate(3.2deg); }
  51% { transform: translate3d(0, calc(-0.6px * var(--u)), 0) rotate(-1.2deg); }
  67% { transform: translate3d(0, calc(1.3px * var(--u)), 0) rotate(-2.7deg); }
  70% { transform: translate3d(0, calc(-0.4px * var(--u)), 0) rotate(0.9deg); }
  85% { transform: translate3d(0, calc(0.5px * var(--u)), 0) rotate(-0.9deg); }
}

/* ---------- feral: the Monster Book of Monsters ----------
   Three times the amplitude, twice the rate, and it skitters — but the skitter
   is a decaying oscillation about zero offset, so it always ends up exactly
   where it was left instead of walking off the desk. */
@keyframes trunk-feral {
  0% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
    animation-timing-function: cubic-bezier(0.35, 0, 0.95, 0.35);
  }
  /* the lunge */
  4% {
    --bob-x: calc(-7px * var(--u)); --bob-y: calc(-5px * var(--u)); --bob-r: -3.4deg;
    animation-timing-function: cubic-bezier(0.05, 0.95, 0.25, 1);
  }
  8.5% {
    --bob-x: calc(7.5px * var(--u)); --bob-y: calc(-2px * var(--u)); --bob-r: 3deg;
  }
  12.5% {
    --bob-x: calc(-5px * var(--u)); --bob-y: calc(-0.5px * var(--u)); --bob-r: -2deg;
  }
  16.5% {
    --bob-x: calc(3px * var(--u)); --bob-y: 0px; --bob-r: 1.1deg;
  }
  20% {
    --bob-x: calc(-1.2px * var(--u)); --bob-y: 0px; --bob-r: -0.4deg;
    animation-timing-function: cubic-bezier(0.25, 1, 0.4, 1);
  }
  24%, 50% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
    animation-timing-function: cubic-bezier(0.35, 0, 0.95, 0.3);
  }
  /* the second, harder lunge, with a four-step skitter home */
  54% {
    --bob-x: calc(8.5px * var(--u)); --bob-y: calc(-6px * var(--u)); --bob-r: 3.8deg;
    animation-timing-function: cubic-bezier(0.05, 0.95, 0.25, 1);
  }
  58% {
    --bob-x: calc(-6px * var(--u)); --bob-y: calc(-1px * var(--u)); --bob-r: -2.4deg;
  }
  62% {
    --bob-x: calc(4px * var(--u)); --bob-y: calc(-3px * var(--u)); --bob-r: 1.4deg;
  }
  66% {
    --bob-x: calc(-2.4px * var(--u)); --bob-y: calc(-0.4px * var(--u)); --bob-r: -0.8deg;
  }
  70% {
    --bob-x: calc(1px * var(--u)); --bob-y: 0px; --bob-r: 0.25deg;
    animation-timing-function: cubic-bezier(0.25, 1, 0.4, 1);
  }
  74%, 100% { --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg; }
}

/* Not straining — slamming. -21deg is far enough that you see lining, which is
   the difference between "the lid moved" and "something hit the lid". */
@keyframes trunk-lid-buck {
  0%, 24%, 50%, 74%, 100% { transform: rotateX(0deg); }
  4%    { transform: rotateX(-17deg); }
  8.5%  { transform: rotateX(-4deg); }
  12.5% { transform: rotateX(-9deg); }
  16.5% { transform: rotateX(-2deg); }
  54%   { transform: rotateX(-21deg); }
  58%   { transform: rotateX(-6deg); }
  62%   { transform: rotateX(-11deg); }
  66%   { transform: rotateX(-2.5deg); }
}

/* Straps whipping. Pivoted at the top (see the hover rule) so the free end
   travels furthest, the way a strap actually behaves. */
@keyframes trunk-strap-whip {
  0%, 24%, 50%, 74%, 100% { transform: rotate(0deg) translate3d(0, 0, 0); }
  4%    { transform: rotate(2.6deg) translate3d(calc(1px * var(--u)), 0, 0); }
  8.5%  { transform: rotate(-2.2deg) translate3d(calc(-1px * var(--u)), 0, 0); }
  12.5% { transform: rotate(1.4deg) translate3d(0, 0, 0); }
  16.5% { transform: rotate(-0.7deg) translate3d(0, 0, 0); }
  54%   { transform: rotate(-3deg) translate3d(calc(-1.2px * var(--u)), 0, 0); }
  58%   { transform: rotate(2.4deg) translate3d(calc(1px * var(--u)), 0, 0); }
  62%   { transform: rotate(-1.3deg) translate3d(0, 0, 0); }
  66%   { transform: rotate(0.6deg) translate3d(0, 0, 0); }
}

/* The hasp jumping in its keeper. */
@keyframes trunk-latch-rattle {
  0%, 24%, 50%, 74%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
  4%    { transform: translate3d(0, calc(1.6px * var(--u)), 0) rotate(-3deg); }
  8.5%  { transform: translate3d(0, calc(-1px * var(--u)), 0) rotate(2.4deg); }
  12.5% { transform: translate3d(0, calc(1px * var(--u)), 0) rotate(-1.4deg); }
  54%   { transform: translate3d(0, calc(2px * var(--u)), 0) rotate(3.4deg); }
  58%   { transform: translate3d(0, calc(-1.2px * var(--u)), 0) rotate(-2deg); }
  62%   { transform: translate3d(0, calc(0.8px * var(--u)), 0) rotate(1deg); }
}

/* ---------- enchanted: calm, and not quite right ----------
   The 7px lift is a *transition* on `translate` (see .trunk), not a keyframe:
   it rises and — the part keyframes cannot do — sinks back gracefully when you
   leave. This animation only adds the slow bob and sway on top, opening and
   closing on zero offset so leaving mid-cycle can never displace it.

   The sway extremes sit at 25% and 75%, *not* at 0%/100%. Hanging them off the
   ends parks the trunk 1.6px to the left of its spot for the whole loop, which
   costs nothing while the animation runs but means the graceful sink on
   pointerleave begins with a 2px sideways jerk as the offset snaps back to
   zero. The bob still peaks at 50%, so the counter-translated shadow below stays
   exactly in step.

   The bob itself is what used to jump 4.5px when the pointer left, because it
   lived on `transform` and an animation ending on `transform` cannot transition.
   It now rides --bob-*, which can — see the @property block at the top. */
@keyframes trunk-enchanted {
  0%, 100% {
    --bob-x: 0px; --bob-y: 0px; --bob-r: 0deg;
  }
  25% {
    --bob-x: calc(-1.6px * var(--u)); --bob-y: calc(-2.25px * var(--u)); --bob-r: -0.55deg;
  }
  50% {
    --bob-x: 0px; --bob-y: calc(-4.5px * var(--u)); --bob-r: 0deg;
  }
  75% {
    --bob-x: calc(1.6px * var(--u)); --bob-y: calc(-2.25px * var(--u)); --bob-r: 0.55deg;
  }
}

/* The shadow breathes against the bob: +4.5px cancels the trunk's -4.5px so the
   shadow stays welded to the desk while the trunk drifts above it, and it
   shrinks and thins as the gap opens. Identity at 0%/100%, so nothing snaps. */
@keyframes trunk-shadow-breathe {
  0%, 100% { transform: scale(1) translateY(0); opacity: 1; }
  50%      { transform: scale(0.9) translateY(calc(4.5px * var(--u))); opacity: 0.7; }
}

/* Three parts, and all three are needed:
   - brightness() first, so the halos below are cast by an already-lit crest.
     This is the only part that reads on *pale* leather: a warm halo on tan is
     a warm smudge on tan, near-invisible whatever its alpha, because the leather
     and the light are the same value. Lifting the crest itself is what separates
     it from the hide.
   - a tight halo, which hugs the 1px stroke and reads as hot metal.
   - the wide soft halo, which is the light in the air around it.
   Both stops carry the same four functions in the same order — a filter list
   that changes shape between keyframes cannot interpolate and snaps instead. */
@keyframes trunk-emblem-glow {
  0%, 100% {
    opacity: 0.9;
    filter: brightness(1)
            drop-shadow(0 1px 1px rgba(0, 0, 0, 0.65))
            drop-shadow(0 0 calc(1px * var(--u)) rgba(var(--glow), 0.2))
            drop-shadow(0 0 calc(3px * var(--u)) rgba(var(--glow), 0.34));
  }
  50% {
    opacity: 1;
    filter: brightness(1.3)
            drop-shadow(0 1px 1px rgba(0, 0, 0, 0.65))
            drop-shadow(0 0 calc(2px * var(--u)) rgba(var(--glow), 0.95))
            drop-shadow(0 0 calc(11px * var(--u)) rgba(var(--glow), 0.95));
  }
}

/* A steady tidal swell, not restless's flare — same element, opposite reading. */
@keyframes trunk-seam-breathe {
  0%, 100% { opacity: 0.34; transform: scaleY(1); }
  50%      { opacity: 0.95; transform: scaleY(1.3); }
}

/* ---------- Wiring ----------
   :not(.open):not(.dragging):not(.filing):not(.closing) on every rule — an open
   trunk has its lid at -128deg, a dragged one is following the pointer, a filing
   one is holding its lid cracked for a photo that is on its way in (Task 11),
   and a CLOSING one has 340ms of accelerating fall to get through. Any of the
   four shaking itself apart is a bug, not a personality.

   .closing was added in the Task 16 fix round and it is the one of the four
   that was actually observed misbehaving. `.open` comes off partway through a
   close — at slamAt, which is 0 for an empty trunk — and every selector here is
   `.hot:not(.open)...`, so with the pointer still resting on the trunk the
   personality RE-ARMED ON THE FALLING LID. Confirmed live: 30ms into the close
   of a hovered feral trunk, `trunk-lid-buck` was running on `.trunk-lid`. An
   animation beats a transition outright, so for the whole of the fall the
   personality owned the lid and the fall did not happen at all. The same class
   also keeps the settle off that lid; see the settle rule near the top.

   .filing (and now .closing) is on the list rather than handled with an
   `animation: none` blanket because the blanket would need !important to
   outrank these six selectors, and an !important animation is exactly the thing
   that survives the reduced-motion block at the bottom of this file. */
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="restless"] {
  animation: trunk-restless var(--dur-restless) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="restless"] .trunk-lid {
  animation: trunk-lid-strain var(--dur-restless) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="restless"] .trunk-seam {
  animation: trunk-seam-flicker var(--dur-restless) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="restless"] .buckle {
  animation: trunk-buckle-rattle var(--dur-restless) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}

.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="feral"] {
  animation: trunk-feral var(--dur-feral) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="feral"] .trunk-lid {
  animation: trunk-lid-buck var(--dur-feral) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="feral"] .trunk-strap {
  transform-origin: 50% 0%;
  animation: trunk-strap-whip var(--dur-feral) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="feral"] .trunk-latch {
  animation: trunk-latch-rattle var(--dur-feral) cubic-bezier(0.2, 0.85, 0.35, 1) infinite;
}

.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="enchanted"] {
  translate: 0 calc(-7px * var(--u));
  animation: trunk-enchanted var(--dur-enchanted) ease-in-out infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="enchanted"] .trunk-shadow {
  translate: 0 calc(7px * var(--u));
  animation: trunk-shadow-breathe var(--dur-enchanted) ease-in-out infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="enchanted"] .trunk-emblem {
  animation: trunk-emblem-glow var(--dur-enchanted) ease-in-out infinite;
}
.trunk.hot:not(.open):not(.dragging):not(.filing):not(.closing)[data-personality="enchanted"] .trunk-seam {
  animation: trunk-seam-breathe var(--dur-enchanted) ease-in-out infinite;
}

/* ===== Opening and closing (Task 10) =====
   The lid, the light, and the photos in flight.

   Ease curves carry this, not amplitude. A trunk is a heavy latched box: it
   springs *open* against its hinge and it *drops* shut, and those are two
   different curves on the same element. */

/* Opening is a spring (the base .trunk-lid transition, cubic-bezier(.34,1.4,…),
   which overshoots past -128deg and settles back). Closing is a fall: gravity
   does not ease out, so the drop accelerates all the way into the rim and stops
   dead. The rule has to hang off :not(.open) because a transition is governed
   by the transition properties of the style being transitioned *to* — with
   .open removed, this is that style. */
.trunk:not(.open) .trunk-lid {
  transition: transform var(--lid-drop-ms) cubic-bezier(0.55, 0, 0.85, 0.42);
}

/* The hasp springs up and off as the catch lets go, rather than simply fading.
   transform-origin at the bottom pivots it on the keeper, which is where a real
   hasp is hinged. Kept off the transform property of anything that already
   animates: .trunk-latch's only other transform is feral's rattle, and that is
   gated on :not(.open).

   The transform-origin does re-pivot that rattle, which used to spin about the
   hasp's centre (the initial 50% 50%) and now spins about its bottom edge. Over
   the rattle's +/-3.4deg on a 21px hasp that moves the top corners by about
   0.6px — visually nothing, but it is a Task 9 element behaving differently, so
   it is recorded here rather than left to be discovered. */
.trunk-latch {
  transform: translate3d(0, 0, 0) rotate(0deg);
  transform-origin: 50% 100%;
  transition: opacity 0.2s ease, transform 0.34s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.trunk.open .trunk-latch {
  opacity: 0;
  transform: translate3d(0, calc(-4px * var(--u)), 0) rotate(-19deg);
  transition: opacity 0.24s ease 0.04s, transform 0.34s cubic-bezier(0.3, 1.7, 0.45, 1);
}

/* Warm light thrown out of an open trunk onto the desk around it. A separate
   element rather than a box-shadow on .trunk-body, because the light has to
   spill *past* the trunk's own footprint onto bare desk and has to fade on its
   own schedule (behind the lid, so the box is lit before the light lands).
   Sits above .trunk-shadow and below .trunk-void, so it is light on the desk
   with the trunk standing on top of it.

   The geometry is dictated by what is actually visible: the trunk's own body
   covers the middle of this element, so a tight pool centred on the box is a
   pool nobody sees. The light has to be wide enough to clear the sides and low
   enough to pour onto the desk in front of the plinth. */
.trunk-spill {
  position: absolute;
  left: calc(-96px * var(--u));
  right: calc(-96px * var(--u));
  top: calc(-40px * var(--u));
  bottom: calc(-62px * var(--u));
  z-index: 0;
  pointer-events: none;
  background-image:
    /* the broad halo in the air around the open box */
    radial-gradient(ellipse 52% 46% at 50% 44%, rgba(255, 201, 116, 0.40) 0%, rgba(255, 186, 96, 0.17) 46%, transparent 74%),
    /* the pool on the desk in front of it, flattened hard on Y so it lies down
       on the wood instead of hanging in the air */
    radial-gradient(ellipse 62% 20% at 50% 86%, rgba(255, 206, 128, 0.34) 0%, rgba(255, 196, 110, 0.12) 44%, transparent 76%);
  filter: blur(calc(10px * var(--u)));
  opacity: 0;
  transform: scale(0.84);
  transform-origin: 50% 42%;
  transition: opacity 0.44s ease 0.14s, transform 0.6s cubic-bezier(0.2, 0.8, 0.3, 1) 0.1s;
}
.trunk.open .trunk-spill { opacity: 1; transform: scale(1); }

/* ---------- Photos in flight ----------
   A filed photo is NEVER reparented into the trunk. It stays a direct child of
   .desk and is hidden with display:none (Polaroids.setVisible), because
   Polaroids.clearPolaroids() reaps every .polaroid descendant of .desk on
   resize — a photo living inside a trunk would be destroyed on the first resize
   and never come back. So the whole flight is done with transforms on the
   polaroid exactly where it already sits.

   --tx/--ty are the trunk's mouth in DESK coordinates, set by trunks.js and
   already offset by half the polaroid, so they are directly comparable with the
   polaroid's own --x/--y. Nothing here mixes them with viewport pixels; the
   particle calls in trunks.js do that conversion separately. */
.polaroid.flying {
  pointer-events: none;
  /* Over the pile and over an open trunk's lid, under the particle canvas
     (5000) so the gold dust reads as being in front of the photo. */
  z-index: 4000 !important;
  /* The keyframes own the transform for the length of the flight; leaving the
     pile transition in place would double-drive it. */
  transition: none;
}

/* Collapsed inside the trunk: what a photo looks like before it is thrown and
   after it is swallowed, and the pose both flights start or end on. */
.polaroid.in-trunk {
  transform: translate3d(var(--tx, 0px), var(--ty, 0px), 0) rotate(220deg) scale(0.06);
  opacity: 0;
}

/* Durations come from the :root variables at the top of this file, which
   trunks.js reads back — the JS schedules the lid slam against them. */
.polaroid.erupting { animation: polaroid-erupt var(--erupt-ms) both; }
.polaroid.absorbing { animation: polaroid-absorb var(--absorb-ms) both; }

/* Thrown out of the mouth and onto its remembered coordinate. The arc is real:
   the 55% stop is the midpoint of the journey lifted 62px, so the photo goes
   *up and over* rather than sliding along the desk. It arrives slightly past
   its mark, high and over-rotated, and settles back — a thrown object has
   momentum to shed, and shedding it is the entire difference between "magic"
   and "a div moving". Per-keyframe easing: fast out of the trunk, floaty over
   the top, sharp into the landing, soft on the settle. */
@keyframes polaroid-erupt {
  0% {
    opacity: 0;
    transform: translate3d(var(--tx, 0px), var(--ty, 0px), 0) rotate(196deg) scale(0.05);
    animation-timing-function: cubic-bezier(0.05, 0.7, 0.34, 1);
  }
  11% { opacity: 1; }
  55% {
    transform:
      translate3d(calc((var(--tx, 0px) + var(--x)) / 2), calc((var(--ty, 0px) + var(--y)) / 2 - 62px), 0)
      rotate(calc(var(--r) + 34deg)) scale(1.02);
    animation-timing-function: cubic-bezier(0.42, 0, 0.28, 1);
  }
  79% {
    transform: translate3d(var(--x), calc(var(--y) - 9px), 0) rotate(calc(var(--r) - 6deg)) scale(1.05);
    animation-timing-function: cubic-bezier(0.32, 0, 0.2, 1);
  }
  90% {
    transform: translate3d(var(--x), calc(var(--y) + 2px), 0) rotate(calc(var(--r) + 1.8deg)) scale(0.994);
  }
  100% {
    opacity: 1;
    transform: translate3d(var(--x), var(--y), 0) rotate(var(--r)) scale(1);
  }
}

/* Sucked back in. The mirror of erupt in shape but not in feeling: it starts by
   being tugged 5% the *wrong* way — the intake catching hold — then accelerates
   the whole way in and spins faster as it goes, and the flight path is nearly
   straight (a 16px sag against erupt's 62px lift) because a vacuum pulls, it
   does not throw. Opacity holds until the last 14%, so the photo vanishes *into*
   the mouth instead of fading out on the way there. */
@keyframes polaroid-absorb {
  0% {
    opacity: 1;
    transform: translate3d(var(--x), var(--y), 0) rotate(var(--r)) scale(1);
    animation-timing-function: cubic-bezier(0.34, 0, 0.66, 0.16);
  }
  18% {
    transform:
      translate3d(
        calc(var(--x) + (var(--x) - var(--tx, 0px)) * 0.05),
        calc(var(--y) + (var(--y) - var(--ty, 0px)) * 0.05 - 4px), 0)
      rotate(calc(var(--r) - 5deg)) scale(1.03);
    animation-timing-function: cubic-bezier(0.6, 0, 0.9, 0.4);
  }
  64% {
    transform:
      translate3d(calc((var(--tx, 0px) + var(--x)) / 2), calc((var(--ty, 0px) + var(--y)) / 2 - 16px), 0)
      rotate(calc(var(--r) - 92deg)) scale(0.7);
    animation-timing-function: cubic-bezier(0.5, 0, 0.86, 0.44);
  }
  86% { opacity: 1; }
  100% {
    opacity: 0;
    transform: translate3d(var(--tx, 0px), var(--ty, 0px), 0) rotate(calc(var(--r) - 300deg)) scale(0.05);
  }
}

/* ===== Drop targeting (Task 11) =====
   Dragging a photo over a trunk and letting go files it. The trunk has to read
   as *reaching for* the photo, not as a rectangle that happens to accept a
   drop, so it cracks its lid, lights the seam in the gap and puts a gold rim
   around the whole box.

   Two classes, both set from trunks.js off the same footprint hit-test the
   hover uses (:hover cannot see a trunk under the pile — see .hot):

     .drop-target  a dragged photo is over this trunk. Exactly one trunk at a
                   time; it comes off on pointerup and on pointercancel.
     .filing       a photo is on its way in. The lid holds a wider crack for the
                   length of the flight and then falls on it.

   Both sit above the personalities in this file so that on a tie — .hot and
   .drop-target both setting `filter`, say — the drop state is the one that
   shows. In practice the hover stands down for the length of a photo drag
   anyway (trunks.js, pointer.photoDrag), so the two rarely coincide. */

.trunk.drop-target,
.trunk.filing {
  /* Consumed by .trunk-body and .trunk-lid-face, which append it to their own
     material box-shadows. --u-scaled so the 120px mobile trunk gets a rim in
     proportion rather than a halo half its own height. */
  --drop-glow: 0 0 calc(18px * var(--u)) rgba(var(--glow), 0.85);
  filter: brightness(1.1) saturate(1.05);
  /* Above the pile, for the same reason an open trunk is: a trunk under a
     scatter of photos can show as much gold as it likes and none of it will be
     visible. This is the one place the drag *does* lift a trunk — the hover
     lift stands down for the length of a drag — and it is safe because the
     photo in hand is at 9999 and the scrim above at 500, so the trunk rises
     between the pile and the photo it is reaching for, and nowhere else.
     Same rung as .trunk.lifted, so nothing else in the ladder moves. */
  z-index: 400;
}

/* The lid cracking. Not applied to an open trunk: its lid is already at -128deg
   and pulling it back a few degrees would read as the trunk *closing* on the
   photo.

   The angles are bigger than they look on paper. The lid is only 42px tall and
   it is being rotated away from the viewer under a 760px perspective, so the
   first ~15deg foreshorten to about a pixel and read as nothing at all; the gap
   under the rim, and the seam light coming out of it, are what actually carry
   the state. -22 is the smallest crack that is legible on a 120px trunk, and
   filing opens it half as far again — the trunk is not signalling any more, it
   is taking the photo. */
.trunk.drop-target:not(.open) .trunk-lid { transform: rotateX(-22deg); }
.trunk.filing:not(.open) .trunk-lid { transform: rotateX(-38deg); }

/* The base transition for a closed lid is the *fall* (see .trunk:not(.open)
   .trunk-lid) — gravity, no ease out. A lid being lifted needs the other
   curve, so it is restated here; taking the class off hands it straight back to
   the fall, which is what makes the lid drop on the photo. */
.trunk.drop-target:not(.open) .trunk-lid,
.trunk.filing:not(.open) .trunk-lid {
  transition: transform 0.26s cubic-bezier(0.34, 1.45, 0.5, 1);
}

/* Gold in the gap. scaleY only — the seam is a 3px blurred bar, and scaling it
   is what makes the light read as spilling *out* rather than merely brightening
   in place. */
.trunk.drop-target:not(.open) .trunk-seam,
.trunk.filing:not(.open) .trunk-seam {
  opacity: 1;
  transform: scaleY(1.6);
}

/* The crest flaring as the trunk takes the photo.

   On the <svg>, not on .trunk-emblem: the enchanted personality already owns
   .trunk-emblem's `animation` at a specificity this rule cannot reach without
   !important, and an !important animation would also outrank the
   reduced-motion blanket at the bottom of this file. The crest inside the slot
   is a different element, so the two compose instead of fighting — and
   `.trunk *` still switches this one off under reduced motion. */
@keyframes emblem-pulse {
  0%, 100% { transform: scale(1); filter: none; }
  45% {
    transform: scale(1.28);
    filter: drop-shadow(0 0 calc(12px * var(--u)) rgba(var(--glow), 1));
  }
}
.trunk-emblem.pulse svg { animation: emblem-pulse 0.5s ease-out; }

/* ---------- The photo in hand ----------
   A trunk is 180x120 and a polaroid is up to 270x450, so a photo held over a
   trunk hides it completely — lid, seam, gold rim and all. Over a drop target
   the photo draws back to roughly the size of the opening, which uncovers the
   trunk's reaction and says plainly what is about to happen to the photo.

   The flag is on .desk, not on the polaroid: the shrink then keys off .dragging
   as well, so it is released automatically the instant the drag ends and there
   is no per-photo class to clean up on any of the paths out of a drag. */
.desk.drop-armed .polaroid.dragging {
  opacity: 0.8;
  /* .polaroid.dragging kills all transitions so the drag tracks the pointer
     exactly. Only opacity is given back — a transitioned `transform` would put
     0.2s of lag between the cursor and the photo. */
  transition: opacity 0.18s ease;
}
/* The scale goes on the inner card, for that reason: .polaroid's own transform
   is the drag position and must stay untransitioned.

   Scaling alone is not enough. A polaroid is portrait and a trunk is landscape,
   so a card shrunk far enough to sit *inside* a 180x120 trunk is too small to
   recognise — at 0.52 it still covered the lid, the seam, the crest and the
   plate, which is everything that reacts. So it is lifted as well, and lands
   just above the lid with the seam showing beneath it: the photo hovering over
   the gap it is about to go into.

   The lift is px PLUS a percentage, and the split is what makes it work for
   every polaroid on the desk. Read the transform right to left: the card is
   scaled about its own centre, then lifted by a distance measured in the
   parent's unscaled units. So the card's bottom edge ends up at

       -66px - 22%h + (42%h / 2)  =  -66px - 1%h

   — i.e. the same ~70px above the pointer whatever the photo's height. The
   percentage is deliberately half the scale factor; drop it and the tall
   polaroids (515px) sit on the trunk while the short ones (215px) float. */
.desk.drop-armed .polaroid.dragging .polaroid-inner {
  transform: translateY(calc(-66px - 22%)) scale(0.42);
}
/* .releasing is .dragging's other half, and it exists because the unwind
   outlives the class that caused it. At the instant of release .dragging and
   .drop-armed both come off, so the card has 138px of lift and a 0.42 scale to
   give back on a frame where the only transition left on .polaroid-inner is the
   0.7s card flip in style.css — and it is not just the open-trunk drop that
   lands here, it is every NEAR MISS: released while armed but a few pixels
   outside the trunk, which is the commonest armed release there is. main.js's
   onDragEnd holds the class on for 220ms, just past the end of the curve. */
.polaroid.dragging .polaroid-inner,
.polaroid.releasing .polaroid-inner {
  /* Both ways round, and quick: the base transition on .polaroid-inner is the
     0.7s card flip, which would leave a photo swelling back to full size long
     after it had left the trunk. */
  transition: transform 0.2s cubic-bezier(0.3, 0.9, 0.35, 1);
}
/* Coming OUT of a trunk there is nothing to unwind — the card was never drawn
   back — so the flip transition is simply switched off for the flight. */
.polaroid.flying .polaroid-inner { transition: none; }

/* Going IN is the opposite problem. At the moment the pointer is released the
   card is drawn back to 0.42 and floating ~138px above the trunk, and the two
   classes that put it there (.drop-armed on the desk, .dragging on the photo)
   both come off in that same instant. Left to itself the card SNAPS to full
   size, centred on the trunk, and stays there for the first fifth of the
   flight — measured: the inner transform goes from matrix(0.42, …, -138.4) to
   `none` between two frames. It is the ugliest frame in the gesture: a 270x450
   photo blotting out the cracked lid, the lit seam and the gold rim, which is
   the entire reaction the drawing-back existed to reveal.

   So the card holds the size it was drawn back to and only gives up the LIFT:
   it descends the 138px into the gap it was hovering over while polaroid-absorb
   spins the frame and shrinks it away underneath. Scale and lift have to come
   apart for that, which is why this is a keyframe animation and not a
   transition — a transition interpolates the whole transform, so unwinding the
   lift would unwind the scale with it and the card would SWELL to three
   quarters of full size halfway in (measured 0.42 -> 0.76 at t=320ms) and cover
   the trunk all over again, just more slowly.

   Only fileInto adds this class, and only for a photo that really was drawn
   back over this trunk, so a photo that was never shrunk is never asked to
   descend from a lift it never had. */
@keyframes polaroid-file-descend {
  from { transform: translateY(calc(-66px - 22%)) scale(0.42); }
  to   { transform: translateY(0) scale(0.42); }
}
.polaroid.filing-in .polaroid-inner {
  animation: polaroid-file-descend var(--absorb-ms) cubic-bezier(0.4, 0, 0.7, 0.4) both;
}

/* The reduced-motion filing collapse: no arc, no lid, no dust — the photo just
   fades where it stands. The fade itself is .polaroid's own opacity transition
   in style.css (0.4s), which trunks.js waits out before hiding it. A fade is
   not motion, and a photo that vanished between two frames would read as a
   crash rather than as filing. */
.polaroid.filing-fade { opacity: 0; }

/* ===== The create/edit form (Task 13) =====
   Two objects, and both are things you could pick up off the desk: a brass
   luggage tag that says "+ New Trunk", and the form itself, which is a
   parchment tag with the trunk you are making standing on it.

   The brass here is deliberately the SAME material as .trunk-plate and
   .trunk-tag above — the stencilled ramp starting at --metal-1 rather than the
   specular --metal-hi, the inset bezel, the hairline seat. None of these
   elements are inside a .trunk, so no trunk's custom properties reach them and
   the three stops are respelled locally. If the trunk's brass is ever re-mixed,
   re-mix these with it. */

/* ---------- "+ New Trunk": a brass tag screwed to the bottom-right ---------- */
.new-trunk-btn {
  --metal-1: #d9ab52;
  --metal-2: #8a6520;
  --metal-lo: #4e380e;

  position: fixed;
  right: 16px;
  bottom: 14px;
  /* Same rung as .color-badge, which owns the opposite corner. It has to clear
     the pile and the desk furniture but must never outrank the focused photo's
     tag or the lock screen. */
  z-index: 6500;

  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 13px 6px 10px;

  font-family: 'Kalam', cursive;
  font-weight: 700;
  font-size: 12px;
  line-height: 1.35;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: #2b1d06;
  text-shadow: 0 1px 0 rgba(255, 246, 214, 0.5);

  background-image: linear-gradient(157deg,
    var(--metal-1) 0%,
    var(--metal-2) 26%,
    var(--metal-1) 52%,
    var(--metal-2) 74%,
    var(--metal-lo) 100%);
  border: 1px solid rgba(0, 0, 0, 0.42);
  border-radius: 3px;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 4px 12px rgba(0, 0, 0, 0.42);
  cursor: pointer;
  /* transform/opacity only — nothing here can trigger layout. */
  transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.22s ease;
}
/* The punched eyelet, exactly as on .trunk-tag: a hole through the brass, dark
   at the bottom with a lit lower rim. It is what makes this a tag and not a
   button with a gradient on it. */
.new-trunk-eyelet {
  flex: 0 0 auto;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: radial-gradient(circle at 42% 34%, #150d02 0%, #2a1c06 60%, #47320d 100%);
  box-shadow:
    inset 0 1px 1px rgba(0, 0, 0, 0.9),
    0 1px 0 rgba(255, 242, 208, 0.4);
}
.new-trunk-btn:hover {
  transform: translateY(-2px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 7px 18px rgba(0, 0, 0, 0.42),
    0 0 14px rgba(255, 214, 122, 0.55);
}
.new-trunk-btn:active { transform: translateY(0); }
.new-trunk-btn:focus-visible { outline: 2px solid #ffd77a; outline-offset: 3px; }

/* ---------- A word to the user, over the form AND over the studio ----------
   The page's own .hint line (style.css), borrowed for the things that happen
   TO someone rather than because of them: a local draft set aside because a
   shared session arrived, a trunk deleted from under the form. It is the same
   handwriting in the same place, so it reads as the desk talking rather than as
   an alert — but the form's backdrop sits at 7500, and a notice about the form
   that is painted underneath it says nothing at all.

   IT HAS TO CLEAR THE STUDIO TOO, and 7600 did not. This number was chosen
   against the form's backdrop before the studio existed; the studio's backdrop
   is 7800 (with a blur over it), its panel 7900 and its cursor layer 7950, so
   four notices were painted behind the canvas — and for two of them this line is
   the only feedback the case has:

     - 'the emblem library is full — delete a drawing to make room'.
       handle_studio_save refuses BEFORE it clears DRAFT["studio"] and returns,
       so no studio_close follows: the canvas is certainly still up.
     - 'lost the connection — the trunk form is read-only until it comes back',
       raised from SYNC.onConnection(false) during a session. That is the
       mid-draw disconnect, which is exactly when the canvas is up.
     - 'the drawing you were on ended while you were offline — those N lines
       could not be kept', after a reconnect into a different session.
     - 'that would start again over the drawing on the canvas', which by
       definition fires with live work on a canvas that is on screen.

   Routing these through the studio's own in-panel note() is not available:
   Studio does not export it, so main.js has no door onto it. 7960 is the one
   free rung above .st-cursors and below .polaroid.dragging (9999) — a notice
   must still lose to the lock screen. See the full ladder at .trunk.lifted.

   AND WHERE THE SECOND LINE GOES IS NOT DECIDED HERE. `bottom` below is the
   foot of the stack; one notice raised while another is still up is lifted a
   line clear of it by main.js, with a transform, or the two paint on top of each
   other and become a single unreadable smudge. They pair more often than that
   sounds: "your draft was set aside" and "those N lines could not be kept" come
   out of one reconnect, and both are about work that has just been lost. The
   number below stays the only statement of where the stack STARTS — see
   notice() in main.js, which owns nothing but the step.

   WITH ONE EXCEPTION, AND IT IS ABOUT `.hint` RATHER THAN ABOUT THIS NUMBER.
   The page's own opening line is a `.hint` with no `.notice`, so it sits at
   style.css's `bottom: 22px` for the first eight seconds of a visit — 12px below
   this rule, for a hand that draws a 30px line. Any notice raised in that window
   would print through it, and both would be lost; so while that line is on the
   desk main.js starts the stack one step up instead, and a lone notice in the
   first eight seconds is drawn 30px above the number below. Moving `bottom` here
   would not fix it — the value is the one that clears the studio's backdrop, it
   would be wrong for the other 99% of the visit, and the hint is a moving
   target. See the argument over notice() in main.js. */
.hint.notice {
  z-index: 7960;
  bottom: 34px;
  opacity: 0.95;
  color: #f6e8c8;
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.75);
}

/* ---------- The parchment tag ---------- */
.trunk-form-backdrop {
  position: fixed;
  inset: 0;
  /* Above the focused photo's brass tag (7000), below .polaroid.dragging (9999)
     and the lock screen (10000). One new rung; nothing else in the ladder
     moves. See the full ladder at .trunk.lifted. */
  z-index: 7500;
  display: grid;
  place-items: center;
  padding: 20px;
  background: rgba(26, 16, 5, 0.55);
  backdrop-filter: blur(2.5px);
  -webkit-backdrop-filter: blur(2.5px);
  animation: tf-fade 0.22s ease both;
}
/* The class rule above outranks the UA's `[hidden] { display: none }`, so the
   hidden state has to be restated here or the form would never close. */
.trunk-form-backdrop[hidden] { display: none; }

.trunk-form {
  position: relative;
  width: min(452px, 100%);
  max-height: min(88vh, 760px);
  overflow-y: auto;
  overflow-x: hidden;
  padding: 18px 22px 20px;

  font-family: 'Kalam', cursive;
  color: #3a2a10;

  /* Aged parchment: a lit corner, a stained one, and the same sepia dot grid
     the desk uses, so the tag reads as a piece of the same page. */
  background-color: #f3e7c9;
  background-image:
    radial-gradient(ellipse 72% 56% at 26% 10%, rgba(255, 251, 238, 0.9) 0%, transparent 70%),
    radial-gradient(ellipse 62% 52% at 90% 94%, rgba(146, 108, 50, 0.20) 0%, transparent 72%),
    radial-gradient(circle, rgba(120, 90, 55, 0.17) 1.1px, transparent 1.7px);
  background-size: auto, auto, 18px 18px;
  border: 1px solid #9a7434;
  border-radius: 3px;
  box-shadow:
    inset 0 0 0 1px rgba(255, 252, 240, 0.55),
    inset 0 0 34px rgba(120, 86, 34, 0.17),
    0 22px 58px rgba(20, 12, 3, 0.55);
  animation: tf-rise 0.32s cubic-bezier(0.2, 0.88, 0.3, 1) both;
}

/* Both entrances are transform/opacity only, and both replay by themselves:
   an animation restarts whenever an element goes from display:none to shown,
   which is exactly what opening the form does. */
@keyframes tf-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes tf-rise {
  from { opacity: 0; transform: translateY(16px) scale(0.975) rotate(-0.7deg); }
  to   { opacity: 1; transform: none; }
}

/* The punched hole a luggage tag hangs from, with a brass grommet around it. */
.tf-eyelet {
  position: absolute;
  left: 21px;
  top: 17px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: radial-gradient(circle at 42% 34%, #140c02 0%, #2a1c06 52%, #6d5018 100%);
  box-shadow:
    inset 0 1px 2px rgba(0, 0, 0, 0.9),
    0 1px 0 rgba(255, 244, 214, 0.6),
    0 0 0 3px rgba(154, 116, 52, 0.42),
    0 0 0 4px rgba(74, 52, 16, 0.35);
}

.tf-title {
  margin: 0 0 6px 26px;
  font-family: 'Caveat', cursive;
  font-weight: 600;
  font-size: 27px;
  line-height: 1.1;
  letter-spacing: 0.01em;
  color: #4a3520;
}

/* ---------- The form has lost the server ----------
   Global Constraint line 54. Until this line existed, a socket that dropped
   during a live session left a form that looked completely normal: `sessionLive()`
   was false so every edit was a silent no-op, and the `draft_state` that arrived
   on reconnect overwrote all five fields — so everything typed after the drop
   was destroyed, with nothing anywhere to say so. The `.readonly` dimming was
   already built; what was missing was a reason for it, because a form that has
   gone quiet and a form you are only allowed to watch are not the same thing and
   should not look the same.

   Sits directly under the title, in the same warm ink as .tf-note, so it reads
   as the tag's own voice rather than as an error dialog. `hidden` does the
   hiding; there is no state where this is on screen and meaningless. */
.tf-offline {
  margin: -2px 0 8px 26px;
  font-family: 'Caveat', cursive;
  font-size: 17px;
  line-height: 1.15;
  color: #8a4b2a;
  opacity: 0.92;
}
.tf-offline[hidden] { display: none; }

/* ---------- The live miniature ----------
   A real trunk, rendered through Trunks.renderTrunk and moved in here, so what
   you are looking at is the same code path as a desk trunk — and it wobbles,
   because trunk-form.js puts .hot on it and the Task 9 personalities key off
   exactly that.

   overflow: hidden is load-bearing. .trunk-spill reaches 96px past the box on
   each side; it is transparent on a closed trunk but it still counts towards
   the form's scroll width, and without this the parchment would carry a
   horizontal scrollbar. Nothing that moves is clipped: the personalities travel
   at most ~8.5px sideways and the enchanted lift is 7px. */
.tf-preview {
  position: relative;
  height: 152px;
  margin: 2px 0 15px;
  overflow: hidden;
  border-radius: 2px;
  /* A shallow shelf: warm light above, a contact pool below, so the trunk is
     standing on something rather than floating in a box. */
  background-image:
    radial-gradient(ellipse 62% 24% at 50% 90%, rgba(116, 84, 34, 0.22) 0%, transparent 72%),
    radial-gradient(ellipse 76% 66% at 50% 32%, rgba(255, 247, 227, 0.6) 0%, transparent 74%);
  box-shadow: inset 0 0 0 1px rgba(140, 104, 44, 0.22);
}
.tf-preview .trunk {
  /* Its own size, not a transform: scale() — --u is what every part of a trunk
     is measured in, so scaling through it keeps the brass and the stitching
     crisp instead of resampling the whole box. */
  --u: 0.86;
  --tw: 155px;
  --th: 103px;
  left: 50%;
  top: 50%;
  margin-left: calc(var(--tw) / -2);
  margin-top: calc(var(--th) / -2 - 7px);
  /* A picture, not a control. Without this the trunk's own click handler would
     open the preview's lid, and worse, would broadcast a lid toggle for an id
     that exists nowhere but this form. It also keeps Task 15's trunk drag off
     it for the same reason. */
  pointer-events: none;
  cursor: default;
}

/* ---------- Rows ----------
   Label column left, control right, and the collaborator's lock badge tucked
   under the label so a held field is announced where you read its name — no
   row can be pushed about by a badge appearing. */
.tf-row {
  display: grid;
  grid-template-columns: 84px 1fr;
  align-items: start;
  gap: 12px;
  margin-bottom: 13px;
}
.tf-label {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding-top: 5px;
  font-size: 13.5px;
  color: #6b4f22;
}
.tf-lock {
  font-size: 10.5px;
  font-style: normal;
  line-height: 1.25;
  letter-spacing: 0.02em;
  opacity: 0.95;
}

.tf-row input[type="text"] {
  width: 100%;
  padding: 6px 10px;
  font: inherit;
  font-size: 15px;
  color: #33240c;
  background: rgba(255, 253, 245, 0.62);
  border: 1px solid #a98a4e;
  border-radius: 2px;
  /* Ruled-paper seat rather than a floating input box. */
  box-shadow: inset 0 1px 3px rgba(120, 86, 34, 0.18);
  transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.tf-row input[type="text"]:focus {
  outline: none;
  border-color: #8a6520;
  box-shadow: inset 0 1px 3px rgba(120, 86, 34, 0.18), 0 0 0 2px rgba(255, 214, 122, 0.55);
}
.tf-row input[type="text"]:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  background: rgba(214, 198, 166, 0.5);
}

/* ---------- Emblem grid ----------
   Sixteen crests plus the reserved "draw your own" tile. Each is a small
   parchment tile with the crest struck into it in sepia; the chosen one is
   brass. */
.tf-emblems {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 5px;
}
.tf-emblems button {
  position: relative;
  aspect-ratio: 1;
  /* A grid item's default min-width is auto, i.e. its content's minimum — so
     the one tile with words on it ("draw your own") widened its column and
     pushed the sixth crest of every row out past the edge of the parchment. */
  min-width: 0;
  padding: 4px;
  display: grid;
  place-items: center;
  color: #7a5a1e;
  background: rgba(255, 252, 242, 0.5);
  border: 1px solid rgba(138, 101, 32, 0.35);
  border-radius: 2px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
  cursor: pointer;
  transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.tf-emblems button:hover:not(:disabled) {
  transform: translateY(-1px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 2px 6px rgba(90, 62, 18, 0.28);
}
.tf-emblems button.active {
  color: #2b1d06;
  border-color: rgba(0, 0, 0, 0.42);
  background-image: linear-gradient(157deg, #d9ab52 0%, #8a6520 26%, #d9ab52 52%, #8a6520 74%, #4e380e 100%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.4),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 0 10px rgba(255, 214, 122, 0.55);
}
.tf-emblems button:disabled { opacity: 0.4; cursor: not-allowed; }
.tf-emblems button:focus-visible { outline: 2px solid #ffd77a; outline-offset: 2px; }
.tf-emblems svg { width: 100%; height: 100%; display: block; }
/* The way into the studio. Dashed, because it is the one tile that is not an
   emblem: it is a piece of blank paper. It was 0.55 opaque while it was
   reserved and said "Coming soon"; now that it opens something it reads at full
   strength like every other live control, and the dashes are what still tell it
   apart from the sixteen. */
.tf-emblems .tf-draw {
  font-family: 'Caveat', cursive;
  font-size: 12px;
  line-height: 1.05;
  text-align: center;
  color: #7a5a1e;
  border-style: dashed;
}

/* ---------- "yours": the drawn-emblem library ----------
   The saved drawings, under their own heading, in the same six-column rhythm as
   the presets above them: the heading spans the grid (`1 / -1`) and the tiles
   are grid items of it like any preset, so nothing here needs a second row in
   the markup or a second set of columns. Each tile is an ordinary emblem choice
   with two small controls sitting on it. */
.tf-emblems .tf-yours-head {
  grid-column: 1 / -1;
  margin: 7px 0 1px;
  font-family: 'Caveat', cursive;
  font-size: 15px;
  color: #6b4e22;
  opacity: 0.85;
}
/* `display: contents` and NOT a nested grid, deliberately. The wrapper exists
   because the tiles are rebuilt wholesale whenever the library changes and a
   container is the only thing that makes "clear it and fill it again" one
   operation — but a grid of its own would have to restate the six columns, and
   the 640px query would then have to restate the five. This way there is one
   grid definition in this file and the drawings sit in the presets' own
   columns, whatever those currently are. */
.tf-emblems .tf-yours { display: contents; }
/* The square the tile and its two controls all live in. It carries the
   aspect-ratio the tile itself would otherwise carry, so that the row keeps its
   height whichever of the three the browser measures. */
.tf-drawn { position: relative; aspect-ratio: 1; }
.tf-emblems .tf-drawn .tf-tile {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
/* NOT SHARED YET: a drawing saved with no server to save it to, which lives in
   this browser and nowhere else until it is saved again with one. It is in the
   same grid, under the same heading, beside drawings everybody in the room can
   see — so the tile has to say so, or the difference (their screen has no such
   emblem, and a trunk committed wearing it goes up wearing the default) is
   invisible until somebody else looks.

   THE DASH IS THE SAME WORD `.tf-draw` USES a few rules up: a dashed edge is
   this grid's way of saying "not one of the finished ones". EDGE ONLY, and that
   is a constraint rather than a preference — the drawn tiles are absolutely
   placed over their wrapper (`inset: 0`) with the ✎ and ✕ stacked on top, so a
   marker that touched the tile's box would move those two out from under the
   pointer that is aiming at them. */
.tf-emblems .tf-drawn .tf-tile.tf-unshared {
  border-style: dashed;
  border-color: rgba(138, 101, 32, 0.75);
}

/* ✎ and ✕, in opposite corners so that a hand aiming for one cannot land on the
   other, and both clear of the middle where the drawing is. They are ABOVE the
   tile rather than inside it — a button inside a button is not markup a browser
   will keep — which is why the tile is absolutely placed and these two are
   stacked over it. */
.tf-emblems .tf-drawn .tf-mini {
  position: absolute;
  z-index: 1;
  aspect-ratio: auto;
  width: 16px;
  height: 16px;
  padding: 0;
  font-size: 10px;
  line-height: 1;
  border-radius: 50%;
  color: #5c421a;
  background: rgba(255, 252, 242, 0.92);
  border-color: rgba(138, 101, 32, 0.5);
  opacity: 0.72;
}
.tf-emblems .tf-drawn .tf-edit { left: 2px; bottom: 2px; }
.tf-emblems .tf-drawn .tf-kill { right: 2px; top: 2px; }
.tf-emblems .tf-drawn:hover .tf-mini:not(:disabled),
.tf-emblems .tf-drawn .tf-mini:focus-visible { opacity: 1; }
/* Armed: one click from destroying a drawing that cannot be got back, and every
   trunk wearing it loses its crest with it. The oxblood is the same ink the
   trunk form's own armed Delete wears. */
.tf-emblems .tf-drawn .tf-mini.armed {
  opacity: 1;
  color: #fff2e8;
  background: #7d1f24;
  border-color: rgba(0, 0, 0, 0.45);
}

/* ---------- Leather swatches ----------
   Real patches of the same hide, grain and all: the two repeating passes are
   the first two of --grain on .trunk, over the leather's own three-stop ramp. */
.tf-swatches { display: flex; flex-wrap: wrap; gap: 7px; }
.tf-swatches button {
  width: 30px;
  height: 30px;
  border-radius: 3px;
  border: 1px solid rgba(0, 0, 0, 0.45);
  background-image:
    repeating-linear-gradient(74deg, rgba(255, 244, 224, 0.07) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(-16deg, rgba(0, 0, 0, 0.09) 0 1px, transparent 1px 5px),
    linear-gradient(158deg, var(--l0) 0%, var(--l1) 52%, var(--l2) 100%);
  box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.18), 0 1px 2px rgba(60, 40, 12, 0.35);
  cursor: pointer;
  transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.tf-swatches button[data-value="oxblood"]    { --l0: #8a4040; --l1: #6b2f2f; --l2: #351616; }
.tf-swatches button[data-value="black"]      { --l0: #4c4845; --l1: #33302e; --l2: #161514; }
.tf-swatches button[data-value="tan"]        { --l0: #c08a4e; --l1: #a2703c; --l2: #5a381a; }
.tf-swatches button[data-value="gryffindor"] { --l0: #9d2b31; --l1: #7d1f24; --l2: #3e0c0f; }
.tf-swatches button[data-value="slytherin"]  { --l0: #2d6c4a; --l1: #1f4d35; --l2: #0b2317; }
.tf-swatches button[data-value="ravenclaw"]  { --l0: #30487f; --l1: #22335e; --l2: #0d162e; }
.tf-swatches button[data-value="hufflepuff"] { --l0: #b28a28; --l1: #8a6a1e; --l2: #45350d; }
.tf-swatches button:hover:not(:disabled) { transform: translateY(-2px); }
/* A brass ring, not a dark outline: the chosen hide is the one with a fitting
   around it. */
.tf-swatches button.active {
  box-shadow:
    inset 0 1px 1px rgba(255, 255, 255, 0.18),
    0 0 0 2px #f3e7c9,
    0 0 0 3.5px #8a6520,
    0 0 10px rgba(255, 214, 122, 0.6);
}
.tf-swatches button:disabled { opacity: 0.45; cursor: not-allowed; }
.tf-swatches button:focus-visible { outline: 2px solid #ffd77a; outline-offset: 4px; }

/* ---------- Choice pills ---------- */
.tf-choices { display: flex; flex-wrap: wrap; gap: 7px; }
.tf-choices button {
  padding: 5px 12px;
  font: inherit;
  font-size: 12px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: #5c421a;
  background: rgba(255, 252, 242, 0.5);
  border: 1px solid rgba(138, 101, 32, 0.4);
  border-radius: 2px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
  cursor: pointer;
  transition: transform 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
}
.tf-choices button:hover:not(:disabled) { transform: translateY(-1px); }
.tf-choices button.active {
  color: #2b1d06;
  font-weight: 700;
  border-color: rgba(0, 0, 0, 0.4);
  background: rgba(255, 214, 122, 0.55);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.5),
    inset 0 -1px 1px rgba(120, 86, 34, 0.3),
    0 0 9px rgba(255, 214, 122, 0.5);
}
.tf-choices button:disabled { opacity: 0.45; cursor: not-allowed; }
.tf-choices button:focus-visible { outline: 2px solid #ffd77a; outline-offset: 2px; }

/* Hardware is two little name plates in the two finishes, struck with the word,
   rather than two text pills — you pick the metal by looking at the metal. */
.tf-metals button {
  min-width: 74px;
  color: #2b1d06;
  text-shadow: 0 1px 0 rgba(255, 246, 214, 0.5);
  border-color: rgba(0, 0, 0, 0.42);
  background-image: linear-gradient(157deg,
    var(--m-1) 0%, var(--m-2) 26%, var(--m-1) 52%, var(--m-2) 74%, var(--m-lo) 100%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.3);
  opacity: 0.72;
}
.tf-metals button[data-value="brass"]  { --m-1: #d9ab52; --m-2: #8a6520; --m-lo: #4e380e; }
.tf-metals button[data-value="silver"] { --m-1: #d7dade; --m-2: #8b9196; --m-lo: #474d52; color: #24282b;
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.55); }
.tf-metals button.active {
  opacity: 1;
  background: none;
  background-image: linear-gradient(157deg,
    var(--m-1) 0%, var(--m-2) 26%, var(--m-1) 52%, var(--m-2) 74%, var(--m-lo) 100%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 0 11px rgba(255, 214, 122, 0.6);
}

/* One line of description under the character pills: three words is the whole
   difference between picking a label and picking a temperament. */
.tf-character { display: flex; flex-direction: column; gap: 6px; }
.tf-note {
  min-height: 19px;
  font-family: 'Caveat', cursive;
  font-size: 17px;
  line-height: 1.15;
  color: #7a6034;
  opacity: 0.9;
}

/* ---------- Actions ---------- */
.tf-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 17px;
  padding-top: 14px;
  /* A scored fold across the tag rather than a border: it belongs to the paper,
     not to a dialog. */
  border-top: 1px dashed rgba(138, 101, 32, 0.45);
}
.tf-spacer { flex: 1; }
.tf-actions button {
  padding: 7px 15px;
  font: inherit;
  font-size: 13px;
  color: #5c421a;
  background: rgba(255, 252, 242, 0.5);
  border: 1px solid rgba(138, 101, 32, 0.4);
  border-radius: 2px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
  cursor: pointer;
  transition: transform 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
}
.tf-actions button:hover:not(:disabled) { transform: translateY(-1px); }
.tf-actions button:focus-visible { outline: 2px solid #ffd77a; outline-offset: 2px; }
.tf-actions button[hidden] { display: none; }

/* Save is the only brass here — the same plate as the trunk's own. */
.tf-actions .tf-primary {
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: #2b1d06;
  text-shadow: 0 1px 0 rgba(255, 246, 214, 0.5);
  border-color: rgba(0, 0, 0, 0.42);
  background-image: linear-gradient(157deg, #d9ab52 0%, #8a6520 26%, #d9ab52 52%, #8a6520 74%, #4e380e 100%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45);
}
.tf-actions .tf-primary:hover:not(:disabled) {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 1px rgba(0, 0, 0, 0.4),
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 0 13px rgba(255, 214, 122, 0.6);
}

/* Delete is ink, not a red button: destructive, but it does not get to be the
   loudest thing on the tag. It arms on the first click and says so. */
.tf-actions .tf-danger {
  color: #7d1f24;
  background: none;
  border-color: transparent;
  box-shadow: none;
}
.tf-actions .tf-danger:hover:not(:disabled) {
  background: rgba(125, 31, 36, 0.09);
  border-color: rgba(125, 31, 36, 0.4);
}
.tf-actions .tf-danger.armed {
  font-weight: 700;
  color: #fdf3e0;
  background: linear-gradient(180deg, #9d2b31, #6d151a);
  border-color: rgba(0, 0, 0, 0.4);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 0 10px rgba(157, 43, 49, 0.5);
}

/* The trunk going. Magic.dissolve throws the particles; this is the box itself
   letting go underneath them. Opacity only — .trunk's transform is its position
   on the desk and its personality, and neither may be touched here. */
.trunk.vanishing {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.34s ease;
}

/* ---------- Read-only mirror ----------
   Task 14 puts phones in here: they watch the session, they do not edit it.
   Cancel stays live — a viewer must always be able to put the tag down. */
.trunk-form.readonly input,
.trunk-form.readonly button:not(#tf-cancel) {
  pointer-events: none;
  opacity: 0.55;
}

/* ---------- Small screens ----------
   Spec §11. Opening and closing a trunk, every animation, focusing and flipping
   a photo and the brass tag all work on a phone. Making a trunk, renaming one,
   dragging one and dragging a photo into one do not — dragging a polaroid onto a
   120x80 target with a thumb is unreliable, and it would need a whole second
   interaction model for a job that only needs doing once.

   The affordances go, the ANATOMY STAYS. The plan's sketch for this task hid
   `.trunk-plate` outright alongside the button, and that is a bigger change than
   it looks: the plate is not a control that happens to be shaped like a label,
   it is the stencilled brass name plate from §5's anatomy — the film prop's
   `H. J. POTTER` — and it is the only thing on the desk that says WHICH trunk
   this is. Hiding it leaves a phone user with two identical unlabelled boxes and
   no way to tell which one holds which photos, to save them from a tap that does
   nothing. So the plate keeps its name and loses its cursor; the click handler
   in main.js is what actually refuses to open the form.

   ...AND THAT LEFT A DEAD ZONE OVER THE TRUNK'S OWN NAME, which is worse than
   either. The plate is a real <button> with pointer-events: auto, trunks.js's
   attachToggle deliberately steps aside for anything inside `.trunk-plate` so it
   never doubles as a lid handle, and requestForm() refuses on a phone. Both
   early-returns fire and the tap reaches NOTHING. Measured at the phone size:
   the trunk is 120x80 and the plate is 52 x 12.7 — 6.9% of the face, centred
   along the bottom, exactly where a thumb aiming at the lower half of the target
   lands — and tapping it left `Trunks.isOpen()` false while a tap anywhere else
   on the same trunk opened it. There is no cursor on a touch screen to hint that
   the region is different, so the user's read is "the trunk is broken".

   pointer-events: none is the whole fix: the name stays, the tap falls through
   to the trunk, and the trunk opens. It is what the form's own miniature plate
   already does for the same reason. trunks.js takes the tab stop and the "Edit
   trunk:" label off at the same breakpoint — see setPlate. */
@media (max-width: 640px) {
  .trunk { --u: 0.667; --tw: 120px; --th: 80px; }

  /* Not `pointer-events: none` — the button is the request, and a hidden one
     cannot be tapped by accident. It mattered more than it sounds: since the
     form became a shared session a phone tap here opened a collaborative form on
     EVERY desktop screen in the room, which the phone could then only watch. */
  .new-trunk-btn { display: none; }
  /* Nothing here is draggable, so nothing should offer to be picked up. A trunk
     is a thing you tap to open. */
  .trunk { cursor: pointer; }
  .trunk-plate { cursor: default; pointer-events: none; }
  /* Everything else scales off --u; the plate only needs its tracking eased so
     a long name still fits inside the 112px * --u cap without ellipsising. */
  .trunk-plate { letter-spacing: 0.05em; }
  /* The tag does not scale off --u — it belongs to the focused photo, not to a
     trunk — so it gets its own easing to keep a long name off the ellipsis. */
  .trunk-tag { letter-spacing: 0.05em; max-width: 84vw; }

  /* The form, at phone width. Not a full mobile design — §11 makes the form a
     VIEWER at this size, so what it owes is legibility rather than comfort —
     but enough that a narrow window shows a tag rather than a squeezed one. The
     label column goes above its control, and the emblem grid loses a column so
     the crests stay big enough to tell apart. */
  .trunk-form-backdrop { padding: 12px; }
  .trunk-form { padding: 16px 16px 18px; }
  .tf-row { grid-template-columns: 1fr; gap: 5px; }
  .tf-label { flex-direction: row; align-items: baseline; gap: 8px; padding-top: 0; }
  .tf-emblems { grid-template-columns: repeat(5, 1fr); }
  /* THE TWO DOORS INTO THE STUDIO, SHUT — the same answer `+ New Trunk` gets a
     few rules up, at the same breakpoint, for the same reason. A canvas is
     raised by the SERVER on every screen in the session, so a tap here would put
     a full-screen studio on both desktops in the room and leave the phone able
     only to watch it. There are two of them and neither is the other: `.tf-draw`
     is a blank canvas and the `✎` on a saved drawing re-opens that one.

     `display: none` rather than `pointer-events: none`, exactly as the button
     above: these are the request, and a hidden request cannot be tapped by
     accident. It is not the whole fix either — main.js refuses the same gesture
     in requestStudio, because a window dragged narrower with the form already up
     keeps every listener it had. */
  .tf-emblems .tf-draw,
  .tf-emblems .tf-drawn .tf-edit { display: none; }
  .tf-preview { height: 128px; }
  .tf-preview .trunk { --u: 0.72; --tw: 130px; --th: 87px; }
}

@media (prefers-reduced-motion: reduce) {
  .trunk-lid,
  .trunk:not(.open) .trunk-lid,
  .trunk-void,
  .trunk-spill,
  .trunk-latch,
  .trunk.open .trunk-latch { transition-duration: 0.01ms; }
  /* The latch still disappears when the trunk opens; it just does not spring. */
  .trunk.open .trunk-latch { transform: none; }

  /* The tag appears and goes rather than fading up into place. It keeps its
     centring — that is placement, not motion — and it keeps working: the dust
     on release is already suppressed inside Magic. */
  .trunk-tag { transition-duration: 0.01ms; transform: translateX(-50%); }

  /* The whole flight collapses to a cross-fade: photos simply appear and
     disappear where they belong. trunks.js takes the same branch and never adds
     these classes or spawns a particle, so this is belt-and-braces for a user
     who flips the OS setting mid-flight. */
  .polaroid.erupting,
  .polaroid.absorbing {
    animation: none !important;
  }
  .polaroid.flying { z-index: auto !important; }

  /* The photo in hand still draws back over a drop target — otherwise the trunk
     it is covering cannot be seen to react at all — but it does it between one
     frame and the next rather than gliding. */
  .desk.drop-armed .polaroid.dragging,
  .polaroid.dragging .polaroid-inner,
  .polaroid.releasing .polaroid-inner { transition: none; }
  /* The descent is motion, and reduced motion never takes the flight path
     anyway (fileInto fades instead) — this is here for an OS setting flipped
     mid-flight, like the .erupting/.absorbing blanket above it. */
  .polaroid.filing-in .polaroid-inner { animation: none !important; }

  /* All three personalities collapse to a brightness/fade change. The animation
     blanket is deliberate and covers trunk children added by later tasks. */
  .trunk,
  .trunk * {
    animation: none !important;
  }
  /* `translate` has to go too, or the enchanted lift would still float the
     trunk 7px and drop its shadow 7px. Scoped to exactly the two elements that
     use it for that: `translate` is a general-purpose placement property, and
     blanketing `.trunk *` would leave a reduced-motion user with a broken
     layout rather than a still one the first time a later task positions
     something inside a trunk with it. */
  .trunk,
  .trunk-shadow {
    translate: none !important;
  }
  .trunk.hot:not(.open):not(.dragging):not(.filing) {
    filter: brightness(1.16) saturate(1.06);
  }
  .trunk.hot:not(.open):not(.dragging):not(.filing) .trunk-emblem { opacity: 1; }

  /* The form arrives rather than settling. The `.trunk, .trunk *` animation
     blanket above already stills the preview trunk — including its
     personality, which is the one thing in here that never stops moving. */
  .trunk-form-backdrop,
  .trunk-form { animation: none; }
  .new-trunk-btn,
  .trunk-form button,
  .trunk-form input { transition: none; }
  .new-trunk-btn:hover { transform: none; }
  /* The trunk still goes when it is deleted; it just goes between two frames.
     Magic.dissolve is already silent under reduced motion, and trunk-form.js
     takes the same branch and removes the element without waiting. */
  .trunk.vanishing { transition-duration: 0.01ms; }
}
