/* ============================================================
   Nimbus — FEEL
   The tactile layer. Nothing in this file is layout and nothing in it
   is colour: it is only what the app does back to you when you touch it.

   Scoped entirely to html[data-feel="soft"] and html[data-feel="full"],
   which only feel.js ever sets — so `off` is not a reconstruction of the
   old build, it IS the old build, and a script that fails to load leaves
   the app looking exactly as it always did rather than half-dressed.

   The two live modes are the same rules at two amplitudes. `--fe-amp`
   scales every magnitude in here, so "more" is one number and not a
   second stylesheet that drifts out of step with the first.
   ============================================================ */

[data-feel] {
  /* Overshoot, and how much of it, is the whole difference between
     "the button moved" and "the button answered". */
  --fe-spring: cubic-bezier(.34, 1.42, .32, 1);
  --fe-snap: cubic-bezier(.2, .9, .25, 1);
  --fe-amp: 1;
  --fe-tilt: 0;                       /* full only — soft never leaves the plane */
  /* The colour every effect in this file is drawn in. It is `--accent` — your colour —
     everywhere except on the handful of controls that are ALREADY painted in a colour of
     their own and set `--fe-hue` for themselves further down. A blue light sliding across
     a gold button is two colours arguing over one control, and the light loses: it reads
     as a smear rather than as the button answering.

     Deliberately a hue rather than the finished `--fe-lit` this replaced. A custom
     property is substituted where it is DECLARED, not where it is used, so a `--fe-lit`
     computed up here on <html> inherits downwards ALREADY BLUE and no override deeper in
     the tree can ever reach inside it. The mixes therefore live on the effect elements,
     where `--fe-hue` is still a name that the host under them gets to answer. */
  --fe-hue: var(--accent);
}
[data-feel="soft"] { --fe-amp: .5; }
[data-feel="full"] { --fe-amp: 1; }
/* A part works at either amplitude (16.10.2026): switching one on turns it on. It used to
   exist only under Ballistic, which made six switches silently inert under Tactile. */
[data-feel][data-fe~="tilt"] { --fe-tilt: 1; }

/* ---- the cursor light ----------------------------------------------------
   A disc that is only ever moved by `translate` — the alternative, a
   radial-gradient whose `at` position changes, re-rasterises the whole
   element background on every mousemove, and on a grid of forty cards that
   is the frame budget gone. The disc is composited; the gradient inside it
   is painted once.

   One element for the whole app, re-parented. It briefly had a pool and a
   ghost left behind in every row you crossed; that is what made switching the
   glow on feel laggy and start missing rows, because a sweep became dozens of
   allocations and dozens of running animations inside the busiest surface in
   the app. The trail you see on a flick is the row grey, which is one CSS
   transition per row and no script at all. See feel.js. */
.fe-fx {
  position: absolute; inset: 0; z-index: 3;
  pointer-events: none; overflow: hidden; border-radius: inherit;
  opacity: 0; transition: opacity .12s var(--fe-snap);
}
.fe-fx.on { opacity: 1; }

/* ---- how a theme declines the light -------------------------------------

   `--fe-glow: 0` on any host, and that host is not lit. Set it on `[data-deep=x]`
   and the whole theme opts out; set it on one selector and only that opts out.

   This exists because Dangis noticed the light on the toolbar of an impersonation theme and
   asked whether it "cuts into the theme's feel". It does. A soft radial wash that tracks the
   cursor is a 2020s lighting model, and Windows XP answered a hover with an amber border and a
   bevel — it had no glows anywhere. Luna was getting XP geometry with a modern light on top,
   which is the seam he saw.

   It is the SECOND time this exact mistake has been made here, which is why the opt-out is a
   documented contract rather than four themes each inventing one. The first was Messages in
   August, and the rule written down then still holds: **reuse the mechanism, never the
   appearance.** The feel layer is a Meridian design. Distributing it unchanged onto a theme
   that has its own opinion about what a hover looks like is exactly the copy-paste he objected
   to the first time.

   A theme that opts out MUST supply its own hover in the same place — the layer's whole job is
   that a control answers when you point at it, and answering with nothing is a regression, not
   a restoration. Lucid is the interesting counter-example and should KEEP the light: a glow is
   on-language for that one, and turning it off there would be following the rule past the point
   where it helps.

   Implemented as a custom property rather than a `display: none` on `.fe-fx` because feel.js
   re-parents one element around the document: hiding it in a rule scoped to a host would hide
   it for the next host too, depending on where it happened to be sitting. `--fe-glow` inherits
   to the disc from whatever it is currently inside, so the answer is always about the host it
   is in right now.

   The default lives in the `var()` FALLBACK and is never declared anywhere — which is the same
   trap `--fe-hue` above is written to avoid, and it was walked into once while writing this.
   Declaring `.fe-fx { --fe-glow: 1 }` puts the property on the disc's own wrapper, where it
   beats anything inherited from the host, and a theme's `--fe-glow: 0` five levels up could
   never reach it. Nothing would error; the opt-out would simply do nothing. A custom property
   is substituted where it is DECLARED. */
/* NOT called --fe-lit, which this file already uses as the name of a RETIRED property (see
   --fe-hue above): a name that used to mean "the finished light colour" and now means "should
   the light draw at all" is a comment that reads correctly and a variable that lies. --fe-glow
   is also what Dangis called it — "mouse glow" — which is the name it will be searched under. */
.fe-fx > i {
  opacity: var(--fe-glow, 1);
  position: absolute; left: 0; top: 0;
  width: var(--fe-d, 190px); height: var(--fe-d, 190px);
  margin: calc(var(--fe-d, 190px) / -2) 0 0 calc(var(--fe-d, 190px) / -2);
  border-radius: 50%;
  background: radial-gradient(circle closest-side,
    color-mix(in srgb, var(--fe-hue) calc(22% * var(--fe-amp)), transparent), transparent);
  translate: var(--fe-x, 0) var(--fe-y, 0);
  will-change: translate;
}

