/* Handloom Haat kiosk framework, the shared design language.
   Dark gallery ground, handloom gold and madder accents.
   Serif (EB Garamond / Cormorant) for display, Lato for reading text.
   Touch-first: minimum hit target 64px, no hover states required. */

@font-face { font-family: 'Cormorant Garamond'; src: url('fonts/CormorantGaramond-var.ttf'); font-weight: 300 700; }
@font-face { font-family: 'EB Garamond'; src: url('fonts/EBGaramond-var.ttf'); font-weight: 400 800; }
@font-face { font-family: 'EB Garamond'; src: url('fonts/EBGaramond-Italic-var.ttf'); font-style: italic; font-weight: 400 800; }
@font-face { font-family: 'Lato'; src: url('fonts/Lato-Regular.ttf'); font-weight: 400; }
@font-face { font-family: 'Lato'; src: url('fonts/Lato-Light.ttf'); font-weight: 300; }
@font-face { font-family: 'Lato'; src: url('fonts/Lato-Black.ttf'); font-weight: 900; }

:root {
  --bg: #16130f;            /* deep loom-wood dark */
  --bg-raised: #211d17;
  --bg-card: #2a251d;
  /* Contrast ratios measured against --bg by testing/test_contrast_audit.py.
     Gallery viewing is 40-80cm in a lit room, so everything carrying text
     must clear WCAG AA (4.5:1 body, 3:1 large). The three originals that
     failed are noted with their old values. */
  --ink: #ede5d8;           /* unbleached cotton      14.8:1 */
  --ink-dim: #b3a895;       /*                         7.9:1 */
  --ink-faint: #9c9280;     /* was #7a715f = 3.84:1, failed AA body */
  --gold: #c9a24b;          /* zari gold               7.7:1 */
  --madder: #c25a41;        /* was #a8442f = 3.11:1, failed AA body */
  --indigo: #6f8fbe;        /* was #3d5a80 = 2.62:1, failed AA outright */
  --madder-deep: #a8442f;   /* original madder, for FILLS only (never text) */
  --indigo-deep: #3d5a80;   /* original indigo, for FILLS only (never text) */
  /* Each token carries the bundled Devanagari face BEHIND its Latin face.
     html[lang="hi"] only reaches body, h1-h3, .btn and .homepill, so any element
     with its own font-family lost Devanagari entirely and fell through to
     whatever the machine happened to supply. This build box has 74 system
     Devanagari fonts, so every glyph rendered here and every screenshot looked
     correct; a sealed panel has only what these stacks name. Measured 13 Aug
     under a fontconfig rejecting the system faces by FILE PATH (rejecting by
     family name also rejects the bundled copies, which declare the same names,
     and produces a false alarm). Additive only: Latin text still resolves to the
     Latin face first, so English rendering cannot change. Proven all day in
     documentary-selector, which carried these exact three lines locally. */
  --serif: 'EB Garamond', 'Noto Serif Devanagari', serif;
  --display: 'Cormorant Garamond', 'Noto Serif Devanagari', serif;
  --sans: 'Lato', 'Noto Sans Devanagari', sans-serif;
  --radius: 14px;
  --touch: 64px;
  /* SHARED TOKENS, added 16 Aug for the app-polish rollout. Both are purely
     ADDITIVE: this block defines them and nothing in framework/ is restyled to
     use them here. Apps adopt them in their own files, in their own time, which
     is what keeps a token change from becoming a six-station re-measure.

     --hairline is the rule-line colour five of the six apps had already settled
     on as a literal (framework/kiosk.css itself twice, passageway-browse three
     times, music-rhythm four); one app drifted. Naming the value they already
     share is what ends the drift, so this token is a description of the fleet
     rather than a new decision. */
  --hairline: #3a332a;
  /* Motion. Three durations for the three things that actually move on a kiosk:
     a control acknowledging a finger, a panel sliding, a whole screen changing.
     Named by what moves rather than by a number, so an app author picks the
     meaning and the fleet stays coherent even if the values are retuned once.
     The easing is one house curve: a gentle start and a long settle, which on a
     32-inch panel at arm's length reads as cloth moving rather than as software
     animating. */
  --dur-micro: .18s;
  --dur-panel: .28s;
  --dur-screen: .45s;
  --ease-house: cubic-bezier(.2, .7, .3, 1);
  /* THE LANGUAGE PILL ONLY, and deliberately a second token rather than a
     change to --touch above.

     Vivek ruled 48px on 15 Aug. The instruction was about the language pill and
     this token is about the language pill: --touch stays 64 and still governs
     its eleven other rules, so no button, chip, seek bar or icon target in any
     of the six apps changes size, and the brand watermarks, whose top offset is
     computed from --touch, do not move. Reading "one number everywhere" the
     other way would have taken every touch target in a public gallery from 64px
     to 48px on a ruling about a single control.

     It also closes an inconsistency that predates the ruling: this floor was
     asserted at 64 here, at 60 in three suites and at 44 in two, with one suite
     comment declaring the 44 dead while two suites still used it. The language
     pill is now 48 here and 48 in all four suite assertions that test it. The
     HOME pill stays at 60 and everything else stays at 64: different controls,
     none of which shrank. */
  --touch-lang: 48px;
  /* The card's secondary line (a count, a duration, a region) at a hard floor,
     so it stays legible for a visitor standing at the 15.6-inch panel and not
     only at the 32-inch. Owned here as one number the six app-local meta
     classes reference (documentary, oral, archival and the rest) rather than
     each guessing its own, so the floor is set once. Copy that overflows a
     card has too much in it and is cut at the source, never clamped. (D7) */
  --meta-size: max(22px, .9em);
}

* { margin: 0; padding: 0; box-sizing: border-box;
    -webkit-user-select: none; user-select: none;
    -webkit-tap-highlight-color: transparent; }

