/* Panel skin comes from ui/panel.css; here only what belongs to the homepage build. */
@import url("/ascii/ui/panel.css");

/* The page sits on the flat paper colour of the sketch; the site's own gradient layer is off. */
html { overflow-x: clip; }
.ab-on { background: #f8f6fc; }
.ab-on #gradient-bg { display: none; }

.ab-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* ---------- hero band ---------- */
/* Runs from the top of the page to the bottom of the hero block, across the whole viewport. */
.ab-on .hero-cv { position: relative; }
.ab-on .hero-cv > .hero-content { position: relative; z-index: 1; }
.ab-on .hero-cv .avatar { display: none; } /* the portrait is the band itself now */
.ab-hero {
  position: absolute;
  top: var(--ab-hero-top, 0);
  bottom: 0;
  left: 50%;
  width: 100vw;
  transform: translateX(-50%);
  overflow: hidden;
  pointer-events: none;
  background: #431a51;
}
/* The chrome moustache of the sketch, set at the foot of the page as its sign-off: the last thing
   the reader passes before the footer's small print. It is a picture rather than a glyph, so it
   keeps its polish instead of being flattened to the text colour around it. */
.ab-on .ab-mark {
  display: block;
  /* Sits in the contact grid, so it keeps the measure column's left edge — the same edge the
     paragraphs and the LinkedIn button start on — at every width. */
  grid-column: 2 / -1;
  justify-self: start;
  width: clamp(80px, 6.5vw, 120px);
  aspect-ratio: 900 / 259; /* the file's own pixel ratio, so `contain` leaves no slack to centre */
  margin: clamp(40px, 5vw, 72px) 0 clamp(8px, 1.5vw, 24px);
  background: url(/logos/moustache.webp) left center / contain no-repeat;
}
/* Below the breakpoint the copy column collapses onto the full grid; the mark follows it. */
@media (max-width: 720px) {
  .ab-on .ab-mark { grid-column: 1 / -1; }
}
/* It arrives the way every other block on the page arrives — lifted into place once it is reached.
   The page's own reveal only observes what was in the document when its script ran, and this mark
   is mounted afterwards, so it carries the same motion under its own trigger. */
@keyframes ab-mark-in {
  from { opacity: 0; transform: translateY(var(--reveal-y, 22px)); }
  to { opacity: 1; transform: none; }
}
html.js-reveal .ab-on .ab-mark { opacity: 0; }
html.js-reveal .ab-on .ab-mark.in { animation: ab-mark-in .85s cubic-bezier(.22, .61, .36, 1) both; }
@media (prefers-reduced-motion: reduce) {
  html.js-reveal .ab-on .ab-mark { opacity: 1; animation: none; }
}


/* White copy over the band, as in the sketch. */
.ab-on .hero-cv :not(.ascii-ui):not(.ascii-ui *) {
  color: #fff !important;
  border-color: rgb(255 255 255 / 0.45) !important;
}
/* The name is the one word of the headline set apart, in the light step of the plate's own purple. */
/* The blanket white above counts its two :not() arguments towards its weight, so the exception has
   to carry one of its own to outrank it. */
.ab-on .hero-cv h1 .name:not(.ascii-ui) { color: #d9cdee !important; }
.ab-on .hero-cv svg:not(.ascii-ui svg) [stroke]:not([stroke="none"]) { stroke: #fff !important; }
.ab-on .hero-cv svg:not(.ascii-ui svg) [fill]:not([fill="none"]) { fill: #fff !important; }

/* ---------- case plaques ---------- */
.ab-on .selected-cases { overflow: visible; }
.ab-bubble { position: relative; }
.ab-bubble .stack .capsule { background: none; }
.ab-plaque {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

/* First four: a band from the bubble's inner edge out past the viewport edge, 380 px tall.
   The artwork scales with it from the inner edge, so the row's layout does not move. */
.ab-wide { --ab-scale: 1; --ab-grow: 0px; --ab-r: 8px; --ab-edge-gap: clamp(16px, 4.2vw, 72px); }
.ab-wide .ab-plaque {
  left: calc(-1 * var(--ab-left, 0px));
  right: calc(-1 * var(--ab-right, 0px));
  top: calc(-1 * var(--ab-grow));
  bottom: calc(-1 * var(--ab-grow));
}
.ab-wide[data-ab-side="left"] .ab-plaque { border-radius: 0 var(--ab-r) var(--ab-r) 0; }
.ab-wide[data-ab-side="right"] .ab-plaque { border-radius: var(--ab-r) 0 0 var(--ab-r); }
.ab-wide > .stack { scale: var(--ab-scale); }
/* The card steps back from the screen edge the band bleeds to, by --ab-edge-gap: the plaque above
   keeps bleeding all the way to that edge unchanged, so the step leaves a strip of the band's own
   colour as the margin the card now floats in, instead of shrinking or moving the band itself.
   `translate` is an individual transform property, so it composes with `scale` above and with the
   `left`/`right`/`width` the v2 stylesheet gives `.stack` at wider breakpoints, rather than
   replacing either. */
.ab-wide[data-ab-side="left"] > .stack { transform-origin: 100% 50%; translate: var(--ab-edge-gap) 0; }
.ab-wide[data-ab-side="right"] > .stack { transform-origin: 0 50%; translate: calc(-1 * var(--ab-edge-gap)) 0; }
/* The capsule stops being a pill: it clips the artwork to the band — open towards the bleed side. */
.ab-wide .stack .capsule { border-radius: 0; overflow: visible; }
.ab-wide[data-ab-side="left"] .capsule { clip-path: inset(0 0 0 -100vw round 0 calc(var(--ab-r) / var(--ab-scale)) calc(var(--ab-r) / var(--ab-scale)) 0); }
.ab-wide[data-ab-side="right"] .capsule { clip-path: inset(0 -100vw 0 0 round calc(var(--ab-r) / var(--ab-scale)) 0 0 calc(var(--ab-r) / var(--ab-scale))); }
.ab-wide[data-ab-side="both"] .capsule { clip-path: inset(0 -100vw); }
/* Здесь было гашение круглой фоновой плашки uula-ds (layers/uula-ds/plate.webp, 1072x708, RGBA:
   тёмный овал с синей сферой справа) — visibility:hidden по :has([data-assemble="plate"]), с
   расчётом, что picture backdrop заменён шейдерной полосой. Правило и давало вспышку. PillStack по
   окончании сборки снимает ровно этот атрибут (в скомпилированном скрипте: f=a=>{...;a.
   removeAttribute("data-assemble")}), селектор переставал совпадать, и слой открывался скачком.
   Замер opacity на каждый rAF от входа ряда во вьюпорт: собственная анимация плашки шла с 237 по
   887мс (0 -> 1, duration .75, delay .18, кривая .22,.61,.36,1 — тот же такт, что у sidebar и
   right-block), всё это время слой стоял hidden, а на 1137мс, через 250мс после того как ряд уже
   собрался, становился visible сразу при opacity 1.00. Гашение снято: плашка видна с начала входа
   и доигрывает свою долю такта вместе с остальным содержимым. Покой ряда не меняется — плашка и
   до правки в нём была видна, просто приходила рывком. Правило было общим на всю широкую
   раскладку, но совпадало только с uula-ds: прямого .layer с data-assemble="plate" нет больше ни
   у одного из семи стеков (обход DOM: yaniverse — три part, student — один part, yotaphone — два
   phone, у mercuryo/uula/postal прямых слоёв нет), поэтому соседние ряды его удаление не трогает. */
/* Glow layers are gradients sized to the old capsule: on a band they end in a visible seam.
   Matched by class, not by a substring of the style attribute. The attribute selector below used to
   be the whole rule and it silently stopped matching: parallax.js writes --par-y on every .layer
   through element.style.setProperty, and the CSSOM re-serialises the attribute it touches with a
   space after each colon, so "--bg:radial-gradient" becomes "--bg: radial-gradient" the moment the
   page scrolls. The glow came back, and what it painted was the pale frame inside the yellow band:
   a white ellipse at 0.3 alpha that is flat across its first third and is cut off square by the
   layer's own box at the bubble's edges, which is why it read as a rectangle with a lit top rather
   than as light. The class is on all three glow layers in the markup; the attribute selector stays
   beside it only for a layer that might carry --bg without the class. */
.ab-wide .capsule > .layer.glow,
.ab-wide .capsule > .layer[style*="--bg:radial-gradient"],
.ab-wide .capsule > .layer[style*="--bg: radial-gradient"] { visibility: hidden; }
/* Same for the inset vignette: it outlines the old capsule box inside the band. */
.ab-wide .capsule > .vignette { display: none; }
/* Artwork drawn as a full-bleed picture for the old capsule: its outer edge dissolves into the band. */
.ab-fade[data-ab-side="left"] .capsule { -webkit-mask-image: linear-gradient(to right, transparent, #000 38%); mask-image: linear-gradient(to right, transparent, #000 38%); }
.ab-fade[data-ab-side="right"] .capsule { -webkit-mask-image: linear-gradient(to left, transparent, #000 38%); mask-image: linear-gradient(to left, transparent, #000 38%); }
/* The exercise sheet carries a logo strip that the capsule's round corner used to hide. */
.ab-wide .layer:has(> .anim > img[src*="student/exercise"]), .ab-wide .layer:has(> img[src*="student/exercise"]) { -webkit-mask-image: linear-gradient(to bottom, transparent 10%, #000 12%); mask-image: linear-gradient(to bottom, transparent 10%, #000 12%); }
/* The turtle scene and the octopus used to be two fixed boxes here, a flat 120% of the stack. They
   are sized off the measured band now, by the `wideArt` entry on the `student` block — the factor
   has to follow the band's own width and the band is only known at measure() time, so a pair of
   !important boxes could only ever be right at one viewport width. What is left in the stylesheet
   is the geometry of the layer mountWideArt() builds, below, and the two rules under this one. */
/* The capsule's own #a3e0c7 fill is a solid rectangle the width of the stack, and the shader draws
   glyphs across the whole band: the fill covered them for exactly the stack's width and the plaque
   read as a smooth rectangle sitting on a textured field, which is the box George was pointing at.
   Dropping it lets the field run edge to edge with the artwork riding on it — the same fix, and for
   the same reason, as the yaniverse capsule further down. The water in illustration.webp measures
   #a3e0c7, the paper the shader is drawing on, so what the artwork's feathered edge dissolves into
   is the same colour it is and only the texture changes across it. */
.ab-wide [data-pill-stack="student"] .capsule { --fill: transparent !important; }
/* Плашка с кабинетом издателя (cabinet.webp, 800×640, соотношение 1.25) была намеренно увеличена на
   20% от центра — по аналогии с черепахой illustration.webp, у которой то же соотношение сторон. Но
   у капсулы здесь ровно то же 1.25 на каждом брейкпоинте (475×380 на 1440px, 351×281 на 390px и
   так далее), поэтому дефолтные --l:0%/--t:0%/--w:100%/--h:100% из разметки уже укладывают скрин в
   капсулу без единого пикселя обрезки. Зум тянул картинку на 20% дальше границ капсулы (у которой
   overflow:visible), и получившийся прямоугольник вылезал за серую плашку-фон — это и есть баг:
   «интерфейсы обрезаны и торчат за фон», а не «недостаточно заполняют пилюлю». У черепахи такой зум
   безопасен, потому что сцена декоративная и её край не обязан быть целым; здесь же зум режет
   читаемый текст интерфейса прямо по словам. Убираю переопределение целиком — дефолт из разметки
   даёт точное совпадение без правки самой разметки. */
/* Второй, отдельный источник того же бага: у любого широкого кейса с открытой стороной справа (как
   у postal, data-ab-side="right") .stack получает translate на -1*--ab-edge-gap (около 60px на
   1440px, 16px на мобильном, см. правило выше на этой открытой/закрытой стороне) — это специально
   отодвигает капсулу от того края экрана, куда бежит шейдерная полоса, оставляя запас у открытого
   правого края. Побочный эффект: тот же сдвиг тащит закрытый (левый) край капсулы за пределы
   плашки-фона .ab-plaque, которая свою левую границу не двигает — получается зазор шириной
   --ab-edge-gap, где скринкол интерфейса рисуется поверх обычного фона страницы, а не текстурной
   полосы. Именно это давало «прямоугольник вылезает за фон» даже после снятия зума выше. Механика
   общая для всех широких кейсов и трогать её саму нельзя — student/yaniverse/uula-ds сейчас правят
   другие агенты, — поэтому глушу её точечно для postal через ту же переменную: --ab-edge-gap:0
   прямо на .stack перекрывает наследуемое значение (без !important, без правки общего правила), и
   капсула встаёт левым краем ровно на границу плашки на всех проверенных ширинах. */
.ab-wide [data-pill-stack="postal"] { --ab-edge-gap: 0px; }
/* Коллаж кабинета теперь идёт в край полосы (см. wideArt у postal в ascii-blocks.js), поэтому
   капсула ему ничего не режет — тот же приём, что у student выше: рамку держит ab-band-mask,
   которая знает обе прямоугольные рамки, а капсула знает только свою. */
.ab-wide [data-pill-stack="postal"] .capsule {
  border-radius: 0 !important;
  clip-path: none !important;
  overflow: visible !important;
  /* И маску тоже. `fade: true` вешает на капсулу `.ab-fade[data-ab-side="right"] .capsule`
     с градиентом to left — он писался под старый cabinet.webp, у которого внешний край висел
     посреди плашки и его надо было растворить. Теперь растворять нечего: коллаж кончается
     ровно на линиях полосы. А главное — маска задаёт область отрисовки по своему border-box,
     то есть режет потомков по капсуле 475x380 сильнее, чем overflow, который мы только что
     сняли: замер показывал слой 720x900, а на экране картинка обрывалась по старой капсуле. */
  -webkit-mask-image: none !important;
  mask-image: none !important;
}
/* Рез по краю плашки держит сам слой: его рамка и есть плашка (замер на 1440: слой 720x900 в
   (720,0), ровно .ab-plaque), а ab-band-mask здесь вырождается в 0/0/100/100 — картинка кончается
   на линиях полосы, резать нечего. В покое img совпадает со слоем пиксель в пиксель, но вступление
   data-assemble даёт --in-scale:1.07, и на входе кадр вылезал за плашку на 25px по горизонтали и
   60px снизу — на голый фон страницы, потому что .ab-plaque слою не родитель и его overflow сюда
   не достаёт. */
.ab-wide [data-pill-stack="postal"] .capsule .layer.ab-art-wide { overflow: hidden; }
/* Библиотека учителя пришла по тому же контракту, что и кабинет: catalog-wide.webp — 1440x1800,
   то есть ровно плашка 720x900 при 2x, и идёт в обрез по всем четырём краям (wideArt с теми же
   box/grow/keep/bandCut). Без этого блока капсула резала кадр дважды, и оба реза мерились на 1440:
   clip-path `inset(0 -100vw 0 0)` открывает только ту сторону, куда уходит полоса, и держит
   остальные три — от слоя 720x900 в (720,0) оставались строки 260…640, ровно высота капсулы 380 на
   её собственном месте в плашке; поверх этого маска `.ab-fade[data-ab-side="right"] .capsule`
   (градиент to left, прозрачный у правого края, #000 на 38%) задаёт область отрисовки по своему
   border-box 475x380 и срезала правую треть кадра, от x≈415 плашки и дальше. Выше и ниже полосы
   вместо каталога оставалось поле глифов шейдера. Геометрия слоя при этом была верной и совпадала
   с postal до числа (--l/--t/--w/--h, ab-band-mask вырожденная 0/0/100/100) — резала капсула.
   Слою возвращается overflow по той же причине, что и у postal: data-assemble даёт --in-scale:1.07,
   и на входе кадр выходил бы за плашку, которой он не потомок. */
.ab-wide [data-pill-stack="schoolbook-teacher"] .capsule {
  border-radius: 0 !important;
  clip-path: none !important;
  overflow: visible !important;
  -webkit-mask-image: none !important;
  mask-image: none !important;
}
.ab-wide [data-pill-stack="schoolbook-teacher"] .capsule .layer.ab-art-wide { overflow: hidden; }
/* Третий случай той же механики, теперь на uula-ds, и с обратным знаком: композиция стояла не по
   центру полосы, а прижатой к шву с текстовой колонкой. Замер на 1440: полоса [0,720], её центр
   360; видимая композиция (plate + sidebar + right-block + круг ::before) занимала [177,814] с
   центром 495 — на 135px правее. Справа круг заходил за шов на 94px, под заголовок «Design System
   for UULA», слева оставалось 177px пустой полосы. На 1920 разъезд вдвое больше: центр +179px,
   заход за шов 146px, пустого поля слева 213px. Правлю не проценты слоёв (они подобраны под сцену
   1070x706 и держат sidebar/right-block/icons вместе), а тот же --ab-edge-gap, что у postal и uula
   выше: он и есть горизонтальный сдвиг .stack.
   Формула вместо лестницы брейкпоинтов. Центр композиции лежит на постоянной доле ширины стека —
   замеры дают 0.4*stackW на всех ширинах до 1680 (768: 127.2/320, 900: 152/380, 1280: 190/475), а
   плашка ровно 50vw, её центр 25vw; отсюда сдвиг = 0.6*stackW - 25vw. Проценты в translate
   считаются от нетрансформированной ширины .stack — проверено подстановкой 100%: сдвиг вышел 400px
   при rect 475 и scale 1.1875, — поэтому долю домножаю на --ab-scale, иначе ниже 900px, где scale
   равен 1, формула и та же доля разошлись бы на 18.75%. Контроль: 768 -> 0, 900 -> +3, 1280 -> -35,
   1440 -> -75, 1920 -> -107, 2200 -> -177px. */
.ab-wide [data-pill-stack="uula-ds"] { --ab-edge-gap: calc(60% * var(--ab-scale) - 25vw); }
/* С 1680 медиазапрос ниже растягивает sidebar и right-block до 188.05% ширины слоя, центр
   композиции уезжает влево внутри стека — замер на 1680/1920/2200 даёт 102.4px при stackW 475,
   то есть 0.2156 вместо 0.4, — и доля в той же формуле другая. */
@media (min-width: 1680px) {
  .ab-wide [data-pill-stack="uula-ds"] { --ab-edge-gap: calc(78.44% * var(--ab-scale) - 25vw); }
}
/* Встречный сдвиг на .icons убран. Он гасил положительный --ab-edge-gap, чтобы колонка иконок —
   сосед .capsule внутри .stack, а не её потомок, — не уезжала на текст: замер на 1920 до него
   давал .copy с x=960 и .icon-art на 1003–1024, то есть пять иконок из шести на заголовке и
   метриках. Теперь сдвиг отрицательный и сам уводит композицию от шва, а встречный ему знак
   разорвал бы сцену: иконки остались бы на старом месте, в 75–180px правее капсулы. Иконки едут
   вместе со .stack, запас до .copy после правки: 768 -> 5px, 900 -> 4px, 1280 -> 43px,
   1440 -> 82px, 1920 -> 114px, 2200 -> 184px. */
@media (min-width: 900px) {
  .ab-wide { --ab-scale: 1.1875; --ab-grow: 30px; }
}
/* Четвёртый случай той же механики, теперь на uula. Сдвиг --ab-edge-gap отодвигает .stack от того
   края экрана, куда бежит полоса, но .ab-plaque своей границы не двигает, и закрытый край уезжает за
   плашку — ровно то же, что уже вылечено у postal выше. У uula добавка: mountDiagram() вставляет
   .ab-diagram через .ab-plaque.after(root), то есть соседом .stack, а не внутрь неё, и диаграмма
   получает собственный такой же translate (правила .ab-wide[data-ab-side] > .ab-diagram ниже) от той
   же переменной. Поэтому приём postal — объявить переменную на самой .stack — сюда не годится:
   кастомные свойства наследуются только вниз, до соседа они не дойдут, и диаграмма продолжила бы
   вылезать. Замер на 900: --ab-edge-gap там 37.8px (4.2vw), и на снимке жёлтая и красная полосы
   процесса уходят левее точечной бумаги плашки на голый фон страницы ровно на эту величину. Сама
   ширина — непрерывная функция вьюпорта, на 900 она не начинается; на 900 скачком растёт --ab-scale
   (правило выше), из-за чего уже существующий вылет становится заметнее. Объявляю переменную уровнем
   выше, на самом пузыре, — она достаётся обоим детям одинаково. */
.ab-wide:has(> .stack[data-pill-stack="uula"]) { --ab-edge-gap: 0px; }
/* 1962:44178: on the wide sketch the design-system plaques are 1.4x the old pill artwork and the
   lesson list leaves the bubble into the band. Narrower screens have no band to hold it. */
/* The new export has no transparent scene padding. Seat it in the sidebar's original
   480px-wide slot at (48,112) in the 1070×706 scene; keep both layer coordinate systems intact. */
.ab-wide [data-pill-stack="uula-ds"] .layer img[src*="uula-ds/sidebar"] {
  position: absolute;
  left: 4.485981%;
  top: 15.864023%;
  width: 44.859813%;
  height: 84.985836%;
}
@media (min-width: 1680px) {
  .ab-wide [data-pill-stack="uula-ds"] .layer:has(img[src*="uula-ds/sidebar"]) { --l: -72.46% !important; --t: -16.63% !important; --w: 188.05% !important; --h: 155.1% !important; }
  .ab-wide [data-pill-stack="uula-ds"] .layer:has(img[src*="uula-ds/right-block"]) { --l: -72.46% !important; --t: -12.75% !important; --w: 188.05% !important; --h: 155.1% !important; }
}
/* Круговая композиция для UULA Design System (Figma: Case row — UULA Design System 1943:45710,
   Bubble 1943:45711, component strip 1943:45715). В скетче у этой плашки нет булевой маски-капсулы,
   как у Mercuryo или YotaPhone: Bubble почти квадратный (400×380 против 400×320 у остальных
   кейсов), а круглый характер сцене даёт мягкое radial-gradient свечение (эллипсы примерно 349×334
   и 420×400) позади неё. Гасить это свечение здесь нельзя без противоречия правилу выше — .ab-wide
   .capsule > .layer.glow {visibility:hidden} гасит свечение во всей широкой раскладке, потому что
   шейдер полосы заменил старый picture backdrop сразу для всех семи кейсов, и точечное исключение
   для одного из них вернуло бы тот самый шов на стыке с полосой, из-за которого правило и
   появилось. Доступный рычаг, который не трогает ни фон, ни уже подобранные процентные геометрии
   sidebar/right-block/icons, — сам вырез капсулы: --ab-r объявляется прямо на .capsule этого стека
   и перекрывает наследуемое значение без !important, а намеренно огромное число (500px) браузер сам
   ограничивает половиной высоты капсулы — получается настоящий полукруг на закрытой (правой)
   стороне на любой ширине, а не приближение, которое пришлось бы пересчитывать под каждый
   брейкпоинт. */
.ab-wide [data-pill-stack="uula-ds"] .capsule { --ab-r: 500px; }
/* Круг, а не полукруг на срезе капсулы. В скетче у этой плашки нет булевой маски: круглый
   характер сцене дают два мягких radial-gradient свечения внутри Bubble fill (1946:46485,
   349×334, и 1943:45713, 420×400) — экран приложения и колонка иконок сидят на них, а не на
   плашке-пилюле. Это не .layer.glow: тот класс гасится правилом чуть выше во всей широкой
   раскладке, но проверка по разметке (index.html) показала, что у самого uula-ds .glow-слоя
   нет вовсе — он есть только у yaniverse/student/yotaphone, так что правило здесь и раньше
   ничего не гасило, а имя класса всё равно оставляю в стороне, чтобы не зацепиться за тот же
   селектор случайно. Псевдоэлемент на .capsule красится после её (прозрачного в .ab-on) фона и
   до всех .layer-слоёв, потому что они идут в разметке позже родным порядком — то есть под
   экраном и иконками, над шейдерной полосой. Форма — круг независимо от того, каких пропорций
   сам .stack: aspect-ratio:1 и высота в процентах, ширина auto, так что смена bubble-w/bubble-h
   этой плашке не нужна и процентные геометрии sidebar/right-block/icons трогать не пришлось.
   Радиус свечения гасится в прозрачность на 62% от края круга — с запасом до собственной рамки,
   поэтому квадратного обреза, из-за которого появилось правило про .glow, здесь физически нет:
   у прямоугольника есть край, у диска, догоревшего до нуля раньше своей рамки, — нет. Цвет —
   замер по скетчу (case-row-uula-ds.png): фон там ровный тёмно-синий #020235, свечение в
   правой нижней четверти Bubble поднимает его на +24R/+48G/+49B, отсюда мягкий синий, не
   выдуманный. */
.ab-wide [data-pill-stack="uula-ds"] .capsule::before {
  content: "";
  position: absolute;
  aspect-ratio: 1;
  height: 108%;
  width: auto;
  left: 60%;
  top: 50%;
  translate: -50% -50%;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(70, 110, 210, 0.4) 0%, rgba(70, 110, 210, 0.22) 38%, rgba(70, 110, 210, 0) 62%);
  pointer-events: none;
}
.ab-on .case-row .copy { position: relative; z-index: 2; }

/* ---------- per-block trigger ---------- */
.ab-gear {
  position: absolute;
  z-index: 5;
  right: 10px;
  bottom: 10px;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 50%;
  backdrop-filter: blur(12px);
  cursor: pointer;
  opacity: 0.55;
  transition: opacity var(--dur) var(--ease);
}
.ab-gear:hover, .ab-gear:focus-visible, .ab-active > .ab-gear { opacity: 1; }
.ab-gear svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; }
.ab-wide[data-ab-side="left"] > .ab-gear { bottom: calc(10px - var(--ab-grow)); }
.ab-wide[data-ab-side="right"] > .ab-gear { bottom: calc(10px - var(--ab-grow)); right: auto; left: 10px; }
.hero-cv > .ab-gear { right: 0; bottom: 12px; }

/* ---------- panel ---------- */
/* Hero: the switch between the portrait layer and the field under it. */
.ab-layers { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; margin: 0 var(--s4) var(--s2); }
.ab-layers[hidden] { display: none; }
.ab-pick { flex: 1; min-width: 0; margin-right: var(--s2); font-weight: 600; }
.ab-body[hidden] { display: none; }
.ascii-site-panel {
  position: fixed;
  top: var(--s3);
  right: var(--s3);
  bottom: var(--s3);
  z-index: 60;
  display: flex;
  flex-direction: column;
  width: 300px;
  color: var(--text);
  font: 400 var(--fs-m)/1.45 var(--font);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  backdrop-filter: blur(12px);
  transition: transform 220ms var(--ease), opacity 220ms var(--ease);
}

.panel-controls {
  flex: 1;
  overflow-y: auto;
  padding: 0 var(--s4) var(--s3);
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
}

.ascii-site-panel .panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--s3) var(--s3) var(--s2) var(--s4);
}
.ascii-site-panel h1 {
  margin: 0;
  font-size: var(--fs-m);
  font-weight: 600;
  letter-spacing: 0.02em;
}
.ascii-site-panel .status {
  margin: 0 var(--s4) var(--s2);
  min-height: 1.45em;
  color: var(--muted);
  font-size: var(--fs-xs);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ascii-site-panel .panel-foot {
  padding: var(--s3) var(--s4);
  border-top: 1px solid var(--line);
}
.ascii-site-panel .actions {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--s2);
}
.ascii-site-panel .actions .btn { padding: 0; }
.ascii-site-panel .keys {
  margin: var(--s2) 0 0;
  color: var(--muted);
  font-size: var(--fs-xs);
}

.ascii-site-show {
  position: fixed;
  top: var(--s3);
  right: var(--s3);
  z-index: 60;
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms var(--ease), background-color var(--dur) var(--ease);
}

.ascii-panel-hidden .ascii-site-panel {
  transform: translateX(calc(100% + var(--s4)));
  opacity: 0;
  pointer-events: none;
}
.ascii-panel-hidden .ascii-site-show {
  opacity: 1;
  pointer-events: auto;
}

@media (max-width: 640px) {
  .ascii-site-panel {
    top: auto;
    left: var(--s2);
    right: var(--s2);
    bottom: var(--s2);
    width: auto;
    max-height: 55vh;
  }
  .ascii-panel-hidden .ascii-site-panel { transform: translateY(calc(100% + var(--s4))); }
}

/* ---------- whole card is the link ---------- */
.ab-link-row { cursor: pointer; }
@media (hover: hover) {
  .ab-link-row .ab-plaque { transition: filter 240ms ease; }
  /* Brightness only. `saturate()` used to ride along here, and it is the one filter that cannot be
     applied to these plaques: a saturation matrix amplifies the chroma distance between two colours,
     so the yaniverse ground (a yellow-green raster) and the band it is matched to (`#ffcc02`, an
     orange-yellow) pulled apart under it and the horizon seam we just removed came back on hover.
     Brightness multiplies every channel by the same number, so colours that match at rest still
     match while the pointer is over the card. */
  .ab-link-row:hover .ab-plaque { filter: brightness(1.05); }
}

/* ---------- animated artwork ---------- */
.ab-video {
  position: absolute;
  z-index: 1;
  top: calc(-1 * var(--ab-grow, 0px));
  bottom: calc(-1 * var(--ab-grow, 0px));
  height: calc(100% + 2 * var(--ab-grow, 0px));
  aspect-ratio: 829 / 380;
  max-width: none;
  object-fit: cover;
  pointer-events: none;
  mix-blend-mode: multiply;
}
/* Same edge-gap step-back as .stack above, so the video card sits the same distance off the screen
   edge as every other case's artwork regardless of which content type it carries. */
.ab-wide[data-ab-side="left"] > .ab-video { right: 0; translate: var(--ab-edge-gap) 0; }
.ab-wide[data-ab-side="right"] > .ab-video { left: 0; translate: calc(-1 * var(--ab-edge-gap)) 0; }
.ab-wide[data-ab-side="both"] > .ab-video { left: 50%; translate: -50% 0; }
.ab-has-video > .stack { visibility: hidden; }

/* ---------- UULA process diagram, revealed step by step ---------- */
.ab-diagram {
  position: absolute;
  z-index: 1;
  top: 50%;
  width: min(660px, 100% + var(--ab-left, 0px) + var(--ab-right, 0px) - 16px);
  pointer-events: none;
}
/* Same edge-gap step-back as .stack above, folded into the vertical centring this element already
   carries: `translate`'s x component is the gap, its y component is the existing -50%. */
.ab-wide[data-ab-side="left"] > .ab-diagram { right: 0; translate: var(--ab-edge-gap) -50%; }
.ab-wide[data-ab-side="right"] > .ab-diagram { left: 0; translate: calc(-1 * var(--ab-edge-gap)) -50%; }
.ab-has-diagram > .stack { visibility: hidden; }
/* What that rule is hiding is the second copy of the drawing: diagram.webp inside the capsule and
   the stepped picture built above it are the same diagram, and the stack's is the one without the
   steps. The paper discs in the same capsule are not part of the drawing — they are the field it
   sits on — so they come back, and they come back here rather than on the shader source because a
   tint over the glyphs keeps them readable where a second set of blobs in the source would move
   which glyph is chosen. Their own boxes are in stack percentages, so they ride the stack's scale
   and the capsule's clip along with everything else in it. */
.ab-has-diagram > .stack .paper-disc { visibility: visible; }
.ab-dg-block {
  position: absolute;
  background-repeat: no-repeat;
  opacity: 0;
  transform: translateY(10px) scale(0.94);
  transition: opacity 320ms ease, transform 520ms cubic-bezier(0.2, 0.9, 0.25, 1.15);
  transition-delay: calc(var(--i) * 55ms);
}
.ab-dg-full {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 700ms ease;
  transition-delay: calc(var(--i) * 55ms + 200ms);
}
.ab-in .ab-dg-block { opacity: 1; transform: none; }
.ab-in .ab-dg-full { opacity: 1; }
/* Ribbed veil of the laid-paper relief: laid lines across, chain lines down. */
.ab-laid {
  z-index: 2;
  background:
    repeating-linear-gradient(to right, transparent 0 calc(var(--ab-laid-chain) - 3px), rgb(255 255 255 / 0.16) calc(var(--ab-laid-chain) - 1.5px), transparent var(--ab-laid-chain)),
    repeating-linear-gradient(to bottom, rgb(255 255 255 / 0.1) 0, rgb(0 0 0 / 0.1) calc(var(--ab-laid-pitch) / 2), rgb(255 255 255 / 0.1) var(--ab-laid-pitch));
}
@media (prefers-reduced-motion: reduce) {
  .ab-dg-block, .ab-dg-full { transition: none; }
}

/* ---------- phone: same rhythm as the desktop ----------
   Rows stack, but the artwork keeps alternating sides: each band leaves from its own viewport edge
   and stops with a rounded end one gutter short of the other, the copy sits under it. */
@media (max-width: 720px) {
  .ab-on .case-row.flip .bubble { margin-left: auto; }
  .ab-on .case-row .bubble { max-width: min(350px, 100% - 8px); }
  .ab-wide { --ab-r: 6px; }
  .ab-diagram { width: calc(100% + var(--ab-left, 0px) + var(--ab-right, 0px) - 12px); }
}

/* Yaniverse: the two fades that used to live here are gone with the crop they were written for.
   hero.webp (820x508) is the pill-sized cut of the scene and it ends inside the walk — its rightmost
   boy stands at source x~=680-820, flush against the file's own edge with no spare pixels past him —
   so on a band its right edge is a cut through a child, and the 96% stop could only choose between
   showing that cut and taking a bite out of him. mountWideArt() now swaps in the full scene on every
   band (scene-wide.webp, 1618x560, every figure whole), so `.layer:not(.ab-art-wide)` no longer
   matches anything inside a `.ab-wide` capsule and the two rules had nothing left to fade. The
   left-side rule also had to go for a second reason: it matched `.capsule img`, which is the full
   scene's own img as well, at a higher specificity than the rule below — on a left band it would
   have overridden the feather with a fixed one aimed at a raster that is no longer there.

   What the full scene does need is an edge that stops short of the band. Measured on the file rather
   than looked at: its top is fully transparent, the ground comes in on a hard antialiased horizon
   (alpha 0 to ~150 in one pixel) at y 0.54-0.62 depending on the column, and below y 0.70 alpha is
   >=251 across the whole width — so the horizon ends in two straight vertical cuts on the file's own
   boundaries. Against the band those cuts read as a box, the ground measuring ~#f4e035 against the
   band's #ffcc02, greener and lighter. Where the scene reaches the band's own edge that cut is
   invisible, because the band ends there too; where the growth cap stops it short of the edge, the
   cut lands in the middle of the colour and has to be dissolved. So the stops are variables and
   mountWideArt() sets them per edge: 0% for an edge that lands on the band's, art.feather for one
   that stops short. The figures occupy x 0.320-0.779 of the file and the portal is placed over the
   ground pool at x 0.06-0.26, so a 12% feather off the right reaches no ink at all and one off the
   left crosses only the pool and the portal's foot. The bottom is left alone: the artwork's foot is
   the band's foot, so there is no seam there to soften, and a fade from below would eat the
   children's feet. */
/* The ground wash mountWideArt() builds for a scene that carries one. Its box is written out here
   because the markup's own .layer rule is scoped to the Astro component's data attribute and does
   not reach an element created in script; the gradient itself is a variable, because its stops are
   the scene's height mapped into the band and both move with the band's width. */
.ab-wide .capsule > .layer.ab-art-ground {
  position: absolute;
  left: var(--l);
  top: var(--t);
  width: var(--w);
  height: var(--h);
  display: block;
  background-image: var(--ab-ground, none);
}

.ab-wide .capsule .layer.ab-art-wide img {
  -webkit-mask-image: linear-gradient(to right, transparent 0%, #000 var(--ab-art-fade-l, 0%), #000 calc(100% - var(--ab-art-fade-r, 0%)), transparent 100%);
  mask-image: linear-gradient(to right, transparent 0%, #000 var(--ab-art-fade-l, 0%), #000 calc(100% - var(--ab-art-fade-r, 0%)), transparent 100%);
}

/* Прямоугольник полосы, наложенный на прямоугольник слоя: по градиенту на ось, пересечение которых
   и есть то, что разрешено рисовать. Четыре пары стопов приходят из ascii-blocks.js, потому что обе
   рамки меняются на каждом ресайзе, а какая из них снаружи — зависит от раскладки. Пара с нулевым
   зазором (l0 = l1) даёт жёсткий рез ровно по линии полосы: там край цвета и так нарисован, и
   картинка, кончающаяся на нём, нового края не добавляет. Пара с зазором (t1 - t0) растворяет тот
   край, который до полосы не дотянулся и потому висит посреди цвета. Значения по умолчанию —
   0/0/100/100, то есть маска без маски: слой без класса ab-band-mask ничего не теряет. */
.ab-wide .capsule .layer.ab-art-wide.ab-band-mask img,
.ab-wide .layer.ab-band-mask img {
  -webkit-mask-image:
    linear-gradient(to right, transparent var(--ab-m-l0, 0%), #000 var(--ab-m-l1, 0%), #000 var(--ab-m-r1, 100%), transparent var(--ab-m-r0, 100%)),
    linear-gradient(to bottom, transparent var(--ab-m-t0, 0%), #000 var(--ab-m-t1, 0%), #000 var(--ab-m-b1, 100%), transparent var(--ab-m-b0, 100%));
  mask-image:
    linear-gradient(to right, transparent var(--ab-m-l0, 0%), #000 var(--ab-m-l1, 0%), #000 var(--ab-m-r1, 100%), transparent var(--ab-m-r0, 100%)),
    linear-gradient(to bottom, transparent var(--ab-m-t0, 0%), #000 var(--ab-m-t1, 0%), #000 var(--ab-m-b1, 100%), transparent var(--ab-m-b0, 100%));
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* Сцена ученика идёт в край полосы, поэтому капсула ей ничего не режет: ни скруглений, ни
   clip-path, ни overflow. Рамку держит маска ab-band-mask выше — она знает обе прямоугольные рамки
   и поэтому режет по той, что внутри, тогда как капсула знает только свою. */
.ab-wide [data-pill-stack="student"] .capsule {
  border-radius: 0 !important;
  clip-path: none !important;
  overflow: visible !important;
}
/* No student-specific mask here any more: illustration.webp is itself a full opaque rectangle,
   painted in the shader's own #a3e0c7 out to every edge, and turtle-mask.svg was discarding that
   real backdrop in favour of a turtle-only cutout, which is what left flat, unlit gaps between the
   turtle and the octopus (including the patch behind the lightbulb) with nothing but the shader's
   own sparse texture behind them. Dropping the silhouette lets that painted backdrop show. Цена
   отказа — сам прямоугольник: файл непрозрачен до последнего пикселя, и в широкой раскладке его
   верх и низ повисали посреди цвета. Этим занимается маска по прямоугольнику полосы выше
   (ab-band-mask, стопы из ascii-blocks.js), а не силуэт: она режет по краю цвета там, где картинка
   за него выходит, и растворяет край там, где картинка до него не дошла. */
.ab-wide [data-pill-stack="yaniverse"] .capsule .layer.ab-art-wide img {
  -webkit-mask: url('/home/layers/yaniverse/figures-mask.svg') 0 0 / 100% 100% no-repeat !important;
  mask: url('/home/layers/yaniverse/figures-mask.svg') 0 0 / 100% 100% no-repeat !important;
}

/* Нижний рез капсулы, открытый под хвост подложки. Общее правило выше (`inset(0 -100vw 0 0)`)
   открывает только ту сторону, куда уходит полоса, и держит остальные три; на этом стеке удержанная
   нижняя линия совпадает с собственной непрозрачной подошвой scene-wide.webp, и растворять край
   подложке было негде — два реза ложились на одну линию и читались как нарисованная граница поперёк
   всей плашки. Figma `2201:44312` ведёт `ab-art-ground` на 144px ниже стека (524 против 380), и эти
   144px — затухание. Больше сюда никто не дотягивается: ходьба, портал и устройство висят на стеке
   снаружи капсулы, а сама сцена кончается на той же линии, что и раньше. Скругления капсулы в этой
   раскладке не видны — левый край стека уходит за границу плашки, — но пары сохранены, чтобы
   правило отличалось от общего только дном. */
.ab-wide[data-ab-side="left"] [data-pill-stack="yaniverse"] .capsule {
  clip-path: inset(0 0 -100vw -100vw round 0 calc(var(--ab-r) / var(--ab-scale)) calc(var(--ab-r) / var(--ab-scale)) 0);
}
.ab-wide[data-ab-side="right"] [data-pill-stack="yaniverse"] .capsule {
  clip-path: inset(0 -100vw -100vw 0 round calc(var(--ab-r) / var(--ab-scale)) 0 0 calc(var(--ab-r) / var(--ab-scale)));
}
.ab-wide[data-ab-side="both"] [data-pill-stack="yaniverse"] .capsule {
  clip-path: inset(0 -100vw -100vw);
}

/* Reverts two failed attempts at this edge, a shallow top scrim and then a gradient --fill, both of
   which read the artwork wrong. Sampling scene-wide.webp rather than looking at it: its alpha is 0
   for the top tenth and only reaches 255 past the 60% line, so the pale top is transparency, not
   white paint, and any --fill painted behind it shows straight through. Its opaque ground measures
   #f4df32, against the band's #ffcc02 — greener and lighter. That mismatch was only half the
   rectangle. The other half is that the band is not a flat colour at all: the shader draws glyphs
   across it, and a solid capsule under the artwork covers them, so the plaque box read as a paler,
   smoother rectangle on a textured field — two tells at once, which is why softening the colour
   never helped. Dropping the fill removes the box itself: the field runs unbroken edge to edge and
   the artwork rides on it, fading in on its own alpha ramp with no rectangle to hide. The remaining
   difference between the artwork's ground and the band's yellow is now a gradual crossfade down the
   artwork's own ramp instead of a hard edge. */
.ab-wide [data-pill-stack="yaniverse"] .capsule { --fill: transparent !important; }

/* The figures are set like the case paragraph beside them — same family, same Medium, same -0.2px
   tracking, same proportional figures, and now the same 20px body the descriptor is set in (measured
   20px/25.4px, weight 500). The sketch node 1943:45684 puts them at 36px in Regular; on the page that
   turned the row into a second headline competing with the case title at 28px, and the figure was
   being read before the name of the case it belongs to. At the descriptor's size the row reads as the
   case's own voice rather than as a display number, and the 14px label under it stays the quieter of
   the two. Nothing here is gated on the width of the screen: 20px fits the narrowest column the copy
   ever gets, so the three measured ramps that used to hold 36px inside it are gone. */
body.ab-on .metrics .metric .value {
  font-weight: 500;
  font-size: 20px;
  line-height: 1.27;
  white-space: nowrap;
}
/* Columns hug their value, so "45% → 75%+" stays on one line; a label wraps at 240 px. Neither
   declaration reads the width of the screen, so neither is gated on the wide sketch any more: under
   the gate the row was dropping back to the page's own three equal columns (two below 720 px), which
   is a different layout, not a narrower one. `auto` tracks take the value's whole line when there is
   room for it and shrink only when there is not, so the columns hold the line by themselves and the
   nowrap below stays where it can be honoured — above 900 px it changes nothing. */
body.ab-on .case-row .metrics { grid-template-columns: repeat(3, auto); justify-content: start; }
body.ab-on .case-row .metrics .metric { max-width: 240px; }

/* The lensed glass below is hung on `.ab-glass`, and that class is put on by a script that returns
   early unless the engine can use an SVG filter as a backdrop — in practice Chrome. On every other
   engine, and so on most phones, the button kept nothing but its outline and read as a flat dark
   rectangle on the band. What follows is the part of the material that needs no filter: a translucent
   fill, a blur of the field behind it and a lit top edge. It is not gated on width, because the band
   is just as dark at 390 px as at 1440 px. Chrome still gets the lens — `.ab-glass` below and the
   script's own inline `backdrop-filter` both outrank this, and they carry the same fill and edge, so
   nothing there moves. */
.ab-on .hero-cv .ds-btn {
  background: rgb(255 255 255 / 0.04);
  -webkit-backdrop-filter: blur(12px) saturate(1.3);
  backdrop-filter: blur(12px) saturate(1.3);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.4), inset 0 -1px 0 rgb(255 255 255 / 0.12);
}
/* Hero buttons as clear glass: the rim bends the glyphs behind it, a hairline catches the light. No frost. */
.ab-on .hero-cv .ds-btn.ab-glass {
  background: rgb(255 255 255 / 0.04);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.4), inset 0 -1px 0 rgb(255 255 255 / 0.12);
}
.ab-on .hero-cv .ds-btn { transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 80ms ease; }
/* The lens is shared by desktop and mobile. Never override its inline SVG filter with
   a mobile blur: that erases the ASCII detail that makes the refraction visible. */
.ab-on .hero-cv .ds-btn.ab-glass-sampled {
  position: relative;
  overflow: hidden;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
.ab-glass-surface {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  pointer-events: none;
}
.ab-glass-sampled > .lbl, .ab-glass-sampled > .ic { position: relative; z-index: 1; }
@media (hover: hover) {
  .ab-on .hero-cv .ds-btn:hover {
    background: rgb(255 255 255 / 0.16);
    border-color: #fff !important;
    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.7), inset 0 -1px 0 rgb(255 255 255 / 0.25), 0 6px 24px rgb(0 0 0 / 0.18);
  }
}
.ab-on .hero-cv .ds-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.ab-on .hero-cv .ds-btn:active { background: rgb(255 255 255 / 0.24); transform: translateY(1px); box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.5); }

/* Key point as a footnote under the metrics (sketch 1943:45744): one 11px voice, the heading only
   bolder. Nothing here needs recalculating for a narrow screen — 11 px is already the smallest step
   on the page, and the 452 px cap only ever binds where the measure is wider than that — so the gate
   is gone and every width takes the sketch's set instead of falling back to the page's own 18/13 px
   pair, which reads as a different component rather than the same one on a smaller screen. */
body.ab-on .case-row .points { margin-top: 18px; row-gap: 12px; }
/* The bullets are set as the metric label beside them: at 11px they were the smallest type on the
   page by three points and read as a footnote to the case rather than as the part of it that says
   what was actually done. Sized up a step, to the page's 16px body token (sm 14 -> base 16), the
   bullet reads as the case's own voice instead of a caption on it. The line-height stays at 1.3 —
   the ratio, not the pixel value, is what keeps the block's rhythm; at 16px it resolves to ~20.8px,
   still tighter than the descriptor's leading above it. The measure is widened by the same 16/14
   ratio (452px -> 516px) so the longer glyphs get back the characters-per-line the narrower column
   was giving them at 14px, instead of wrapping a line sooner than the text above it does. The lead-in
   keeps its bold — it is the sentence the bullet is about, and the label it now matches has no
   lead-in to borrow a treatment from. */
body.ab-on .case-row .point strong { font-size: 16px; line-height: 1.3; font-weight: 700; color: #000; }
body.ab-on .case-row .point span { margin-top: 2px; max-width: 516px; font-size: 16px; line-height: 1.3; font-weight: 400; color: #000; }

/* Один голос на всю страницу: любой текст на светлом — чёрный, акцент снят.
   Hero не трогаем: он лежит на тёмном шейдере и остаётся белым.
   `.se-ui` — интерфейс внутри плашки Schoolbook: это иллюстрация кейса, а не текст страницы,
   и цвета в ней (погашенный шаг степпера, кнопка) несут состояние.
   `.slide-viewer` — просмотрщик слайдов: он лежит поверх страницы на тёмном поле, как и hero,
   и чёрная подпись на нём не читается. */
/* `.vt-morph-host` — блок ряда на время перехода главная <-> кейс: BaseLayout перекрашивает
   его текст в белый, пока тёмная панель уезжает. Исключение здесь, а не `!important` там:
   у этого селектора восемь классов, и перебить его специфичностью можно только нечитаемым
   нагромождением повторов. Класс живёт только внутри перехода, вне него вид не меняется. */
/* `.case-panel` — раскрывающаяся панель кейса на главной: внутри неё живёт собственная тёмная
   тема кейса (case-panel-dark.css), и чёрный текст по чёрному фону её убивает. Панель
   исключена целиком, как `.hero-cv` и `.slide-viewer`: цвета внутри задаёт её тема. */
/* `:not(.theme-dark)` — страница кейса. Класс `ab-on` ставит монтаж блоков, а он идёт и на
   кейсе, где такая же плашка; при SPA-переходе класс приезжает на тёмную страницу со второго
   круга и красит весь её текст в чёрный по чёрному (воспроизведено на прод-сборке: круг 1 даёт
   белый, круги 2-3 — rgb(0,0,0)). Чёрный текст осмыслен только на светлой бумаге главной. */
body.ab-on:not(.theme-dark) *:not(.ascii-ui):not(.ascii-ui *):not(.hero-cv):not(.hero-cv *):not(.se-ui):not(.se-ui *):not(.slide-viewer):not(.slide-viewer *):not(.vt-morph-host):not(.vt-morph-host *):not(.case-panel):not(.case-panel *) {
  color: #000 !important;
}

/* Как набрано в эскизе (1943:45667): название организации — не мелкая подпись, а вторая строка
   самого заголовка. Тот же Black 28px и 110%, трекинг нулевой, зазор 4px, начинается с «for».
   В эскизе один кейс из четырёх набран мелко — беру набор, общий для трёх остальных. */
body.ab-on .copy h3 {
  letter-spacing: 0;
  line-height: 1.1;
}
body.ab-on .copy p.company {
  margin: var(--space-1, 4px) 0 0;
  font-size: var(--text-tile, 28px);
  font-weight: 900;
  line-height: 1.1;
  letter-spacing: 0;
}
body.ab-on .copy p.company::before {
  content: 'for ';
}

/* Исключение для UULA design system. Его заголовок вдвое короче остальных, и разбитый по эскизу на
   две строки он читается как оборванный: вторая строка втрое короче первой. Только у этого кейса
   заголовок и организация текут одним абзацем — «Design System for UULA» встаёт в строку там, где
   помещается, и переносится само там, где нет, то есть на узком экране. Остальные шесть кейсов
   сохраняют набор из эскиза: у них заголовок длинный, и одной строкой он всё равно не встанет. */
body.ab-on .case-row:has([data-pill-stack="uula-ds"]) .copy h3,
body.ab-on .case-row:has([data-pill-stack="uula-ds"]) .copy p.company {
  display: inline;
}

/* Both lines of the title come down to the descriptor's own 20px, keeping Black. The case column now
   has one body size — title, descriptor and figures all at 20px — and the title is told apart by
   weight rather than by size, so the block reads as one paragraph of the same voice with its first
   two lines pressed. The 1.1 leading above stays: at 20px Black the lines are shorter, and the looser
   1.27 the descriptor runs at would open a gap the title does not need. */
/* The `.case-row` in the first selector is there to outweigh the site's own `h3[data-astro-cid-…]`,
   which carries an attribute's worth of specificity and otherwise keeps the title at 28px. */
body.ab-on .case-row .copy h3,
body.ab-on .copy p.company {
  font-size: clamp(20px, 1.1rem + 0.9vw, var(--case-title, 32px));
}

/* Заголовок кейса набирается как заголовок раздела над ним — те же 32px Black, — а не в размер
   абзаца: на широком экране 20px читались как подпись, и ряд оставался без своей верхней ступени.
   Дальше по списку кегль спадает на полступени за ряд: семь заголовков в полную величину утяжеляют
   колонку, а спад даёт ей собственную перспективу — первый кейс громче последнего. Нижняя граница
   clamp держит узкий экран на прежних 20px, где колонка и так лёгкая, верхняя не даёт ни одному
   заголовку перерасти «Selected cases». */
body.ab-on .case-row { --case-title: 32px; }
body.ab-on .case-row:nth-of-type(2) { --case-title: 30px; }
body.ab-on .case-row:nth-of-type(3) { --case-title: 29px; }
body.ab-on .case-row:nth-of-type(4) { --case-title: 27px; }
body.ab-on .case-row:nth-of-type(5) { --case-title: 26px; }
body.ab-on .case-row:nth-of-type(6) { --case-title: 25px; }
body.ab-on .case-row:nth-of-type(n+7) { --case-title: 24px; }

/* The case link, given a body. It sat on the page as bare text while every other call to action on
   the page is a pill, so it read as a footnote rather than as the way into the case. Glass rather
   than another outlined pill: it stands on flat lavender, where a blur alone would show nothing, so
   the material is made of a translucent fill, a lit top edge and a soft drop — the light in it comes
   from the highlight, not from what is behind it. The blur is kept anyway for where the page's
   ASCII field runs under the copy. */
.ab-on .read-more a {
  padding: .62em 1.15em .62em 1.25em;
  border: 1px solid rgba(255, 255, 255, .85);
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(255, 255, 255, .62), rgba(255, 255, 255, .28));
  -webkit-backdrop-filter: blur(10px) saturate(1.5);
  backdrop-filter: blur(10px) saturate(1.5);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9),
              inset 0 -1px 0 rgba(255, 255, 255, .35),
              0 1px 2px rgba(26, 0, 90, .05),
              0 6px 18px rgba(26, 0, 90, .07);
  /* State grammar matched to the hero's own pill (.ab-on .hero-cv .ds-btn, above): 160ms on the
     material properties, 80ms on the one property that moves the box. The materials differ — glass
     on a dark band up there, flat lavender with its own highlight down here — but a page should not
     teach the hand two different rhythms for "this is a button". */
  transition: background-color 160ms ease, box-shadow 160ms ease, transform 80ms ease;
}
/* Underlining the label inside a button would be a link pretending to still be a link. */
.ab-on .read-more a:hover .label { text-decoration: none; }
/* Hover used to lift the pill 1px; the hero's own hover does not move at all, only its material
   changes. Kept the brighter gradient and the wider shadow — that is the material saying "hoverable"
   — dropped the translateY so the two buttons agree on when a press is allowed to move the box. */
.ab-on .read-more a:hover {
  background: linear-gradient(180deg, rgba(255, 255, 255, .8), rgba(255, 255, 255, .45));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1),
              inset 0 -1px 0 rgba(255, 255, 255, .4),
              0 2px 4px rgba(26, 0, 90, .06),
              0 10px 24px rgba(26, 0, 90, .1);
}
/* Same shape as the hero's focus ring — 2px solid, 2px offset — but white would vanish on this
   pill's own lavender-on-white material, so the outline takes the page's ink colour instead. */
.ab-on .read-more a:focus-visible { outline: 2px solid #000; outline-offset: 2px; }
/* Presses down, same direction as the hero's :active, instead of releasing back to the resting
   position — a press is a press regardless of which button took it. */
.ab-on .read-more a:active { transform: translateY(1px); box-shadow: inset 0 1px 3px rgba(26, 0, 90, .12); }
@media (prefers-reduced-motion: reduce) {
  .ab-on .read-more a { transition: none; }
  .ab-on .read-more a:active { transform: none; }
}

/* «Details coming soon» — это не строка кейса, а отметка о его отсутствии, и читаться она должна
   тише всего остального в колонке. Общее правило «любой текст на светлом — чёрный» стоит выше
   по специфичности, поэтому этот отвод набран длинной цепочкой: класс здесь несёт статус, а не
   акцент, и это единственное исключение в блоке кейсов. */
body.ab-on .selected-cases .rows .case-row .copy .read-more.soon {
  color: rgba(0, 0, 0, .42) !important;
  /* Строка обещает работу, которой ещё нет, и перелив — единственный способ сказать это самой
     краской: свет медленно проходит по буквам, как по загружающемуся плейсхолдеру, и строка
     читается как «ещё не готово», а не как выключенная ссылка. Градиент залит в сами буквы через
     background-clip, поэтому подложка остаётся бумагой; крайние остановки держат прежнюю плотность
     42 %, светлая середина уводит к 14 % — светлее только на проходе блика, и текст не мигает.
     Три ширины фона на строку дают длинный пологий проход вместо короткой вспышки. */
  /* The sheen is a light pass over the words, not a blink. Two things made it read as the label
     disappearing and coming back: the highlight went nearly to nothing (.14 against the .42 the text
     sits at), and `no-repeat` left the tile's own width of empty background behind it — with the text
     painted through the background, wherever the tile was not, the words were not either. The stop
     now lifts to .30, a third of the way up rather than two thirds, and the tile repeats: its two
     ends are the same .42, so the joins are invisible and the pass never runs out of paint. */
  background-image: linear-gradient(100deg,
    rgba(0, 0, 0, .42) 0%, rgba(0, 0, 0, .42) 42%,
    rgba(0, 0, 0, .30) 50%,
    rgba(0, 0, 0, .42) 58%, rgba(0, 0, 0, .42) 100%);
  background-size: 300% 100%;
  background-position: 0 0;
  background-repeat: repeat;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: ab-soon-sheen 3.6s linear infinite;
}

/* One tile's width per turn: the pass leaves exactly as the next copy of the tile arrives, so the
   loop closes with nothing to see at the join. */
@keyframes ab-soon-sheen {
  to { background-position: -300% 0; }
}

/* The movement is the whole of the effect: the line says the same thing at the same weight without
   it. Stopping the animation and leaving the tile parked would freeze the highlight as a smear
   across the middle of the words, so the gradient is dropped altogether and the text goes back to
   painting itself in the flat colour it already carries. */
@media (prefers-reduced-motion: reduce) {
  body.ab-on .selected-cases .rows .case-row .copy .read-more.soon {
    animation: none;
    background-image: none;
    -webkit-text-fill-color: currentColor;
  }
}

/* The metric labels are set in the same size as the case's status line: both are the service tier
   under the figures and the title, and two different sizes were splitting that tier in half for no
   reason the reader could see. */
body.ab-on .case-row .metrics .metric .label {
  font-size: var(--text-sm);
  line-height: 1.3;
}

/* The case link is a button now, and a button whose label is underlined is a link wearing a button.
   The underline came from the page's own link styling, which the button's shape already replaces. */
body.ab-on .selected-cases .rows .case-row .copy .read-more a,
body.ab-on .selected-cases .rows .case-row .copy .read-more a .label,
body.ab-on .case-row .copy .read-more a:hover .label {
  text-decoration: none !important;
  background-image: none;
}

/* Заголовок героя — две фразы двумя голосами: жирная представляется, светлая договаривает. Своя
   таблица стилей сайта ниже 1100 px гасит переносы `br.lg` и переводит обе фразы в `inline`, и
   светлая начинается там, где кончилась жирная: «...digital content products, In love with...» в
   одной строке. Два веса в одной строке читаются как сбитая вёрстка, а не как приём. Переносы
   остаются выключенными — на узкой ширине они рвали строку не там, — но блочность возвращается,
   и каждая фраза снова начинается со своей строки, как на широком экране. */
body.ab-on .hero-cv h1 .lines,
body.ab-on .hero-cv h1 .muted-line {
  display: block !important;
}

/* The same 1100 px breakpoint also moves `.hero-content` from `grid-column: 2 / -1` to `1 / -1`,
   so one pixel below it the headline loses the column it sat in and takes the whole content box:
   measured 878 px at a 1155 px viewport, 1020 px at 1100 px, 907 px of muted line at 1000 px. Two
   phrases set as blocks read as a headline only while they keep a headline's measure; across the
   full box they read as a paragraph. So the measure becomes a ceiling rather than a grid position.
   680 px is where the rag comes out even: measured at a 1155 px viewport the four headline lines
   fall at 577 / 680 / 626 / 579 px, against 767 / 878 / 807 / 207 at the box's own width, where
   the last line is the single word `products,` hanging under a full one. Below the breakpoint the
   line length stops growing, and once the content box is itself narrower than the ceiling (a
   390 px phone leaves 350 px) the rule is inert and the block stays flush with the gutter. The
   left edge is deliberately left where the breakpoint put it — restoring the grid inset too would
   indent the headline on a phone. The cap sits on `.hero-content` rather than on the `h1` so the
   button row below keeps the headline's measure, the way it does above the breakpoint.

   Потолок держится только ниже брейкпоинта. Выше него `br.lg` снова расставляет переносы сам, и
   680 px оказывались на 70 px уже авторской строки «design & product leader, keen on»: строка
   доезжала до `keen`, а `on` уходило мягким переносом на свою строку — и следом стоял ещё и жёсткий
   `<br>`. Те же лишние строки давало «...diverse teams». Выше 1100 px колонка `2 / -1` (1020 px)
   вмещает самую длинную строку целиком, и рваться нечему. */
@media (max-width: 1100px) {
  body.ab-on .hero-cv > .hero-content {
    max-width: 680px;
  }
}

/* Телефон на полосе. Его коробка — система координат всей иллюстрации: слои внутри заданы
   процентами от неё, поэтому ни подрасти, ни сдвинуться вместе с полосой она не может. Пилюля-маска
   резала телефон по этой коробке, и в высокой полосе второй версии срез оказывался в 107 px от края
   цветного поля — телефон повисал обрезанным посреди цвета. На полосе маска снимается, а клип берёт
   саму плашку: measure() пишет, насколько плашка выходит за коробку с каждой стороны (в
   нерастянутых единицах коробки), и срез ложится ровно на край полосы. Скругление повторяется там,
   где оно есть у плашки, — делённое на --ab-scale, как у .capsule выше. */
.ab-wide .stack .phone-mask {
  --ab-phone-reach: var(--ab-phone-top, 0px) var(--ab-phone-right, 0px) var(--ab-phone-bottom, 0px) var(--ab-phone-left, 0px);
  -webkit-mask-image: none;
  mask-image: none;
  clip-path: inset(var(--ab-phone-reach));
}
.ab-wide[data-ab-side="left"] .phone-mask { clip-path: inset(var(--ab-phone-reach) round 0 calc(var(--ab-r) / var(--ab-scale)) calc(var(--ab-r) / var(--ab-scale)) 0); }
.ab-wide[data-ab-side="right"] .phone-mask { clip-path: inset(var(--ab-phone-reach) round calc(var(--ab-r) / var(--ab-scale)) 0 0 calc(var(--ab-r) / var(--ab-scale))); }

/* The three buttons that end the page were the only call to action on it that stayed a bare outline:
   under the pointer nothing happened but the border darkening a step. They now light up into the same
   glass the hero's buttons and the case links are made of. The hero's own hover is white against the
   dark ASCII band — a .16 white fill behind a white rim — and on the paper this block stands on that
   reads as nothing at all, so the numbers here are the case link's, which is the same material
   measured against lavender. The resting state stays an outline on purpose: the glass is what the
   pointer finds, not what the block wears while the reader is elsewhere. */
.ab-on #contact .ds-btn.outline {
  transition: background-color .2s ease, border-color .2s ease, box-shadow .2s ease, transform .2s ease;
}
@media (hover: hover) {
  body.ab-on #contact .ds-btn.outline:hover {
    border-color: rgba(255, 255, 255, .85);
    background: linear-gradient(180deg, rgba(255, 255, 255, .8), rgba(255, 255, 255, .45));
    -webkit-backdrop-filter: blur(10px) saturate(1.5);
    backdrop-filter: blur(10px) saturate(1.5);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 1),
                inset 0 -1px 0 rgba(255, 255, 255, .4),
                0 2px 4px rgba(26, 0, 90, .06),
                0 10px 24px rgba(26, 0, 90, .1);
    transform: translateY(-1px);
  }
}
body.ab-on #contact .ds-btn.outline:active {
  transform: translateY(0);
  box-shadow: inset 0 1px 3px rgba(26, 0, 90, .12);
}
@media (prefers-reduced-motion: reduce) {
  body.ab-on #contact .ds-btn.outline,
  body.ab-on #contact .ds-btn.outline:hover,
  body.ab-on #contact .ds-btn.outline:active {
    transition: none;
    transform: none;
  }
}

/* ---------- experience grid ---------- */
/* Карточки скиллсета выше делят свой участок сетки на три равные колонки, и .cols
   из experience — тот же .cols[data-astro-cid-xpq65ryk], что в _astro/index.LKtJTy4O.css —
   делит ровно тот же участок на такие же три колонки: замер при любой ширине от 1101px
   и выше показывает у обоих блоков одни и те же левый и правый край. Базовый CSS сайта
   изначально отдавал .meta одну из трёх колонок, а .highlights — две последние, и это
   совмещало текст роли с двумя правыми карточками. Правило ниже раньше делало наоборот:
   отдавало .meta две колонки, а .highlights — только последнюю, из-за чего роль и её пункты
   сжимались в одну узкую колонку с широким пустым разрывом перед ней. Правка возвращает
   деление сайта: .meta — первая колонка, .highlights — две последние. Специфичность
   правил сайта — класс плюс атрибутный селектор, поэтому здесь впереди стоит класс секции,
   чтобы правило было точнее и продолжало их перекрывать. */
.experience .cols[data-astro-cid-xpq65ryk] > .meta[data-astro-cid-xpq65ryk] {
  grid-column: 1 / 2;
}
.experience .cols[data-astro-cid-xpq65ryk] > .highlights[data-astro-cid-xpq65ryk] {
  grid-column: 2 / -1;
}

/* Below 1100px the two blocks stop sharing a tract: the cards drop to two columns across the same
   columns 2 through 12, while the year widens to two columns and pushes .cols out to start at
   column 3. No line of the site's grid falls on the card edge any more — the edge sits half a
   column short of the middle of .cols — so the company track is measured as half of .cols less
   half a grid column, and with .cols covering ten of the twelve columns one column is
   (100% - 9 gaps) / 10 of it. Below 720px .cols is a flex column and both blocks stack, where
   these grid properties do nothing at all. */
@media (max-width: 1100px) {
  .experience .cols[data-astro-cid-xpq65ryk] {
    grid-template-columns: calc((100% - (100% - 9 * var(--col-gap)) / 10) / 2 - var(--col-gap)) 1fr;
  }
  .experience .cols[data-astro-cid-xpq65ryk] > .meta[data-astro-cid-xpq65ryk] {
    grid-column: 1 / 2;
  }
  .experience .cols[data-astro-cid-xpq65ryk] > .highlights[data-astro-cid-xpq65ryk] {
    grid-column: 2 / -1;
  }
}