/* ---- the hit ------------------------------------------------------------
   A shockwave, not a Material blob. The ring is baked into the gradient
   rather than drawn as a border, because a scaled border thickens as it
   travels and stops reading as a wave. Its clip box is its own element, so
   a row does not have to take `overflow: hidden` — which would cut the
   checkbox's oversized hit area clean off the left of every line. */
.fe-rip {
  position: absolute; inset: 0; z-index: 4;
  pointer-events: none; overflow: hidden; border-radius: inherit;
}
.fe-rip > i {
  position: absolute; left: 0; top: 0;
  width: var(--fe-d); height: var(--fe-d);
  margin: calc(var(--fe-d) / -2) 0 0 calc(var(--fe-d) / -2);
  translate: var(--fe-x) var(--fe-y);
  border-radius: 50%;
  background:
    radial-gradient(circle closest-side,
      transparent 40%,
      color-mix(in srgb, var(--fe-hue) 34%, transparent) 64%,
      color-mix(in srgb, var(--fe-hue) 52%, transparent) 76%,
      transparent 100%);
  animation: fe-wave calc(460ms + 160ms * var(--fe-amp)) cubic-bezier(.15, .72, .25, 1) forwards;
  will-change: transform, opacity;
}
@keyframes fe-wave {
  from { transform: scale(.12); opacity: 1; }
  to   { transform: scale(1); opacity: 0; }
}

/* ---- press --------------------------------------------------------------
   `scale`, not `transform`. They are separate properties composed in a fixed
   order, so the squash can sit on top of a card's hover lift or a button's
   :active nudge without either rule having to know the other exists.
   Rewriting those transforms to carry a scale factor is how a tactile layer
   ends up owning every transform in the app. */
[data-feel] .fe-can-press { transition: scale .34s var(--fe-spring); }
[data-feel] .fe-press {
  scale: calc(1 - .04 * var(--fe-amp));
  transition-duration: .07s;
  transition-timing-function: linear;
}

/* ---- the hosts --------------------------------------------------------
   Both overlays are `position: absolute; inset: 0`, so a host that is still
   statically positioned hands them the nearest positioned ancestor instead:
   the light stops being a light and becomes a wash across a whole panel, and
   the wave starts at the panel's corner. Every selector in feel.js's LIT list
   has to appear here — the two lists are one list, kept in two files because
   one of them has to be CSS. Anything already relative is harmless to repeat. */
[data-feel] .btn, [data-feel] .iconbtn, [data-feel] .sh-copy, [data-feel] .nav-btn, [data-feel] .card,
[data-feel] .row, [data-feel] .crumb, [data-feel] .user-btn, [data-feel] .report-btn,
[data-feel] .seg button, [data-feel] .th-card, [data-feel] .ic-card,
[data-feel] .motion-seg button, [data-feel] .feel-seg button, [data-feel] .kpi,
[data-feel] .ctx-item, [data-feel] .tl-item, [data-feel] .ph-item,
[data-feel] .acc-row, [data-feel] .dev-row, [data-feel] .share-row,
[data-feel] .pod, [data-feel] .sc-row, [data-feel] .tab,
/* Messages. Same list as MSG in feel.js. */
[data-feel] .msg-person, [data-feel] .msg-ask, [data-feel] .ch-ic, [data-feel] .ch-jump,
[data-feel] .head, [data-feel] .emo, [data-feel] .gp-tile {
  position: relative;
}

/* ---- controls that are already a colour ---------------------------------
   Report is the one gold thing in the sidebar, and it is gold on purpose. Every other
   host in the list above is neutral, so the accent light landing on it is the only
   colour there and reads as the control lighting up. On Report the button is ALREADY
   lit — so a cyan or violet accent slid a second colour across a gold face and the two
   fought: not a highlight, a stain. Its light and its shockwave are drawn in its own
   colour instead, which is what "the thing you touched answered" is supposed to look
   like whatever accent you picked.

   One line per such control, and there is deliberately only one so far: a host claims a
   hue when it is painted in a colour that is not the accent's to override. */
[data-feel] .report-btn { --fe-hue: var(--sun); }

/* ============ buttons ============ */
[data-feel] .btn-primary:hover {
  filter: brightness(calc(1 + .1 * var(--fe-amp)));
  box-shadow: 0 calc(6px * var(--fe-amp)) calc(22px * var(--fe-amp)) -6px
              color-mix(in srgb, var(--accent) 70%, transparent);
}
/* The glyph leads the press: it lifts a hair on hover so the icon, not the
   36px square around it, is what reads as the control.

   Two ways to have it, because the overshoot that makes a single button feel
   answered is the same overshoot that makes a whole toolbar hop when you sweep
   across it — each glyph starts toward 1.42 of its target and gets reversed a
   frame later. That is the jitter.

   `smooth` (default) takes the overshoot out and shortens it: the glyph goes
   where it is going and stops. `dwell` keeps the spring and moves nothing at
   all until the pointer has stayed on the same button for 140ms, so passing
   through is silent and stopping is an event. `off` is off. */
[data-feel][data-fe~="icons-smooth"] .iconbtn > svg {
  transition: translate .18s var(--fe-snap), scale .18s var(--fe-snap);
}
/* `.fe-over` beside every :hover an effect depends on. It is the same state said twice, and the
   second one is the only one a DEMONSTRATION can produce: the picker's stages and the
   walkthrough's panel drive this layer with synthetic PointerEvents so that what runs is the real
   effect, and a synthetic event cannot make :hover — that belongs to the input device. Without
   this line the Icon lift tile was a picture of four buttons that never moved. */