html, body {
  width: 100%; height: 100%; overflow: hidden;
  background: var(--bg); color: var(--ink);
  font-family: var(--sans);
  /* NOTE: do not raise this to enforce an 18px floor here. Each app already
     re-bases its own root for its panel (verified: 0 text runs under 18px
     across all 40 screens). Raising it again inflates every app a second time
     and pushes cards out of the viewport. */
  /* 20px, not 22. Nudging this up for the 32 inch panels read better in the
     abstract and pushed the quiz's fourth option off the bottom of a 1080 tall
     screen: the layouts are tuned to this root, and a global type change is
     not a free improvement. */
  font-size: clamp(14px, 1.2vw, 20px);
  cursor: none;
  overscroll-behavior: none;   /* kills two-finger back/forward navigation */
}

/* Gestures must stay inside the app. `touch-action: manipulation` does NOT
   stop a two-finger pinch: it zoomed the page to 4x in testing and left the
   kiosk stuck there, and the --disable-pinch launch flag did not prevent it.
   Pinning the full-screen layers to `none` and re-granting only the axis each
   scroller needs is what actually holds. Chrome stops the touch-action walk
   at the scroller, so `none` here does not disable `pan-y` inside a list. */
#saver, .screen, .modal, .player { touch-action: none; }
.grid, .cliplist, .tracks, .lb, [data-scroll] { touch-action: pan-y; }

img, video, svg { -webkit-user-drag: none; user-drag: none; }

img, video { pointer-events: none; }
button, .touchable { pointer-events: auto; cursor: none; }

/* ---------- typography ----------
   The complaint that started this was that screen titles and card captions
   read as the same thing, and they did: 3.2em and 1.35em of the same serif at
   the same weight in the same ink is a size difference, not a hierarchy.

   The fix is a weight INVERSION rather than more size. A screen title is now
   the display face at its lightest, large and slightly open, which is how a
   gallery sets a title. A card caption is the text serif, smaller, semibold
   and a touch tracked, which is how a label is set. Big-and-light against
   small-and-solid reads as title against label at a glance and from across
   the room, where two sizes of the same weight do not. The ratio widens from
   2.4x to 2.7x as well, but that is the smaller half of the effect.
   Typefaces and palette are untouched. */
h1, .h1 { font-family: var(--display); font-weight: 400; font-size: 3.4em;
          line-height: 1.06; letter-spacing: .008em; color: var(--ink); }
h2, .h2 { font-family: var(--serif); font-weight: 500; font-size: 2.05em; line-height: 1.2; }
h3, .h3 { font-family: var(--serif); font-weight: 600; font-size: 1.24em;
          line-height: 1.3; letter-spacing: .006em; }
.body { font-family: var(--sans); font-weight: 300; line-height: 1.6; color: var(--ink-dim); }
.caption { font-size: .82em; color: var(--ink-faint); letter-spacing: .04em; }
/* A small tracked cap line for the thing ABOVE a title: a region, a craft, a
   count. It exists so an app has somewhere to put that line other than at
   caption size, where it competes with the title it belongs to. */
.eyebrow { font-family: var(--sans); font-weight: 600; font-size: max(18px, .72em);
           letter-spacing: .14em; text-transform: uppercase; color: var(--gold); }
.gold { color: var(--gold); }

/* ---------- structure ---------- */
/* An inactive screen must be completely untouchable. `pointer-events: none`
   alone is NOT enough: any descendant that sets `pointer-events: auto` (every
   button, .touchable and .card does) re-enables itself, so a faded-out screen
   kept stealing taps from the screen underneath. That reproduced as subject
   cards becoming untappable after one profile visit, and as a hidden screen's
   controls firing. `visibility` cannot be overridden that way: it removes the
   whole subtree from hit-testing. It flips instantly on activate and only
   after the fade on deactivate, so the cross-fade still reads smoothly. */
.screen { position: absolute; inset: 0; display: flex; flex-direction: column;
          opacity: 0; visibility: hidden; pointer-events: none;
          transition: opacity .45s ease, visibility 0s linear .45s; }
.screen.active { opacity: 1; visibility: visible; pointer-events: auto;
                 transition: opacity .45s ease, visibility 0s linear 0s; }

.topbar { display: flex; align-items: center; gap: 1.2em;
          padding: 1.1em 1.6em; }
.topbar .title { flex: 1; }

/* ---------- buttons ---------- */
.btn {
  min-height: var(--touch); min-width: var(--touch);
  padding: .7em 1.6em; border: 1.5px solid var(--gold); border-radius: var(--radius);
  background: transparent; color: var(--gold);
  font-family: var(--serif); font-size: 1.15em; letter-spacing: .03em;
  display: inline-flex; align-items: center; justify-content: center; gap: .5em;
}
.btn:active { background: var(--gold); color: var(--bg); }
.btn.primary { background: var(--gold); color: var(--bg); }
.btn.primary:active { filter: brightness(1.15); }
.btn.ghost { border-color: var(--ink-faint); color: var(--ink-dim); }
.btn.big { font-size: 1.5em; padding: .9em 2.2em; }

/* A back button belongs in the top-left, and so does the settings cluster, so
   the default has to yield rather than sit underneath it. At left:1em it was
   already painted under the language toggle before the type control existed,
   which is why two apps had each written their own `left:` override for it.
   The framework knows how wide its own chrome is, so it should be the one to
   say. The fallback keeps the old value if the variable has not been
   published yet, which is the single frame before kiosk.js measures.

   Scoped away from .player deliberately: a running film drops the cluster to
   z 50 and owns that corner for its own close button, so the film's ✕ must
   stay hard against the edge where a visitor reaches for it. */
.backbtn { position: absolute; top: 1em; left: 1em; z-index: 40; }
.screen .backbtn, .modal .backbtn { left: var(--kiosk-chrome-clear, 1em); }

/* ---------- the Home pill ----------
   One control, same place, same meaning, on every screen of every app:
   tap it and the kiosk is home again, reset for a fresh visitor. Sits below
   the media player (z 60) on purpose. The player has its own close, and a
   film should not carry a second competing exit. Apps may reposition via
   .homepill but should not restyle it: public visitors learn it once. */
.homepill {
  position: fixed; top: 1em; right: 1em; z-index: 55;
  min-height: var(--touch); padding: .55em 1.5em .55em 1.2em;
  border: 1.5px solid var(--gold); border-radius: 999px;
  background: rgba(22, 19, 15, .82); color: var(--gold);
  font-family: var(--serif); font-size: 1.12em; letter-spacing: .02em;
  display: none; align-items: center; gap: .55em;
  pointer-events: auto; cursor: none;
}
.homepill.visible { display: inline-flex; }
.homepill:active { background: var(--gold); color: var(--bg); }
.homepill .glyph { font-size: 1.25em; line-height: 1; }

/* ---------- cards / grids ---------- */
.grid { display: grid; gap: 1.4em; padding: 1.4em;
        overflow-y: auto; overscroll-behavior: contain; flex: 1;
        pointer-events: auto; }
.grid::-webkit-scrollbar { width: 0; }

/* The border stays 1px and the box geometry is therefore unchanged, but it is
   now all but invisible until the card is pressed. A grid of hard grey
   outlines is what made these read as the default card component rather than
   as considered surfaces, and an outline earns nothing once the card carries
   a woven face: cloth has its own edge. The shadow does the separating
   instead, which is how a textile panel actually sits on a wall. */
.card {
  background: var(--bg-card); border-radius: var(--radius); overflow: hidden;
  border: 1px solid rgba(237,229,216,.07); pointer-events: auto;
  box-shadow: 0 8px 22px rgba(0,0,0,.38);
  display: flex; flex-direction: column;
}
.card:active { border-color: var(--gold); transform: scale(.985); }
.card .thumb { width: 100%; aspect-ratio: 16/9; object-fit: cover; background: #000; display: block; }
.card .meta { padding: 1em 1.1em 1.2em; }
.card .meta h3 { color: var(--ink); margin-bottom: .3em; }
/* No line clamp here, deliberately. The clamp that used to sit on this rule was
   the single mechanism behind every mid-sentence ellipsis in the gallery audit:
   it cut a weaver's town off at "the town is..." and gave the visitor no way to
   read the rest. A card that does not fit has too much copy in it, and the copy
   is cut at the source. Nothing on a gallery wall is truncated unless tapping
   it shows the remainder. */
.card .meta p { font-size: var(--meta-size); color: var(--ink-dim); font-weight: 300; }

/* chips / filters */
.chips { display: flex; gap: .7em; padding: 0 1.4em; flex-wrap: wrap; }
/* One touch number governs every small target: --touch, 64px. The 52px here
   was the last exception and it was the size documentary-selector had already
   asked to have raised centrally. */
.chip { min-height: var(--touch); padding: .35em 1.2em; border-radius: 999px;
        border: 1px solid var(--ink-faint); color: var(--ink-dim);
        font-family: var(--sans); font-size: .95em;
        display: inline-flex; align-items: center; pointer-events: auto; }
.chip.on { border-color: var(--gold); color: var(--gold); background: rgba(201,162,75,.08); }

/* ---------- state badges ----------
   A label whose job is to stop a dead tap ("Coming soon" on a card that does
   nothing yet) must be the loudest small thing on that card, not the quietest.
   Solid fill, --bg on --gold at 7.7:1, which clears AA at any size, so it does
   not depend on being set large to be legible from standing distance. */
.badge-state {
  display: inline-flex; align-items: center;
  background: var(--gold); color: var(--bg);
  /* v2: floored at 18px. Bare .9em measured 17.01px on the ten Coming soon
     badges at station 5's portrait root, the one red in the 14 Aug v2
     certification pass. Same floor idiom as the fleet's other type floors;
     behaviour above 20px roots is unchanged. */
  font-family: var(--sans); font-weight: 600; font-size: max(18px, .9em);
  letter-spacing: .02em; padding: .4em 1em; border-radius: 999px;
}

/* ---------- screensaver ---------- */
#saver { position: absolute; inset: 0; z-index: 100; background: var(--bg);
         display: flex; flex-direction: column; align-items: center; justify-content: center;
         gap: 2em; text-align: center;
         opacity: 1; transition: opacity .6s ease; }
#saver.hidden { opacity: 0; visibility: hidden; pointer-events: none;
                transition: opacity .6s ease, visibility 0s linear .6s; }
#saver video, #saver img.bg { position: absolute; inset: 0; width: 100%; height: 100%;
                              object-fit: cover; opacity: .45; }
#saver .overlay { position: relative; z-index: 2; padding: 2em; }
/* The one line on the whole kiosk that has to persuade a standing visitor to
   put a finger on the glass. It is a solid object, not an outline: filled gold
   with --bg ink, 7.7:1. Size floor is max(40px, half the h1), because a person
   several steps away decides whether to approach before they are close enough
   to read anything small.
   The animation moves a ring outwards rather than fading the text. The old
   keyframes spent half of every cycle at opacity .55, which measures 3.16:1
   and is under AA for the single most important sentence on the screen. */
/* ---------- the attract screen is ONE screen across all six stations ----------
   Measured 14 Aug before this block existed: the title rendered 68px on five
   stations and 84px on the sixth; the subtitle rendered at 20, 22, 22, 23, 25
   and 30px, six values for six stations, because `.saver-sub` was styled
   nowhere in the framework and every app had invented its own; and the hairline
   rule under the title appeared on two stations out of six.

   None of that was a decision. It is what happens when a shared screen has no
   shared definition, so each app answers the same question locally. A visitor
   walking the gallery sees these six screens one after another and reads the
   differences as sloppiness, because that is what they are.

   So the attract screen is defined HERE, once, and the apps stop overriding it.
   What stays per-app is only what genuinely differs: the words, and the
   line-height tuning Devanagari needs for those particular strings.

   SIZED IN PIXELS, DELIBERATELY, and this is the part worth reading before
   anyone "tidies" it back to em. The six stations do NOT share a root: archival
   runs a 22px root, the other five run 20px. So an em value that is identical
   in the stylesheet renders 10% larger on that one panel. Archival had already
   noticed and compensated locally (`.saver-title` at 3.1em, which lands on the
   same 68px the others reach at 3.4em), and an earlier version of THIS block
   overrode that compensation and pushed its title to 75px, making the very
   screen it was meant to unify worse on one station.
   Pixels here mean the attract screen is the same size on every panel, whatever
   root that panel's app happens to use, which is the whole point of it. */