[data-feel][data-fe~="icons-smooth"] .iconbtn:hover > svg,
[data-feel][data-fe~="icons-smooth"] .iconbtn.fe-over > svg { translate: 0 -1px; scale: 1.12; }
[data-feel][data-fe~="icons-dwell"] .iconbtn > svg {
  transition: translate .26s var(--fe-spring), scale .26s var(--fe-spring);
}
[data-feel][data-fe~="icons-dwell"] .iconbtn.fe-dwell > svg { translate: 0 -1px; scale: 1.12; }

/* ============ the sidebar glider ============
   osu keeps one highlight and moves it; this does the same. The pill is a
   single element parked behind whichever nav row is active, and it travels
   to whatever the pointer is over instead of eight separate backgrounds
   lighting up and going out.

   The active row keeps its accent ring — so "where I am" is still stated by
   the button itself, and the pill only ever says "where I am pointing". */
/* The glider is absolutely positioned inside the sidebar, so the sidebar has to
   be a containing block — but this must NOT be the thing that decides how the
   sidebar is positioned. It was: `[data-feel] .side` is (0,2,0) and the mobile
   rule in styles.css is a bare `.side` (0,1,0), and a media query adds no
   specificity, so this line quietly beat `position: fixed` at every phone
   width. The sidebar still slid off screen — the transform in the same block
   applied fine — but it stayed IN FLOW while it did, so it went on holding 216
   pixels the listing should have had and pushed the rest off the right edge.
   That is Dangis's "it slides but the UI doesn't come in its place".

   Fixed is a containing block too, so the pill is happy either way; the only
   job here is to not contradict the layout. */
[data-feel] .side { position: relative; }
@media (max-width: 780px) { [data-feel] .side { position: fixed; } }
/* 12px is .side's own horizontal padding, and it is deliberate: an absolutely
   positioned child resolves against the containing block's PADDING box, but
   .side has no left border for that box to be inset by, so its padding edge is
   its border edge and the pill has to step in by the padding itself to line up
   with the buttons. Measured: left/right 12 puts the pill exactly on the
   button's bounds; 0 overhangs it by 12px on each side.

   The X in `translate` is the other half of lining up, and it is why the pill
   looked wrong: the nav button itself slides 4px toward the pointer on hover
   (2px when it is the active view), and the pill was not going with it. The
   fill therefore sat 4px to the left of the label and of the active row's
   ring, which is the mismatch in the screenshot — nothing to do with the
   corners. JS writes only --fe-gy, so those two offsets are declared once,
   here, next to the rules on .nav-btn that they have to agree with. */
[data-feel] .fe-glide {
  position: absolute; left: 12px; right: 12px; top: 0; z-index: 0;
  border-radius: 9px; background: var(--accent-tint);
  opacity: 0; pointer-events: none;
  translate: var(--fe-nxo) var(--fe-gy, 0px);
  transition:
    translate calc(.32s - .08s * var(--fe-amp)) var(--fe-spring),
    height .26s var(--fe-spring),
    opacity .18s,
    background .18s;
  will-change: translate, height;
}
[data-feel] .fe-glide.roam { translate: var(--fe-nx) var(--fe-gy, 0px); }
[data-fe~="glide"] .fe-glide.on { opacity: 1; }
[data-feel] .fe-glide.roam { background: var(--glass2); }
[data-feel] .nav-btn { z-index: 1; }
[data-fe~="glide"] .nav-btn:hover, [data-fe~="glide"] .nav-btn.on { background: transparent; }
[data-feel] .nav-btn { transition: color .14s, translate .3s var(--fe-spring); }
/* Gated on nothing: --fe-nx and --fe-nxo are 0 unless `lean` is on, so the
   declaration is already inert without it. Gating it on `glide` as well was
   wrong twice over — the lean is not the glider's, and it meant the lean could
   not be shown without the glider, which is exactly what made its step in the
   walkthrough look broken. */
[data-feel] .nav-btn:hover,
[data-feel] .nav-btn.fe-over { translate: var(--fe-nx) 0; }
[data-feel] .nav-btn.on { translate: var(--fe-nxo) 0; }
/* The icon leads the label, so the row arrives in two beats rather than
   sliding as one rigid block — under the same setting as the toolbar glyphs,
   since it is the same movement and it jitters for the same reason. */
[data-feel][data-fe~="icons-smooth"] .nav-btn > i { transition: translate .22s var(--fe-snap); }
[data-feel][data-fe~="icons-smooth"][data-fe~="lean"] .nav-btn:hover > i,
[data-feel][data-fe~="icons-smooth"][data-fe~="lean"] .nav-btn.fe-over > i { translate: 2px 0; }
[data-feel][data-fe~="icons-dwell"] .nav-btn > i { transition: translate .3s var(--fe-spring); }
[data-feel][data-fe~="icons-dwell"][data-fe~="lean"] .nav-btn.fe-dwell > i { translate: 2px 0; }

/* ============ rows ============

   THE GREY IS NOT THIS FILE'S. styles.css has always done it — `.row
   { transition: background .14s }` plus `.row:hover { background: var(--glass);
   transition-duration: 0s }` — instant in, .14s ease out, and it is smooth and
   catches every row at any speed. new.dangus.me is the proof.

   Everything this file ever had to do was ADD a translate transition without
   destroying that, and using the `transition` shorthand to do it is what broke
   it: a shorthand resets transition-duration, so `.row:hover`'s 0s stopped
   applying and the grey faded IN as well as out. The fix for that was a second
   shorthand on :hover, and then a JS class to hold the state across a frame,
   and by then the grey was being driven by three rules and a timer and had
   started missing rows. All of it deleted.

   Longhands, so the two properties are configured independently and the grey
   keeps exactly the numbers it has always had. :hover zeroes only ITS duration.
   Nothing in JS touches the background. */