#saver h1 { font-size: 68px; line-height: 1.2; }
#saver .saver-sub {
  /* One size everywhere. 23px is the middle of the six that were in use, and
     comfortably over the project's 18px text floor. */
  font-family: var(--sans); font-weight: 300;
  font-size: 23px; line-height: 1.55;
  color: var(--ink-dim); margin-top: .6em;
}
#saver .weave-rule { width: min(420px, 34vw); margin: .7em auto; }

#saver .pulse {
  margin-top: 1.5em; display: inline-block;
  /* One short verb, and small, on every station. This was a slab carrying a
     whole sentence in two scripts, which competed with the title for the eye
     on an attract screen whose job is to show the cloth. A single word needs a
     fraction of that, and the glow does the inviting the size used to do
     badly.

     The glow breathes in and out rather than throwing a ring outward: a kiosk
     in a quiet gallery should invite, not blink. */
  font-family: var(--serif); font-size: min(max(24px, 1.1em), 3.2vh);
  background: var(--gold); color: var(--bg); border: 0;
  border-radius: 999px; padding: .52em 1.9em; opacity: 1;
  animation: pulse 3.4s ease-in-out infinite;
  box-shadow: 0 0 18px rgba(201,162,75,.28);
}
@keyframes pulse {
  0%, 100% { box-shadow: 0 0 14px rgba(201,162,75,.22), 0 0 3px rgba(201,162,75,.18); }
  50%      { box-shadow: 0 0 34px rgba(201,162,75,.62), 0 0 10px rgba(201,162,75,.34); }
}
@media (prefers-reduced-motion: reduce) {
  #saver .pulse { animation: none; box-shadow: 0 0 20px rgba(201,162,75,.34); }
}

/* ---------- media player ---------- */
/* Back and Home sit at the two top corners of a playing film: a visitor who
   opened a sixteen minute documentary by mistake could previously only find a
   bare cross, which says nothing about where it leads. Both carry a word. */
.playerbar .pbtn { flex: 0 0 auto; }

/* Volume reads as one instrument: quieter, level, louder, grouped and spaced
   apart from the transport controls so the two are never confused mid-film. */
/* flex: 0 0 auto so the group can never be squeezed into its own controls, and
   never grows into slack that belongs to the transport cluster beside it. */
.playerbar .volgroup {
  flex: 0 0 auto;
  display: flex; align-items: center; gap: .5em;
  margin-left: .4em; padding-left: 1.1em;
  border-left: 1px solid rgba(201,162,75,.22);
}
.playerbar .vollevel { width: 5.5em; height: 6px; border-radius: 999px;
                       background: rgba(201,162,75,.2); overflow: hidden; }
.playerbar .volfill { height: 100%; width: 100%; background: var(--gold);
                      border-radius: 999px; transition: width .18s ease-out; }

.player { position: absolute; inset: 0; background: #000; z-index: 60;
          display: none; flex-direction: column; }