[data-feel] .row {
  transition-property: background, translate;
  transition-duration: .14s, .2s;
  transition-timing-function: ease, var(--fe-snap);
}
[data-feel] .row:hover { transition-duration: 0s, .2s; }

/* The lean is off by default. It is a discrete per-row jump — the row shifts a
   few pixels and shifts back — so sweeping a list is a run of half-finished
   slides in both directions, which is the "tik tak tik tak" and no easing fixes
   it, because the jaggedness is the steps, not the curve. The accent bar below
   grows in place and does not have that problem, so the highlight survives
   without it. --fe-rx / --fe-nx / --fe-nxo are the single source for all of it,
   including the glider, which has to agree with the button it sits under. */
[data-feel] { --fe-rx: 0px; --fe-nx: 0px; --fe-nxo: 0px; }
[data-fe~="lean"] {
  --fe-rx: calc(5px * var(--fe-amp));
  --fe-nx: calc(4px * var(--fe-amp));
  --fe-nxo: calc(2px * var(--fe-amp));
}
/* THREE carriers, because the lean is not the highlight's. `.fe-hot` is the one
   that matters in use — it is what survives a row being crossed in one frame —
   but requiring it meant `lean` could not be shown with `hot` off, and the
   walkthrough shows exactly one part at a time. :hover covers that case and
   costs nothing when both are on.
   `.fe-over` is the third and it is not a duplicate of :hover: a DEMONSTRATION drives this layer
   with synthetic PointerEvents, which never produce :hover, so with `hot` forced off the Lean
   step and the Lean tile both moved nought pixels — a stage that draws rows and cannot lean one.
   See enter() in feel.js, which is the single place that decides what the pointer is on. */
[data-fe~="lean"] .row:not(.sh-link):hover,
[data-fe~="lean"] .row.fe-over:not(.sh-link),
[data-fe~="lean"][data-fe~="hot"] .row.fe-hot:not(.sh-link) { translate: var(--fe-rx) 0; }

/* The accent bar: the whole highlight when the lean is off, and one row at a
   time either way. It grows from its own middle rather than moving, so a sweep
   reads as the mark travelling down the list instead of as rows twitching. */
/* Asymmetric on purpose: the transition that runs is the one declared on the
   state being moved TO, so arriving is .09s and leaving is .34s. That is the
   whole tuning. Symmetric at .2s, a row crossed in one or two frames at 180Hz
   reached about a sixth of its height before being handed on, which is why the
   bar read as "not really there"; quick in and slow out means every row you
   cross is drawn in full and then unwinds, and several are unwinding at once
   on a sweep. Coming back to one that is still shrinking picks it up where it
   got to and grows it again — a transition reverses from its current value, so
   the unwind is free once the arrival is fast enough to see. */
[data-feel][data-fe~="hot"] .row:not(.sh-link)::before {
  content: ''; position: absolute; left: 0; top: 12%; bottom: 12%;
  width: 2px; border-radius: 2px; background: var(--accent);
  scale: 1 0; transform-origin: 50% 50%;
  transition: scale .34s var(--fe-snap);
}
/* The grey too, for the rows the pointer crossed without the browser saying
   so — see sweepBetween in feel.js. ADDITIVE only: it sets the same colour
   `.row:hover` sets and touches nothing else, so the hovered row is unchanged
   and the transition config stays exactly the app's own (.14s ease at rest,
   0s while hovered). Overriding that config with a shorthand is what broke the
   grey the first time; this does not. */
[data-fe~="hot"] .row.fe-hot:not(.sh-link):not(.selected) {
  background: var(--glass);
  transition-duration: 0s, .2s;
}
[data-feel][data-fe~="hot"] .row.fe-hot:not(.sh-link)::before {
  scale: 1 1;
  /* 0s in, exactly like the grey it sits beside. .09s sounded gentler and
     measured terribly: at 11ms per row a flick left every bar at about a
     tenth of its height, so the trail was slivers. Instant means each row you
     cross is drawn in FULL and then unwinds over .34s, which is the same
     shape as the grey — arrive together, leave at different rates — and it is
     the leaving that the eye actually follows. */
  transition: scale 0s;
}

/* ---- the bar handed between neighbours ----
   While `carry` is driving, the height is not a state — it is a number written every frame
   from the pointer's position between two row centres, so no transition may be in the way. See
   "handing the bar over" in feel.js for what the number means.

   Both of these have to outrank `.fe-hot`'s `transition: scale 0s` above, or letting go of a
   half-drawn bar on the row the pointer is still on would snap it to full instead of easing
   there. One more attribute and one more class than that rule has, and after it in the file. */
[data-feel][data-fe~="hot"][data-fe~="carry"] .row.fe-carry:not(.sh-link)::before {
  scale: 1 var(--fe-bar, 0);
  transition: none;
}
/* The hand-back: the drive stops writing, the bar keeps whatever height it had, and the row's
   own state (lit or not) becomes the target of an ordinary .34s ease. Nothing is written here
   at the moment of release — that is the whole point, a written value is what snaps. */
[data-feel][data-fe~="hot"][data-fe~="carry"] .row.fe-carry-off:not(.sh-link)::before {
  transition: scale .34s var(--fe-snap);
}

/* ============ cards ============
   The whole transform is restated here rather than patched, because a card
   in this mode is doing four things at once — lift, tilt, squash and entry —
   and three of them arrive from JS as custom properties. One declaration
   that composes them beats four rules fighting over one property.

   translateZ after the rotation, so the lift runs along the card's own
   normal: it comes toward you out of the plane it is tilted into, which is
   what sells the perspective. Reverse the order and it merely slides. */
[data-feel][data-fe~="tilt"] .card:not(.leaving) {
  transform:
    perspective(760px)
    rotate3d(var(--fe-ax, 1), var(--fe-ay, 0), 0, calc(var(--fe-a, 0deg) * var(--fe-tilt)))
    translateZ(var(--fe-lift, 0px));
  transition: transform .28s var(--fe-snap), border-color .16s, box-shadow .16s, background .16s;
}
[data-feel][data-fe~="tilt"] .card:not(.leaving):hover {
  --fe-lift: 18px;
  /* while the pointer is on it, the tilt has to keep up with the pointer */
  transition: transform .1s linear, border-color 0s, box-shadow 0s, background 0s;
  box-shadow: 0 22px 46px -18px rgba(0, 0, 0, .75);
}
[data-feel="soft"]:not([data-fe~="tilt"]) .card:hover { transform: translateY(-4px); }
/* Media parallax: the picture drifts against its own frame, a couple of
   pixels behind the card's tilt. Overscaled first so the drift never
   exposes an edge. */
[data-feel][data-fe~="tilt"] .card-media img {
  scale: 1.06;
  translate: calc(var(--fe-px, 0) * -3px) calc(var(--fe-py, 0) * -3px);
  transition: translate .18s linear, scale .3s var(--fe-snap);
}
[data-feel][data-fe~="tilt"] .card:hover .card-media img { scale: 1.09; }
/* Entry, with a settle on it. `backwards`, never `both` — a filling
   animation keeps owning `transform` for the life of the card, and the tilt
   above would land instantly for ever after. */
[data-feel="full"] .card:not(.leaving) { animation: fe-card-in .46s var(--fe-spring) backwards; }
@keyframes fe-card-in { from { opacity: 0; transform: perspective(760px) translateY(14px) scale(.94); } }

[data-feel="full"] .ph-item:hover {
  transform: scale(1.045); z-index: 2;
  transition: transform .26s var(--fe-spring), box-shadow .16s var(--ease);
}

/* ============ popovers, menus, modals ============ */
[data-feel] .ctx, [data-feel] .pop { animation: fe-menu-in calc(.14s + .1s * var(--fe-amp)) var(--fe-spring) both; }
@keyframes fe-menu-in { from { opacity: 0; transform: scale(.9) translateY(-4px); } }
[data-feel] .modal { animation: fe-modal-in calc(.24s + .12s * var(--fe-amp)) var(--fe-spring) both; }
@keyframes fe-modal-in { from { opacity: 0; transform: translateY(calc(16px * var(--fe-amp))) scale(calc(1 - .05 * var(--fe-amp))); } }
[data-feel] .ctx-item { transition: background .1s, translate .2s var(--fe-spring); }
@media (hover: hover) { [data-feel] .ctx-item:hover { translate: calc(3px * var(--fe-amp)) 0; } }

/* ============ toasts ============ */
[data-feel] .toast:not(.out) { animation: fe-toast-in calc(.26s + .14s * var(--fe-amp)) var(--fe-spring) both; }
@keyframes fe-toast-in { from { opacity: 0; transform: translateY(calc(14px * var(--fe-amp))) scale(.9); } }

/* ============ the cursor comet ============
   Full mode only, and speed-gated: it exists while you are flicking across
   the window and is gone by the time you have stopped to read anything. A
   trail that is always on is a trail you are always looking at.

   One canvas, stroked along the pointer's actual path, rather than the nine
   chasing dots it started as — nine beads cannot describe a fast circle, and
   Dangis's first report of it was exactly that: choppy. All of the drawing is
   in feel.js; the only thing this rule has to get right is that the element
   is fixed, inert and on top.

   Fixed position, so every coordinate written into it is divided by the UI
   zoom in feel.js — a fixed element's `left` and `width` render at ×Z while
   the pointer event that produced it is already in ×Z space, and innerWidth
   is in neither. */
.fe-comet {
  position: fixed; left: 0; top: 0; z-index: 999;
  pointer-events: none;
}

/* ============ the sweep combo ============
   Press the checkbox gutter and drag, and the count rides the pointer while
   every tile it takes flashes a ring. Straight out of osu: the number is the
   feedback, it grows with the streak, and it punches on each hit rather than
   counting up politely. It follows the pointer instead of living in the
   toolbar because the toolbar is not where you are looking.

   Fixed, so — as with the comet — every coordinate written into it has
   already been divided by the UI zoom. */