.player.active { display: flex; }
.player video { flex: 1; width: 100%; object-fit: contain; background: #000; }
/* The scrim has to carry the controls over ANY frame the film happens to be
   paused on. A gradient that only darkened the very bottom left gold buttons
   sitting on a white title card at barely any contrast, which is exactly the
   frame a visitor pauses on. This reaches a near solid ground behind the row
   itself and fades upward into the picture. */
.playerbar { display: flex; align-items: center; gap: 1em; padding: 1.15em 1.4em;
             background: linear-gradient(to top, rgba(10,8,6,.95) 0%,
                                         rgba(10,8,6,.88) 62%, rgba(10,8,6,0) 100%);
             position: absolute; left: 0; right: 0; bottom: 0; z-index: 61;
             transition: opacity .4s; }
/* The framework's own bar wraps onto two lines: the scrubber takes the top
   line to itself, the transport and volume controls sit below it. See the note
   in kiosk.js build() for why. The break is forced by giving .seek a basis of
   100%, which no following item can share a line with, rather than by tuning
   widths until they happen to overflow: an arithmetic break would come apart
   the moment a button or a longer time readout is added.
   .pb-stack, not .playerbar, because oral-history builds its own single-row
   .playerbar in its own markup and must not be restacked. */
/* The column gap comes in from 1em because the controls line has to hold eight
   buttons, two time readouts and the volume group, and at 1em it cleared a
   1080px panel by under 2px: a hair more type in the duration and the volume
   group dropped to a third line. */
.playerbar.pb-stack { flex-wrap: wrap; row-gap: .55em; column-gap: .75em; }
.pb-stack > .seek { flex: 1 0 100%; order: -1; }
.playerbar.faded { opacity: 0; pointer-events: none; }
.playerbar .time { font-family: var(--sans); font-size: .9em; color: var(--ink-dim);
                   min-width: 5.5em; text-align: center; }
/* The visible bar is 8px, but the touch target must be a full 64px or a
   standing visitor cannot reliably grab it. Three apps had rebuilt this
   locally for exactly that reason. Transparent padding does the work while
   background-clip keeps the bar itself thin. */
/* content-box is deliberate: the global reset sets border-box, under which
   `height: 8px` would INCLUDE the 56px of padding and the whole control would
   measure 56px, under the 60px touch minimum. With content-box the maths is
   8 + 28 + 28 = 64px of target around an 8px visible bar. */
/* min-width as well as flex-grow: grow alone is exactly what failed before,
   because a flex item cannot grow into space that is already spoken for. The
   floor is the backstop that keeps the scrub target grabbable. */
.seek { flex: 1 1 auto; min-width: 240px; box-sizing: content-box;
        height: 8px; border-radius: 4px;
        background: #3a332a; background-clip: content-box;
        position: relative; pointer-events: auto; padding: 28px 0; }
.seek .fill { position: absolute; top: 28px; bottom: 28px; left: 0;
              background: var(--gold); border-radius: 4px; }
.pbtn { width: 64px; height: 64px; border-radius: 50%; border: 1.5px solid rgba(201,162,75,.55);
        background: rgba(10,8,6,.66); color: var(--gold); font-size: 1.5em;
        display: flex; align-items: center; justify-content: center; pointer-events: auto; }
.pbtn:active { background: var(--gold); color: #000; }
/* Play is the one control a visitor reaches for without looking, so it is the
   only filled button on the bar. */
.playerbar .pbtn.play { background: rgba(201,162,75,.16); border-color: var(--gold); }
.playerbar .pbtn.play .ico { width: 34px; height: 34px; }


/* ---------- on-screen keyboard ---------- */
.osk { display: grid; gap: .5em; padding: 1em; }
.osk .row { display: flex; gap: .5em; justify-content: center; }
.osk key, .osk .key {
  min-width: 64px; height: 64px; border-radius: 10px;
  background: var(--bg-card); border: 1px solid #3a332a; color: var(--ink);
  font-family: var(--sans); font-size: 1.25em;
  display: inline-flex; align-items: center; justify-content: center;
  pointer-events: auto; flex: 0 1 64px;
}
.osk .key.wide { flex: 2 1 140px; }
.osk .key:active { background: var(--gold); color: var(--bg); }
/* One optional honest line under the keys. The keys are Latin, and a visitor
   reading the kiosk in Hindi deserves to be told that before they start
   hunting for their own script, not after. */
.osk .osk-note { text-align: center; padding: .3em 1em 0;
                 font-family: var(--sans); font-size: .95em; color: var(--ink-dim); }

/* ---------- portrait tuning ---------- */
@media (orientation: portrait) {
  .grid { grid-template-columns: repeat(2, 1fr); }
  h1, .h1 { font-size: 2.6em; }
}

.kiosk-settings {
  position: fixed; top: .8em; left: .8em; z-index: 110;
  display: inline-flex; align-items: center; gap: .5em;
  font-size: clamp(18px, 1.15vw, 23px);
}

/* Both the cluster AND the pill drop under a running film. The pill keeps its
   own z-index because it is a stated contract several app tests measure: it
   must read above the screensaver's 100 normally and below the player's 60
   while a film is up. */
.langpill { position: relative; z-index: 110; }
body:has(.player.active) .kiosk-settings,
body:has(.player.active) .langpill { z-index: 50; }


/* One shell, shared, so the two controls read as a pair. It stays a real
   object with a filled ground and an edge, because it has to be findable
   against a busy woven screensaver, but the SELECTION inside it no longer
   needs to be a solid gold block to be unambiguous. */
.langpill {
  display: inline-flex; align-items: center; gap: .12em;
  padding: .16em; border-radius: 999px;
  border: 1px solid rgba(201,162,75,.32);
  background: rgba(22, 19, 15, .9);
  opacity: 1;
}

/* Full touch size, not the old 44px. This is not a rarely-used preference:
   for a visitor who reads Hindi far more comfortably than English it is the
   most important control on the screen, and it was the smallest thing on it.

   The selected half is gold ink on a faint gold wash inside a hairline gold
   ring; the other is offered in --ink-dim. Both clear AA on this ground
   (gold 6.4:1, ink-dim 8.0:1). What the old solid fill bought was
   unambiguity, and the warning that went with it stands: a segmented pair
   whose bright half is the one you are NOT on is genuinely ambiguous, and a
   museum visitor gets one guess. Three signals now carry that instead of one
   block of gold: the ink, the wash and the weight all move together. */
/* The one rule --touch-lang governs. v2.2.0, 15 Aug: was var(--touch), 64px.
   min-width holds at 76px so the two words still read as buttons rather than as
   a cramped toggle, and font-size stays at 1em: the pill got shorter, not
   smaller in type. */
.langpill button {
  min-height: var(--touch-lang); min-width: 76px; padding: .2em .85em;
  border: 0; border-radius: 999px; background: transparent;
  color: var(--ink-dim); font-family: var(--sans); font-size: 1em;
  letter-spacing: .01em; font-weight: 400;
  pointer-events: auto; cursor: none;
}
.langpill button.on {
  color: var(--gold); background: rgba(201,162,75,.15);
  box-shadow: inset 0 0 0 1px rgba(201,162,75,.5);
}
.langpill button.on { font-weight: 600; }
.langpill button:active { background: rgba(201,162,75,.3); }

/* The ramp. The smallest step still measures over 18px, the gallery's floor,
   because a control nobody can read is not a legibility control. */
/* EB Garamond carries no Devanagari, so अ would come up as an empty box on a
   sealed offline kiosk. Same reason as every other rule in this file that
   names the bundled face by hand. */
/* Devanagari needs its own faces: EB Garamond and Lato carry no Devanagari
   glyphs at all, so Hindi would render as empty boxes on an offline kiosk. */
@font-face { font-family: 'Noto Sans Devanagari';
  src: url('fonts/NotoSansDevanagari-Regular.ttf'); font-weight: 400; font-display: block; }
@font-face { font-family: 'Noto Serif Devanagari';
  src: url('fonts/NotoSerifDevanagari-Regular.ttf'); font-weight: 400; font-display: block; }

html[lang="hi"] body { font-family: 'Noto Sans Devanagari', var(--sans); }
html[lang="hi"] h1, html[lang="hi"] h2, html[lang="hi"] h3,
html[lang="hi"] .h1, html[lang="hi"] .h2, html[lang="hi"] .h3,
html[lang="hi"] .btn, html[lang="hi"] .homepill {
  font-family: 'Noto Serif Devanagari', var(--serif);
}
/* Devanagari needs more leading than the Latin display faces do, and the
   tighter a title is set the more obviously it needs it. A line of Devanagari
   carries matras and a chandrabindu ABOVE the shirorekha and conjunct tails
   BELOW the baseline, so the 1.06 that makes EB Garamond read as a gallery
   title shears the top off करघे and drops the next line onto its matras. This
   is worst at the LARGE type step in Hindi, which is exactly the combination a
   visitor who needs the control will be in. */
html[lang="hi"] h1, html[lang="hi"] .h1 { line-height: 1.3; }
html[lang="hi"] h2, html[lang="hi"] .h2 { line-height: 1.38; }
html[lang="hi"] h3, html[lang="hi"] .h3 { line-height: 1.5; }
[data-i18n-both] > [lang="hi"] { line-height: 1.4; }

/* the toggle's own labels always render in their own script */
.langpill button[lang="hi"] { font-family: 'Noto Sans Devanagari', var(--sans); }
.langpill button[lang="en"] { font-family: var(--sans); }

/* These framework components name a face of their own, so the html[lang="hi"]
   rule on <body> above never reaches them and their Hindi fell through to
   whatever generic sans the machine happened to have. On a sealed offline
   kiosk that is not a guarantee, it is a coin toss between a wrong face and a
   row of empty boxes. Naming the bundled face here settles it for all six. */
html[lang="hi"] .body, html[lang="hi"] .chip, html[lang="hi"] .badge-state,
html[lang="hi"] .osk .key, html[lang="hi"] .osk .osk-note,
html[lang="hi"] .playerbar .time {
  font-family: 'Noto Sans Devanagari', var(--sans);
}

/* ---------- bilingual blocks (D1) ----------
   data-i18n-both renders the English and the Hindi string stacked, both always
   on screen, neither swapped by the toggle. An attract screen that has to be
   switched into Hindi has already lost the visitor who needed switching.
   Because both scripts are painted at the same time, neither span can rely on
   the html[lang] switch to pick its face: each names its own. */
[data-i18n-both] > span { display: block; }
[data-i18n-both] > [lang="hi"] { margin-top: .12em; }
[data-i18n-both] > [lang="en"] { font-family: var(--sans); }
[data-i18n-both] > [lang="hi"] { font-family: 'Noto Sans Devanagari', var(--sans); }
h1[data-i18n-both] > [lang="en"], .h1[data-i18n-both] > [lang="en"] {
  font-family: var(--display);
}
h2[data-i18n-both] > [lang="en"], .h2[data-i18n-both] > [lang="en"],
h3[data-i18n-both] > [lang="en"], .h3[data-i18n-both] > [lang="en"],
.pulse[data-i18n-both] > [lang="en"], .btn[data-i18n-both] > [lang="en"] {
  font-family: var(--serif);
}
h1[data-i18n-both] > [lang="hi"], .h1[data-i18n-both] > [lang="hi"],
h2[data-i18n-both] > [lang="hi"], .h2[data-i18n-both] > [lang="hi"],
h3[data-i18n-both] > [lang="hi"], .h3[data-i18n-both] > [lang="hi"],
.pulse[data-i18n-both] > [lang="hi"], .btn[data-i18n-both] > [lang="hi"] {
  font-family: 'Noto Serif Devanagari', var(--serif);
}

/* The Cultre mark, the quiet corner credit on the screensaver. */
/* THE WEAVE CAPTION PLATE IS GONE, 16 Aug, by Vivek's ruling: "the large sized
   banarsi brocade etc info text to be removed from everywhere. i think its an
   overkill and not needed anymore."
   Removed with it: .weave-labels, .weave-label and every .wl- child rule
   (wl-name, wl-meta, wl-place, wl-technique, wl-thread) plus the .wl-tight
   variant and the Hindi letter-spacing overrides. Nothing in framework/ or in
   any of the six apps creates those nodes any more, so these rules matched
   nothing; they are deleted rather than left as a stylesheet describing a
   component that no longer exists.
   The cloth now teaches through the WOVEN fact layer alone, which is drawn on
   the canvas and has no CSS. */

/* The dimming sits on the mark, not on the whole credit. It used to live here,
   at .72 on the container, and that is what dragged "Powered by" under AA and
   got the words hidden altogether. Holding the type at full strength and
   dimming only the image keeps the credit quiet without making it unreadable,
   so the words can stay. */
.saver-credit {
  position: absolute; right: 1.4em; bottom: 1.1em; z-index: 3;
  display: flex; align-items: center; gap: .7em;
  pointer-events: none;
}
/* The words are shown, not hidden: a lockup on its own reads as decoration,
   whereas "Powered by Cultre" says who built the thing. Set as a credit line,
   small tracked caps in the dim ink, which measures 7.9:1 on the gallery
   ground.

   Set light and italic, in the cream ink, over a scrim.

   The colour is NOT the lever here, and the palette table will mislead anyone
   who assumes it is. test_contrast_audit.py resolves a ground from the CSS
   ancestor chain, so for this one rule it measures against flat --bg and
   reports a ground that is not there: the credit sits over the weave-saver
   CANVAS, whose bands cycle from a near-white kanchipuram highlight through
   gold and indigo to a deep crimson. Measured against the cloth actually
   underneath, EVERY candidate ink fails, the best of them at 1.85:1. There is
   no fixed colour that clears AA against both #fff3d0 and #b8123c.

   So the scrim does the work, and the ink is then chosen for contrast against
   the scrim rather than against any band. It lays a near-opaque dark ground
   under the glyphs without drawing a plate. The weave caption plate used to
   carry the identical four-layer shadow for exactly this reason over exactly
   this canvas; that plate was retired on 16 Aug, so this is now the only place
   the technique survives and there is no longer a second copy to keep in step.
   On that ground the cream reads 16:1, which is the highest of anything in the
   palette and the reason it is used here in preference to a brand colour.

   If a brand colour is ever wanted back here, use #e35d3f and NOT --madder.
   The scrim is a blur, not a plate, so the ground under a glyph runs from
   about #080604 at the core out to the cloth at the edge, and the two
   terracottas behave very differently across that falloff. #e35d3f holds
   5.69:1 down to 5.21:1 and stays clear the whole way. --madder starts at
   4.66:1 and crosses BELOW the 4.5:1 an AA body run needs before the falloff
   is done, reaching 4.37:1 and then 4.26:1. It is the palette's terracotta and
   therefore the token a reader reaches for first, which is precisely why it is
   named here as the wrong one.
   The floor is 18px, not 14. The scrim answers CONTRAST, and contrast is not
   the only thing that makes a run of type readable at standing distance: this
   project holds every run to 18px and a credit is not exempt. Note that the
   audit cannot arbitrate this one for you in either direction, since it can
   neither see the scrim nor measure against the cloth.

   This paragraph was appended AFTER the previous comment's closing marker and
   without one of its own, so five lines of prose sat in the stylesheet as bare
   CSS. The parser read them as the start of a selector, swallowed the
   `.saver-credit span {` below into it, and dropped the whole rule on every
   one of the six kiosks. The credit then rendered completely unstyled, and
   the 18px this paragraph argues for never applied at all. Nothing in the
   suite catches it: the checks assert the credit does not OVERLAP the attract
   copy, which an unstyled credit passes comfortably. If you add another note
   here, open it with a comment marker. */
.saver-credit span {
  /* v2.1, 15 Aug: small caps, Vivek's ask, "small caps except first letters
     of words". text-transform: uppercase made every letter the same height,
     which is what he was correcting. The pair below is what actually produces
     it: capitalize raises the first letter of each word to a full capital,
     and font-variant renders the remaining lowercase letters as small
     capitals. "Powered by" therefore sets as P-owered B-y with the caps
     leading. Tracking eased from .1em to .06em: small caps are already open,
     and the old value was compensating for uppercase. */
  font-family: var(--sans); font-weight: 400; font-style: italic;
  font-size: max(18px, .46em);
  letter-spacing: .06em; text-transform: capitalize;
  font-variant-caps: small-caps; font-variant: small-caps;
  color: var(--ink);
  text-shadow: 0 0 3px rgba(8,6,4,.98), 0 1px 5px rgba(8,6,4,.98),
               0 0 18px rgba(8,6,4,.92), 0 0 34px rgba(8,6,4,.75);
}
/* Held at 3.1em on purpose. It was cut to 1.86em and then to 1.12em by two
   passes that each read the other's output as the starting point, which is how
   a 40% reduction turned into 64%. The mark the shrinking was actually aimed at
   is the one WOVEN INTO THE CLOTH, in weave-saver.js: this is the corner
   credit, and it stays the size it has always been. The .72 sits on the image
   alone, never on the container, or it drags "Powered by" back under AA.

   3.1em is VIVEK'S OWN RULING, given at 07:52 on 13 August when he was shown
   that three sessions had this block at 1.86em, 3.1em and 4em at the same
   time, each acting on a defensible reading of one instruction. It is an
   ABSOLUTE value, not a step. Do not nudge it from whatever it currently says
   without a fresh instruction, because reading the previous value as the
   starting point is the exact mechanism that turned a 40% cut into 64% and
   then bounced it back past where it began.

   The mark is left in its shipped WHITE and deliberately not tinted. A tint
   was solved and fitted exactly, and then thrown away: recolouring the lockup
   can only move it AWAY from maximum contrast, since white is already the
   furthest thing from the dark scrim behind it, and it would have pinned a
   numerically solved filter chain to a palette token that cannot follow it.

   The two shadows are the mark's half of the scrim on the span above, same
   purpose over the same moving cloth: without them the white lockup vanishes
   into the kanchipuram band, which is the palest thing the loom weaves.
   The old opacity .72 is gone. It was dimming the mark toward the very cloth
   it has to be legible against, which is the opposite of what it needs. */
.saver-credit img {
  /* v2.1, 15 Aug: the Cultre mark is now RED, Vivek's ask, and the SIZE IS
     UNCHANGED at 3.1em, which is still his 13 August absolute ruling.

     The colour is #E8395F: Cultre's brand red (#BD264A on the logo artwork)
     lifted for a dark ground. Measured, not guessed. No red clears 3:1
     against all nine bands at once, because one of those bands is the
     Banarasi wine ground and a red mark on a red cloth cannot be made to
     contrast. So the ink is chosen against the SCRIM rather than against the
     cloth, exactly as the "Powered by" text above it already is, and the scrim
     is what carries it over the ivory Jamdani and the wine Banarasi alike.

     v2.2.0, 16 Aug, VIVEK'S RULING: "make the cultre logo less bright but try
     keeping the contrast. i like matte earthy red colors." The mark moved from
     the bright pink-red to #A85C48, a matte red-orange earth (Design's
     candidate J; #96513F is the held fallback if he still reads it as bright).
     MEASURED AGAINST THE SCRIM, which is the ground the mark is actually read
     against and not the cloth: 5.00:1 before, 4.13:1 now, against a 3:1 floor
     for a logo. So it is dimmer as asked and still clear of the floor, which is
     the "keeping the contrast" half. Measuring against the CLOTH instead nearly
     produced a no-op recommendation; the ground was the whole question, and
     this block had already answered it.

     Recoloured as a real asset (cultre-lockup-red.png, generated from the
     white lockup keeping its own alpha and modelling) rather than by a CSS
     filter chain: the six stations include old Android WebViews, and a
     baked pixel is the one approach that cannot render differently on them.

     Shadows deepened from the two-layer stack to three: red is a mid
     luminance ink, so it needs more scrim under it than white did to stay
     clearly visible, which is the "high contrast" half of the ask. */
  height: 3.1em; width: auto; display: block;
  filter: drop-shadow(0 1px 4px rgba(8,6,4,1))
          drop-shadow(0 0 10px rgba(8,6,4,.95))
          drop-shadow(0 0 24px rgba(8,6,4,.8));
}

/* v2, 14 Aug: the Handloom Haat programme mark. Top-right, mirroring the
   Cultre credit's bottom-right so the two never sit near each other or read
   as one lockup. This is a NEW element, independent of the 3.1em ruling
   above, which is specifically about the Cultre credit and stays untouched.
   Own size, own shadow stack: v2 gave the cloth nine distinct vivid palettes
   including an ivory Jamdani band that is nearly white, so the white mark
   needs a stronger scrim than the credit's two-layer one to stay legible
   against the palest band the loom now weaves. Held at 2.6em, smaller than
   the credit, because this is an identifying mark in a corner, not the
   thing being sold; --logo-height exists on the token list for the same
   reason the credit calls out that its own value is not a relative step. */
.saver-mark {
  position: absolute; right: 1.4em; top: 1.1em; z-index: 3;
  pointer-events: none;
}
.saver-mark img {
  /* v2, 14 Aug, third pass: 2.6em raised to 4.2em, VIVEK'S DIRECT ASK
     ("handloom haat logo should be bigger so that it could be seen"). Like
     the credit's 3.1em, treat this as an absolute, not a step to scale from.

     v2.1, 15 Aug: 4.2em raised 50% to 6.3em, Vivek's ask, and it applies to
     the ATTRACT state only. The programme mark should own the screen while
     nobody is using the station, and get out of the way the moment somebody
     does: see .brand-watermarks below, which carries the same mark at the
     old 4.2em and half the opacity once a visitor is interacting. Both
     numbers are absolutes, not steps; 6.3 = 4.2 x 1.5 is recorded here so a
     future pass does not compound another 50% onto the enlarged value, which
     is exactly how an earlier 40% reduction turned into 64%. */
  height: 6.3em; width: auto; display: block;
  transition: height .5s ease, opacity .5s ease;
  filter: drop-shadow(0 1px 4px rgba(8,6,4,.98))
          drop-shadow(0 0 10px rgba(8,6,4,.9))
          drop-shadow(0 0 22px rgba(8,6,4,.7));
}

/* v2, 14 Aug, third pass: the two identities as WATERMARKS on every screen,
   not only the attract loop. Vivek's ask: the Handloom Haat mark "like a
   watermark along with cultre logo always on screen". Framework-level, so
   one edit covers all six apps.
   BOTTOM corners, NOT top-right: the Home pill owns x 1748-1898 at the top
   right of app screens, and anything placed there is safe only while it
   stays scoped to the saver (Agent 1's measurement, 14 Aug, in CLAUDE.md).
   pointer-events: none on the whole layer, so it cannot take the wake tap or
   any control tap by construction; the wake-listener bug fixed today in
   kiosk.js is the proof of why that matters for anything painted over a
   screen. Cultre holds its 0.6 ratio to the programme mark here too, per the
   standing ruling that Cultre never stands at the marks' own scale.
   Hidden two ways: .wm-hidden while the saver is up (weave-saver.js toggles
   it; the saver carries its own full-strength pair, and both at once doubled
   the Cultre mark in the same corner), and via body.hh-player-open during
   video, because a watermark over a playing film is the one place a
   watermark stops being quiet. z-index 50: over screen content, under the
   player (60), its bar (61), the Home pill (55 fixed above it visually but
   opposite corner anyway) and the saver (100). */
.brand-watermarks {
  position: fixed; inset: 0; pointer-events: none; z-index: 50;
}
.brand-watermarks img {
  /* v2, 14 Aug, fourth pass: .42 with a two-layer shadow read as a SOLID
     mark, and on documentary and oral-history it sat on top of card
     captions a visitor needs ("16 MIN · KASHMIR / Pashmina", "2
     RECORDINGS"): the 6-agent fan-out called both collisions out. A
     watermark that costs caption legibility fails visitor-first, so this is
     now true ghost weight: .26, one faint tight shadow for edge definition
     only. Text underneath stays readable through it; the mark reads on
     every ground the moment you look for it, which is what a watermark is. */
  position: absolute; display: block; width: auto; opacity: .26;
  filter: drop-shadow(0 1px 3px rgba(8,6,4,.8));
}
.brand-watermarks .wm-hh { left: .7em; bottom: .6em; height: 4.6em; }
.brand-watermarks .wm-cultre { right: .8em; bottom: .6em; height: 2.8em; }
/* v2.1, 15 Aug: the interacting state of the top-right programme mark. The
   attract screen shows it at 6.3em (.saver-mark img); the moment a visitor
   starts using the station the saver hides and this takes over at 4.2em, the
   size it was before v2.1, at half the opacity of the attract mark. That is
   Vivek's "reduce it back and fade it by 50%", read literally: same corner,
   previous size, half strength.

   Opacity .5 rather than the .26 the other two watermarks use. Those are
   ghosts behind content; this one is a deliberate step down from a mark the
   visitor was just looking at, and dropping it to .26 read as the logo
   vanishing rather than receding.

   TOP OFFSET, and this is the trap: the Home pill also lives top-right, at
   x 1748-1898 on a 1920 panel, and anything else put there is safe only
   while it stays scoped to the saver. This one is NOT scoped to the saver,
   so it is seated BELOW the pill's band rather than in it. pointer-events
   stays none on the whole layer, so even where they overlap visually the
   pill still takes every touch. */
.brand-watermarks .wm-hh-top {
  right: 1.4em; top: calc(1.1em + var(--touch) + .6em);
  height: 4.2em; opacity: .5;
}
/* Portrait panels are narrower, so the pill and the mark are closer together;
   a little more clearance keeps them from reading as one stacked object. */
@media (orientation: portrait) {
  .brand-watermarks .wm-hh-top { top: calc(1.1em + var(--touch) + 1.2em); }
}
.brand-watermarks.wm-hidden,
body.hh-player-open .brand-watermarks { display: none; }

/* decorative weave divider */
.weave-rule { height: 5px; margin: .8em 0;
  background: repeating-linear-gradient(90deg, var(--gold) 0 14px, transparent 14px 22px,
              var(--madder) 22px 36px, transparent 36px 44px);
  opacity: .5; border: 0; }