.fe-combo {
  position: fixed; left: 0; top: 0; z-index: 998;
  pointer-events: none; user-select: none;
  padding: 5px 11px; border-radius: 999px;
  font: 700 13px/1 var(--mono, ui-monospace, monospace);
  color: var(--accent-ink, #04121a); background: var(--accent);
  box-shadow: 0 6px 20px -6px color-mix(in srgb, var(--accent) 80%, transparent);
  translate: 18px -34px;
  opacity: 0; scale: .6;
  transition: opacity .16s var(--fe-snap), scale .3s var(--fe-spring);
  will-change: transform, scale;
}
.fe-combo.on { opacity: 1; scale: 1; }
/* Restarted from JS by removing and re-adding the class, so a fast sweep
   punches once per tile instead of once per gesture. */
.fe-combo.hit { animation: fe-combo-hit .22s var(--fe-spring); }
@keyframes fe-combo-hit {
  0%   { scale: 1.34; }
  100% { scale: 1; }
}
/* The tile that was just taken. `outline` rather than a border or a shadow:
   it is drawn outside the box, so it cannot reflow the grid and cannot be
   clipped by a card that already owns its own overflow. */
/* !important, for the reason `.card.revealed` has it in styles.css: enterStyle() writes
   `animation:none` INLINE on every tile painted outside a navigation, and Lively's entry cascade
   (`[data-feel="full"] .card:not(.leaving)`) outranks a plain rule too — so a taken tile kept
   its class and showed no ring, in the grid and on the Effects page's sweep stage (17.10.2026).
   The reduced-motion `none !important` below still wins: same selector, later. */
[data-feel] .fe-took { animation: fe-took calc(.34s + .1s * var(--fe-amp)) var(--fe-snap) !important; }
@keyframes fe-took {
  from { outline: 2px solid var(--accent); outline-offset: -1px; }
  to   { outline: 2px solid transparent; outline-offset: calc(7px * var(--fe-amp)); }
}

/* ============ messages ============

   The one view in the app that was outside this layer entirely, and it showed: the file list
   answered the pointer and the conversation beside it did not.

   It joins rather than growing a tactile layer of its own — the light, the press and the wave
   all arrive from the lists above, because every surface in Messages is a shape this file
   already knows (a run of rows, a grid of pictures, a strip of icon buttons). What is here is
   only what those rules cannot say on their own.

   One rule governs everything below, and it is about the paint and not about taste: this view
   rebuilds its markup whenever anything in it changes, so an ENTRY animation written here
   against something that is always on screen would replay every time anything else changed. So
   there are none. Entries are one-shot classes put on from paintMessages (`.ch-in`,
   `.ch-cascade`, `.send-in`, `.pop`) or belong to elements that are built for the occasion
   (the drop cue, the pickers, the flying badge). Everything in this section is either a state
   the pointer is holding, or an amplification of a one-shot the app already decided to run. */

/* ---- the people column ----
   The same accent bar the file list has, for the same reason: a name is crossed in two frames,
   and the bar is what survives that. Quick in and slow out — arriving is 0s and leaving is the
   .34s unwind, so every row a fast hand crosses is drawn in FULL and then lets go. */
[data-feel][data-fe~="hot"] .msg-person::before {
  content: ''; position: absolute; left: 0; top: 14%; bottom: 14%;
  width: 2px; border-radius: 2px; background: var(--accent);
  scale: 1 0; transform-origin: 50% 50%;
  transition: scale .34s var(--fe-snap);
}
[data-feel][data-fe~="hot"] .msg-person.fe-hot::before { scale: 1 1; transition: scale 0s; }
/* The grey for the names the pointer went over without the browser reporting them — see
   sweepBetween, which now knows about this column as well as about the file list.

   FOUR durations, one per property in the list below, and they have to stay in step: a shorter
   list repeats from the start, so three values would put `translate` on 0s and the lean would
   snap instead of springing, and two would put box-shadow on 0s and lose the fade that makes
   unpicking read as the glow leaving that row. */
[data-fe~="hot"] .msg-person.fe-hot:not(.on):not(.picked):not(.drop-to) {
  background: var(--glass);
  transition-duration: 0s, .2s, .22s, .2s;
}
[data-fe~="lean"] .msg-person:hover,
[data-fe~="lean"] .msg-person.fe-over,
[data-fe~="lean"][data-fe~="hot"] .msg-person.fe-hot { translate: var(--fe-rx) 0; }
[data-feel] .msg-person, [data-feel] .msg-ask { transition-property: background, border-color, box-shadow, translate; }
[data-feel] .msg-ask:hover { translate: var(--fe-rx) 0; }

/* The tick that lands when somebody is picked for a send. `.pop` is put on by paintMessages and
   only for a name that was not already picked, so picking a second person does not re-pop the
   first one's tick. */
[data-feel] .pick-pill.pick-in { animation: fe-pill-in calc(.24s + .16s * var(--fe-amp)) var(--fe-spring); }
@keyframes fe-pill-in { from { opacity: 0; transform: scale(0) rotate(-90deg); } }
/* And the bar it belongs to, the first time it appears — `.send-in`, same bookkeeping. */
[data-feel] .send-bar.send-in { animation: fe-send-in calc(.26s + .16s * var(--fe-amp)) var(--fe-spring); }
@keyframes fe-send-in { from { opacity: 0; transform: translateY(calc(-14px * var(--fe-amp))) scale(.97); } }

/* ---- the tap ----

   The file list answers a click with a shockwave: a ring going out from where you hit, which is
   right there — you are striking one object out of a field of them, and a circle spreading is
   what striking a surface looks like.

   A conversation is not a field of objects. It is lines: rows of people, rows of words, a bar
   you type into. So a press here does the thing a line does — it OPENS. A band of light parts
   from under your finger and runs out to both edges of whatever you pressed, and the edge of
   that thing catches the accent for a moment as it goes. Horizontal where the shockwave is
   radial, a widening where the shockwave is a travelling ring, and it lands in the same
   ~400ms so the two feel like one hand.

   Everything is restated rather than patched: `.fe-rip > i` is a circle with a radial gradient
   and a translate, and half a rule that used to be a circle is worse than either. */
[data-feel] .fe-rip.fe-tap > i {
  left: 0; top: 0; width: 100%; height: 100%;
  margin: 0; translate: none; border-radius: 0;
  background: linear-gradient(90deg,
    transparent 0%,
    color-mix(in srgb, var(--fe-hue) 24%, transparent) 34%,
    color-mix(in srgb, var(--fe-hue) 44%, transparent) 50%,
    color-mix(in srgb, var(--fe-hue) 24%, transparent) 66%,
    transparent 100%);
  /* Out from the finger, not out from the middle. --fe-x is already the press point measured
     from the host's left edge, in the host's own unscaled pixels. */
  transform-origin: var(--fe-x) 50%;
  animation: fe-tap-open calc(280ms + 150ms * var(--fe-amp)) cubic-bezier(.16, .84, .28, 1) forwards;
}
@keyframes fe-tap-open {
  from { transform: scaleX(0); opacity: 1; }
  55%  { transform: scaleX(1); opacity: .9; }
  to   { transform: scaleX(1); opacity: 0; }
}
/* The edge catching. Inset, on the clip box, which already inherits the host's radius — so it
   traces the real shape of a pill, a row or a 30px emoji square without knowing which it is. */
[data-feel] .fe-rip.fe-tap {
  animation: fe-tap-edge calc(280ms + 150ms * var(--fe-amp)) cubic-bezier(.2, .8, .25, 1) forwards;
}
@keyframes fe-tap-edge {
  from { box-shadow: inset 0 0 0 1.5px var(--fe-hue); }
  60%  { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--fe-hue) 45%, transparent); }
  to   { box-shadow: inset 0 0 0 1.5px transparent; }
}

/* ---- the composer ----
   The three icons lift the way every other icon button in the app does. `translate`, so it
   composes with the press squash's `scale` and with the plus's `rotate` instead of fighting
   whichever of the three is declared last. */
[data-feel] .ch-ic { transition: color .14s, background .14s, border-color .14s, translate .24s var(--fe-spring); }
[data-feel="full"] .ch-ic:hover { translate: 0 calc(-2px * var(--fe-amp)); }

/* ---- a line in the thread ----

   Nothing. And that is the note worth keeping: this had the file list's accent bar in the
   gutter, and it was wrong for the same reason the light below was — a line of somebody's
   writing is not a row you are aiming at. There is nothing to click, so there is nothing to
   answer, and drawing a target on it makes the conversation feel like a menu. Dangis called it
   out as copy-paste and it was. The hover band and the clock are the app's own and stay.

   The clock that used to slide out of the gutter went with it, and for the same reason: it was
   a second thing moving on a surface that is being read. */

/* ---- arriving ----
   Amplifies the app's own .ch-in, which is put on by markArrivals and only ever for a line
   nobody has seen before in a conversation that was already open. In bubbles it comes from its
   own side, because in bubbles the sides mean something; in rows everything shares one column
   and there is no side for it to come from. */
[data-feel] .ch-msg.ch-in { animation: fe-ch-in calc(.26s + .16s * var(--fe-amp)) var(--fe-spring); }
@keyframes fe-ch-in { from { opacity: 0; transform: translateY(calc(9px * var(--fe-amp))) scale(.94); } }
[data-feel][data-chat=bubbles] .ch-grp:not(.mine) .ch-msg.ch-in { animation-name: fe-ch-in-them; }
[data-feel][data-chat=bubbles] .ch-grp.mine .ch-msg.ch-in { animation-name: fe-ch-in-mine; }
@keyframes fe-ch-in-them { from { opacity: 0; transform: translate(calc(-16px * var(--fe-amp)), 5px) scale(.9); } }
@keyframes fe-ch-in-mine { from { opacity: 0; transform: translate(calc(16px * var(--fe-amp)), 5px) scale(.9); } }
/* A file arriving is a bigger event than a line of text, and it says so by holding the accent
   for the first half of its entry. No `to`: the implicit 100% is the card's own resting style,
   so it settles into whatever the theme says a card looks like. */
[data-feel] .ch-file.ch-in { animation: fe-ch-file-in calc(.34s + .2s * var(--fe-amp)) var(--fe-spring); }
@keyframes fe-ch-file-in {
  from { opacity: 0; transform: translateY(calc(14px * var(--fe-amp))) scale(.93);
         border-color: var(--accent); box-shadow: 0 0 26px -6px var(--accent); }
  55%  { border-color: var(--accent); }
}
/* Opening a conversation. Bigger and springier than the plain cascade, and only in full. */
[data-feel="full"] .ch-grp.ch-cascade {
  animation: fe-ch-cascade .44s var(--fe-spring) backwards;
  animation-delay: calc(var(--i, 0) * 30ms);
}
@keyframes fe-ch-cascade { from { opacity: 0; transform: translateY(20px) scale(.96); } }

/* ---- something held over the thread ----
   The pill hangs rather than sits. Two animations on one element, so the entry has to be
   restated here — a second `animation` declaration replaces the first, it does not add to it. */
[data-feel="full"] .msg-drop-cue {
  animation: cueIn .28s var(--fe-spring), fe-cue-float 2.4s ease-in-out .28s infinite;
}
@keyframes fe-cue-float {
  0%, 100% { translate: -50% 0; }
  50%      { translate: -50% -5px; }
}

/* ============ the beat ============
   One pulse from the mark in the corner when the view changes, so switching
   places has a downbeat instead of a cut. Gated on the switch, not on Lively (17.10.2026): the
   old `[data-feel="full"]` was the last Ballistic-only rule, so View beat switched on at
   Tactile did nothing, in the app and on the Effects page's preview alike. */
[data-feel][data-fe~="beat"] .logo-cloud { transform-origin: 50% 60%; }
[data-feel][data-fe~="beat"] .logo-cloud.fe-beat { animation: fe-beat .5s var(--fe-spring); }
@keyframes fe-beat {
  0%   { transform: scale(1); }
  22%  { transform: scale(1.22) rotate(-5deg); }
  100% { transform: scale(1); }
}

/* The picker in Appearance and the walkthrough that goes with it USED TO BE HERE, and moved
   to feelui.css on 2026-09-05 when the screen was rebuilt around live demos. The split is the
   same one looks.css made: this file is what the app does under your hand, and that one is the
   screen where you choose it. Nothing in here is layout and nothing in here is a control.

   feelui.css is loaded BEFORE this file, so a rule there that has to beat one here needs a
   class more than the [data-feel] descendant selectors below — see .fx-stage.fx-rows .row. */

/* ---- the layer is Meridian's, and Appearance has to say so -----------------
   added 2026-09-06

   feel.js reads `off` under every theme but Meridian (see OWNERS there), which by itself is
   the correct behaviour and the wrong screen: app.js builds Appearance ▸ Feel from
   `Feel.mode()`, so under Luna the picker shows "Off" selected, hides the effect grid, and
   offers three cards that write a preference and then visibly do nothing. A control that
   silently does nothing is the bug this repo keeps writing down, and it is worse than a
   missing one because it teaches you the app is broken.

   So the three cards are put beyond reach and told why. The note is generated content on the
   grid rather than markup, because the markup is app.js's and app.js is not this file's to
   edit — the honest cost is that the sentence cannot name the theme you are actually wearing.
   If a `<p class="fx-note">` is ever added there in JS, delete this whole block: a real
   paragraph beats a ::before, and two notes is worse than either.

   And yes, this is the picker, which lives in feelui.css — the one exception to the split
   noted at the foot of this file, and it is here because the ownership of the SCREEN and the
   ownership of the LAYER came apart on 2026-09-06 and this rule is about the layer. It is
   three selectors; move them if the two ever line up again.

   Only the cards are locked. `.fx-acts` — "Buttons act on press", "Files open on press" — sits
   OUTSIDE `.feel-rest` and is not gated in feel.js either, because those two are behaviour
   rather than texture and have nothing to do with which theme is worn. */
html:not([data-theme=meridian]) .feel-seg { position: relative; grid-template-columns: 1fr; }
html:not([data-theme=meridian]) .feel-seg button {
  /* Not display:none. The three amplitudes are what the note is ABOUT, and a note explaining
     something that is not on screen explains nothing. */
  opacity: .42;
  filter: grayscale(1);
  pointer-events: none;
}
html:not([data-theme=meridian]) .feel-seg::before {
  content: "Feel belongs to Meridian. It is switched off under every other theme — each one "
           "brings its own sounds and its own touch instead. Your settings here are remembered "
           "for when you wear Meridian again.";
  order: -1;
  padding: 9px 12px; border-radius: 9px;
  font-size: 12.5px; line-height: 1.5;
  color: var(--accent-text); background: var(--accent-tint);
  box-shadow: inset 0 0 0 1px var(--accent-ring);
}

/* The OS setting outranks the picker, exactly as the delete animation's does.
   feel.js already forces the whole layer off when reduce is asked for; this
   is the second lock, for an attribute set by hand. */
@media (prefers-reduced-motion: reduce) {
  .fe-fx, .fe-rip, .fe-comet, .fe-combo { display: none !important; }
  [data-feel] .fe-took { animation: none !important; }
  [data-feel] .nav-btn:hover, [data-feel] .row:hover,
  [data-feel] .row.fe-hot, [data-feel] .ctx-item:hover { translate: none !important; }
  [data-feel] .row::before { content: none !important; }
  [data-feel="full"] .card:not(.leaving) { transform: none !important; }
}

/* ---- the panel sweep (feel.js `sweep`, public/sweep.js) ----  18.10.2026
   While app.js runs one, the main panel is its own view-transition layer and is the only thing
   handed over across the line; the rest of the window (the sidebar you pressed) swaps at once
   rather than taking the browser's default crossfade. The class is on <html> only for the
   transition's length. */
/* Opaque for the run: the panel has no background of its own (the page's shows through it), and a
   see-through snapshot would let the line's colour fill every empty pixel of the view. */
html.nb-sweep .main { view-transition-name: nb-main; background-color: var(--bg); }
html.nb-sweep::view-transition-old(root),
html.nb-sweep::view-transition-new(root) { animation: none; }

/* ---- the Vault moment (feel.js `vault`) ----  18.10.2026
   The sidebar lock is the Vault's state, so its shackle answers it: lifted and swung open while
   the Vault is open, dropped shut when it is locked, with one soft ring settling round it as it
   closes (the seal, a class feel.js puts on for the lock). Transforms only: nothing moves in the
   layout, and without the layer (Calm, reduced motion, another theme) the glyph is as it was. */
[data-feel][data-fe~="vault"] .nav-vault > i { position: relative; }
[data-feel][data-fe~="vault"] .nav-vault > i > svg { overflow: visible; }
[data-feel][data-fe~="vault"] .nav-vault > i > svg > path {
  transform-box: fill-box; transform-origin: 100% 100%;
  transition: transform .26s cubic-bezier(.55, 0, .75, .2);
}
[data-feel][data-fe~="vault"] .nav-vault:not(.locked) > i > svg > path {
  transform: translateY(-2.5px) rotate(-18deg);
  transition: transform .46s cubic-bezier(.3, 1.45, .45, 1);
}
[data-feel][data-fe~="vault"] .nav-vault > i::after {
  content: ''; position: absolute; inset: -6px; border-radius: 50%; pointer-events: none;
  border: 1.5px solid var(--accent); opacity: 0; transform: scale(.55);
}
[data-feel][data-fe~="vault"] .nav-vault.fe-seal > i::after { animation: fe-seal .72s cubic-bezier(.2, .7, .2, 1) .2s both; }
@keyframes fe-seal {
  0% { opacity: 0; transform: scale(.55); }
  30% { opacity: .8; }
  100% { opacity: 0; transform: scale(1.4); }
}
.ef-vt { display: grid; align-content: center; height: 100%; padding: 0 18px; }
.ef-vt .nav-vault { margin: 0 auto; border-top: 0; padding-top: calc(8 * var(--sp)); width: 150px; transform: scale(1.5); }
