/* GENERATED by build.sh — edit assets/css/src/*.css */
/* ========================================================================== Design tokens — colour,type,space,radius. Change --accent here and the whole product follows. Everything a component can vary by is a token. The rule that keeps it that way:no literal hex,rgba() or px value below this file,and none in the JS. Before this existed there were 360 inline style objects carrying 44 distinct px values and a dozen hard-coded colours,several of which could not follow the light theme because they were literal white-on-white. ========================================================================== */:root{/* The palette is the company's,lifted from cyberneticresearch.ai so the product and the site that sells it read as one thing. The values are copied exactly rather than matched by eye. An audit of that site found two reds and two ambers for the same meaning,fifteen RGB units apart — close enough that nobody notices and nobody fixes it. A third almost-right copy of the brand cyan is the last thing either codebase needs,so #07090d,#eef5f8 and #26d9e8 here are the site's own numbers. */ --bg:#07090d;--panel:rgba(255,255,255,.05);--line:rgba(255,255,255,.11);--text:#eef5f8;--muted:rgba(238,245,248,.62);/* A tint of the ink rather than the site's fixed #a7b4bd:this app has a light theme,and a tint follows it for free where a fixed grey cannot. .62 measures 7.13:1 on --bg and 6.90:1 on a --panel,both past AA. */ --faint:rgba(238,245,248,.52);/* Tints layered over --bg. These are the ones the light theme MUST flip:a white .03 wash is invisible on a white panel. */ --surface-1:rgba(255,255,255,.03);--surface-2:rgba(255,255,255,.05);--surface-3:rgba(255,255,255,.06);--surface-hover:rgba(255,255,255,.06);--field-bg:rgba(255,255,255,.04);--sheet:rgba(11,17,23,.94);--overlay:rgba(3,5,8,.64);--overlay-strong:rgba(3,5,8,.72);--ink-well:rgba(3,5,8,.6);/* Brand cyan,and one relationship inverts because of it. The blue this replaced could carry white text on a solid fill,so --accent-ink was a *darkened* blue that made white legible on it. Cyan cannot be used that way at all:white on #26d9e8 is 1.73:1. The fill stays bright and the foreground goes dark instead — which is what the marketing site already does on its primary button and skip link,at 11.19:1. So --accent-ink is now the cyan itself,and --on-accent is near-black. Three rules in components.css used --on-accent as text on a *tinted* surface rather than on a solid fill;they were wrong in a way white hid,and now read from --text. The light theme flips both tokens back — a bright cyan chip looks garish on white,so light gets a deep teal fill with white on it. */ --accent:#26d9e8;--accent-2:#17b4c4;--accent-glow:rgba(38,217,232,.45);--accent-ink:#26d9e8;--on-accent:#031014;/* Accent-coloured TEXT. 11.55:1 here;the light theme overrides it,where #26d9e8 on white is 1.73:1 and unusable for type. */ --accent-text:#26d9e8;--accent-soft:rgba(38,217,232,.14);--accent-wash:rgba(38,217,232,.1);/* Link hover. One step lighter,not a different hue,so the hover reads as the same link rather than a second kind of link. */ --accent-hover:#7fe9f2;/* Three states,at the company's values:green is done or correct,amber is attention,red is failed or destructive. The site also declares a --violet it never once references,expressing that role through three hand-written literals instead. It is not copied here:a token nothing uses is a colour the next person cannot find when they need it,and works around when they don't. */ --green:#4ade80;--green-soft:rgba(74,222,128,.14);--green-line:rgba(74,222,128,.4);--amber:#f6c453;--amber-soft:rgba(246,196,83,.14);--amber-line:rgba(246,196,83,.4);--danger:#ff6b7a;--danger-line:rgba(255,107,122,.4);/* A destructive *fill* is the one place the palette still needs a dark red:it carries white text,and #ff6b7a at 2.75:1 cannot. */ --danger-fill-1:#b4374b;--danger-fill-2:#8f2233;--grid:rgba(38,217,232,.14);/* The logo is a fixed brand asset,not a themed drawing — but roughly half of it is near-white,which is invisible on a light panel. So the light theme sets it on a dark tile instead of recolouring it. Transparent here:on dark the mark already sits on the ground it was drawn for. */ --logo-tile:transparent;/* Difficulty palette — the same three-state ramp,so a learner reads level and status with one vocabulary. Tokenised so the light theme flips values here instead of restating three whole rules. */ --lvl-beg-bg:rgba(74,222,128,.15);--lvl-beg-fg:#4ade80;--lvl-int-bg:rgba(38,217,232,.15);--lvl-int-fg:#26d9e8;--lvl-adv-bg:rgba(255,107,122,.16);--lvl-adv-fg:#ff6b7a;/* One family,three jobs. IBM Plex Sans sets both the display sizes and the interface;IBM Plex Mono sets data — ids,codes,counts,terminal output. Using one family across all of it is the deliberate choice:the voice stays constant and the hierarchy comes from size,weight and tracking rather than from a second typeface competing with the first. This replaced Archivo + Inter. Inter is the most-used interface face in modern SaaS,and an interface set in it reads as a template rather than a product. Plex was drawn for IBM and is used across their security tooling — squared terminals,low contrast,engineered rather than friendly,which is the register this product wants. Plex Sans is variable and covers 400-700. Nothing asks for 800:that weight does not exist in the file and the browser would fake it,which is precisely the smeared look that reads as unconsidered. --reading stays a system serif for lesson prose. */ --heading:"IBM Plex Sans",system-ui,sans-serif;--ui:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;--mono:"IBM Plex Mono",ui-monospace,monospace;/* * A reading face,separate from the interface face. * * A display face was once setting everything — nav labels,buttons,and the * lesson prose learners actually read. One typeface doing every job is most * of why the product read as generic:there was no signal distinguishing * "chrome" from "content". * * The stack is deliberately system-resident,so it costs no download and no * layout shift. Charter and Iowan Old Style ship on macOS/iOS,Georgia is on * effectively every Windows and Android install,and `ui-serif` picks up * whatever the platform considers its reading face. */ --reading:"Iowan Old Style","Charter","Bitstream Charter","Source Serif 4",Georgia,ui-serif,serif;/* One scale. Steps are the sizes the design actually used once duplicates were collapsed — not a fresh ratio,so nothing shifts visually. */ --fs-1:11px;--fs-2:12px;--fs-3:13px;--fs-4:14px;--fs-5:15px;--fs-6:17px;--fs-7:19px;--fs-8:22px;--lh-tight:1.35;--lh-body:1.6;--lh-loose:1.75;--track-caps:.14em;--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:24px;--s-7:32px;--s-8:40px;--r-1:7px;--r-2:11px;--r-3:13px;--r-4:18px;--r-pill:999px;/* Before this block there were sixteen hand-typed durations and exactly one easing curve in the whole product. Everything else inherited the browser default,`ease`,which starts slow — so a control began moving after the eye had already gone looking for it. The curves are stronger than anything built in. `--ease-out` is for things arriving or leaving:it covers most of its distance immediately,which is what makes an interface feel like it answered rather than considered. `--ease-in-out` is for something already on screen moving somewhere else,where both ends should settle. Durations are named for the job,not the number,so a control that gains a press state uses the same 140ms as every other one. UI stays under 300ms;past that a transition reads as latency. No asterisks inside this comment:build.sh strips comments with a regex that cannot handle one,and an unterminated comment silently swallows the rest of the bundle. */ --ease-out:cubic-bezier(.23,1,.32,1);--ease-in-out:cubic-bezier(.77,0,.175,1);--t-press:140ms;--t-hover:200ms;--t-panel:260ms}/* ========================================================================== Base element resets and document-level defaults. ========================================================================== */ *,*::before,*::after{box-sizing:border-box}/* The UA rule for [hidden] is display:none,but it loses to any author rule that sets display — `.ghost{display:inline-flex}` on a hidden button was enough to keep it on screen. Restate it here,after the reset,so `hidden` means hidden for every component. */ [hidden]{display:none !important}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--ui);overflow-x:hidden;-webkit-font-smoothing:antialiased}/* Headings get the tracking a face is drawn to be set with at large sizes. -.02em was a compromise chosen back when this rule also had to survive at 14px,because one face was setting the whole product;the interface has its own role now,so these sizes are only ever large and can be set properly tight. The scale is graded rather than one value:-.02em at 22px reads as normal spacing,while -.045em at 84px is what stops a big headline looking like it was pasted in at the wrong size. */ h1,h2,h3,h4{font-family:var(--heading);font-weight:700;letter-spacing:-.028em;margin:0}h1{letter-spacing:-.045em}h2{letter-spacing:-.038em}a{color:var(--accent);text-decoration:none}a:hover{color:var(--accent-hover)}html{scroll-behavior:smooth}.navlink{transition:color var(--t-hover) var(--ease-out)}.navlink:hover{color:var(--text) !important}@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}::selection{background:var(--accent-soft)}/* ========================================================================== Keyframes. Every animation is disabled under prefers-reduced-motion. ========================================================================== */ @keyframes spin3d{to{transform:rotateX(-20deg) rotateY(360deg)}}@keyframes ringspin{to{transform:rotateX(74deg) rotateZ(360deg)}}@keyframes ringspin2{to{transform:rotateY(66deg) rotateZ(-360deg)}}@keyframes gridmove{to{background-position:0 60px}}@keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-12px)}}@keyframes pulse{0%,100%{opacity:.45}50%{opacity:1}}@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}/* `flashin` lived here. Its only consumer was the toast,which now drives its own entry and exit as transitions so a replacement can interrupt one cleanly — see components/toast.js. */ /* Reduced motion means less movement,not no feedback. This rule used to kill animation and nothing else,which covered the keyframes above but left every transition running — so the card tilt,the button lift and the pointer-follow parallax all kept moving for someone who had asked the operating system for them to stop. Those are transforms,and transforms are the part that causes trouble. So movement is neutralised at the source:transforms are pinned to none and the properties carrying them are dropped from the transition. Colour,border and shadow transitions are deliberately left alone — they tell you a control responded,and removing them would leave the interface feeling broken rather than calm. */ @media (prefers-reduced-motion:reduce){*{animation:none !important}.card3d,.card3d:hover,.tilt,.btn:hover,.btn:active,.ghost:active,.chip:active{transform:none !important}.card3d{transition:box-shadow var(--t-hover) var(--ease-out),border-color var(--t-hover) var(--ease-out) !important}.btn{transition:box-shadow var(--t-hover) var(--ease-out) !important}}/* ========================================================================== Ambient background layers:glow,perspective grid floor,stacking context. The WebGL canvas and scrim are injected by js/core/backdrop.js and sit at z-index 0. ========================================================================== */ /* Ambient glow. Opacities raised 5 percentage points from the original (blue .20 -> .25,cyan .09 -> .14). These sit behind .content,so any further increase needs a contrast re-check on the landing hero,which is the only large text sitting directly on this layer. */ /* Scrim over the backdrop photo. Lives in CSS,not JS:built inside the WebGL callback it was absent on the first paint (photo flashed at full strength) and absent entirely whenever the Three.js CDN was unreachable. Alphas are the original's minus 5 percentage points,so 5% more of the photo reads through. Stacking order is bottom -> top:photo (z 0) -> WebGL canvas (z 0) -> .bg-scrim (z 0) -> .glowbg (z 0) -> .content (z 1). Everything up to .content shares z-index 0,so that order is DOM order,not z-index — backdrop.js therefore inserts its canvas immediately BEFORE .bg-scrim. Appending it to <body> instead puts it on top of both the scrim and the glow and the plate renders at full strength. */ .bg-scrim{position:fixed;inset:0;z-index:0;pointer-events:none;background:linear-gradient(90deg,rgba(7,10,18,.87) 0%,rgba(7,10,18,.57) 42%,rgba(7,10,18,.39) 100%),radial-gradient(130% 100% at 50% 0%,rgba(7,10,18,.15),rgba(7,10,18,.73))}.glowbg{position:fixed;inset:0;z-index:0;pointer-events:none;background:radial-gradient(1100px 620px at 50% -12%,rgba(75,141,255,.25),transparent 60%),radial-gradient(700px 500px at 92% 8%,rgba(57,224,216,.14),transparent 60%)}.gridfloor{position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;perspective:620px}.gridfloor::before{content:"";position:absolute;left:-60%;right:-60%;bottom:-24%;height:130%;background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:60px 60px;transform:rotateX(74deg);transform-origin:bottom center;animation:gridmove 5.5s linear infinite;mask-image:linear-gradient(to top,#000,transparent 72%);-webkit-mask-image:linear-gradient(to top,#000,transparent 72%)}.content{position:relative;z-index:1}.scene{perspective:1500px}.panel{background:linear-gradient(180deg,rgba(24,31,46,.92),rgba(13,18,29,.92));border:1px solid var(--line);border-radius:18px;box-shadow:0 34px 70px -28px rgba(0,0,0,.85),inset 0 1px 0 rgba(255,255,255,.07)}/* The tilt itself is unchanged — same angles,same lift,same glow. Two things around it are:400ms is over the 300ms an interface has before a response reads as lag,and this fires on a hover,which is one of the most repeated gestures there is. At --t-panel the card arrives while the pointer is still settling. And the whole hover is gated:a tap on a touchscreen fires:hover and leaves it latched,so a tapped card stayed tilted and glowing until something else was touched — on a phone that read as a stuck card,not as feedback. */ .card3d{transition:transform var(--t-panel) var(--ease-out),box-shadow var(--t-panel) var(--ease-out),border-color var(--t-panel) var(--ease-out)}/* The lift,the accent border and the glow are unchanged. The 7deg rotation on both axes is gone. A rotated element is rasterised on a plane that no longer lines up with the pixel grid,so every glyph inside it gets resampled — hovering a card made its own title and body copy go soft,which is the opposite of what a hover is for. It is also the single detail that most made these read as demo cards rather than product cards. Ten of these are on screen at once on the certifications page. What is left is a straight lift on the compositor:sharp text,and the same family of feedback. */ @media (hover:hover) and (pointer:fine){.card3d:hover{transform:translateY(-4px);border-color:rgba(75,141,255,.5);box-shadow:0 34px 74px -30px rgba(0,0,0,.9),0 0 34px -14px var(--accent-glow),inset 0 1px 0 rgba(255,255,255,.12)}}.tilt{transform:perspective(1200px) rotateY(calc(var(--px,0)*9deg)) rotateX(calc(var(--py,0)*-9deg));transition:transform .12s ease-out}/* ========================================================================== Reusable UI components — buttons,fields,chips,bars,badges,rows. Nothing here is page-specific. ========================================================================== */ .kick{font-family:var(--mono);font-size:var(--fs-2);letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}.muted{color:var(--muted)}.faint{color:var(--faint)}/* -------------------------------------------------------------------------- Layout and text primitives. These exist because the same handful of shapes were being re-typed as inline style objects in the page modules — `display:flex,alignItems:center,gap:8px,flexWrap:wrap` appeared six times,a mono 12px meta line nine times. Naming them moves the design values out of the JS and into the one file that the tokens and the light theme can reach. Deliberately small and semantic,not a utility framework:a shape earns a class here once it repeats,not before. -------------------------------------------------------------------------- */ .stack{display:flex;flex-direction:column}/* Horizontal groups that wrap. `.cluster` is the default;`.between` pushes the last child to the far edge. */ .cluster{display:flex;align-items:center;flex-wrap:wrap}.cluster.top{align-items:flex-start}.between{display:flex;align-items:center;justify-content:space-between}.gap-1{gap:var(--s-1)}.gap-2{gap:var(--s-2)}.gap-3{gap:var(--s-3)}.gap-4{gap:var(--s-4)}.gap-5{gap:var(--s-5)}.gap-6{gap:var(--s-6)}.gap-7{gap:var(--s-7)}/* Flex children:`.grow` also resets min-width,without which a flex item refuses to shrink below its content and overflows on mobile. */ .grow{flex:1;min-width:0}.fixed{flex:none}.eyebrow{font-family:var(--mono);font-size:var(--fs-1);letter-spacing:var(--track-caps);text-transform:uppercase;color:var(--faint)}/* Font only — no colour. `.meta` is nearly always paired with `.muted` or `.faint`,and since those are declared above it,a colour here would win the cascade and silently re-tint every caption it touched. */ .meta{font-family:var(--mono);font-size:var(--fs-2)}.lede{margin:0;font-family:var(--reading);font-size:15.5px;line-height:1.6;color:var(--muted)}.h-sec{font-size:var(--fs-7);margin:0 0 var(--s-4)}.h-sub{font-size:var(--fs-6);margin:0}.err{font-size:12.5px;color:var(--danger)}.num{font-family:var(--heading);font-weight:700}.pad{padding:var(--s-5)}.pad-lg{padding:var(--s-6)}/* Empty state. Every list uses this rather than inventing its own,so an empty screen always names the next action. */ .empty-state{padding:34px 26px;text-align:center;align-items:center}/* Auth screens. Login,signup,forgot-password and reset-sent were each repeating the same viewport-centring block and the same card box;they are one shape,so they are one pair of classes. */ .auth-viewport{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:var(--s-8) var(--s-5);gap:26px}.auth-card{width:min(430px,100%);padding:clamp(26px,4vw,var(--s-8));display:flex;flex-direction:column;gap:var(--s-4)}.auth-note{font-size:var(--fs-2);text-align:center;font-family:var(--mono)}.terms-box{width:19px;height:19px;flex:none;border-radius:6px;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:var(--fs-2);font-weight:700}.terms-row{display:flex;align-items:flex-start;gap:10px;cursor:pointer;font-size:12.5px;line-height:1.5;color:var(--muted)}.stat-tile{padding:22px}.stat-value{font-family:var(--heading);font-weight:700;font-size:38px;line-height:1}.stat-value.grad{background:linear-gradient(180deg,var(--text),var(--accent));-webkit-background-clip:text;background-clip:text;color:transparent}.stat-label{font-size:var(--fs-2);letter-spacing:.08em;text-transform:uppercase;margin-top:10px}/* The lift is behind a hover query:a tap on a touchscreen fires:hover and then leaves it stuck,so every button a thumb had touched stayed raised until something else was tapped. Press feedback is the part that belongs on every pointer,and it is what the finger is actually waiting for. */ .btn{display:inline-flex;align-items:center;gap:9px;padding:13px 22px;border:0;border-radius:9px;background:linear-gradient(180deg,var(--accent-ink),var(--accent-2));color:var(--on-accent);font-family:var(--ui);font-weight:700;font-size:15px;cursor:pointer;box-shadow:0 6px 16px -8px var(--accent-glow),inset 0 1px 0 rgba(255,255,255,.18);transition:transform var(--t-press) var(--ease-out),box-shadow var(--t-hover) var(--ease-out)}@media (hover:hover) and (pointer:fine){.btn:hover{transform:translateY(-2px);box-shadow:0 10px 24px -10px var(--accent-glow),inset 0 1px 0 rgba(255,255,255,.24)}}.btn:active{transform:scale(.97)}.btn.sm{padding:9px 16px;font-size:13px;border-radius:10px}.btn.block{width:100%;justify-content:center}/* `all` animated font-size and border-radius on every .sm variant too,off the compositor. Named properties,so only the two that actually change move. */ .ghost{display:inline-flex;align-items:center;gap:9px;padding:14px 24px;border:1px solid var(--line);border-radius:13px;background:var(--surface-1);color:var(--text);font-family:var(--ui);font-weight:700;font-size:15px;cursor:pointer;transition:border-color var(--t-hover) var(--ease-out),background-color var(--t-hover) var(--ease-out),transform var(--t-press) var(--ease-out)}.ghost:hover{border-color:var(--accent);background:var(--surface-hover)}.ghost:active{transform:scale(.97)}.ghost.sm{padding:9px 16px;font-size:13px;border-radius:10px}.field label{display:block;font-size:12px;color:var(--muted);margin-bottom:7px;letter-spacing:.02em}.input{width:100%;padding:13px 14px;background:var(--field-bg);border:1px solid var(--line);border-radius:11px;color:var(--text);font-family:var(--ui);font-size:14px;outline:none;transition:border-color var(--t-hover) var(--ease-out),box-shadow var(--t-hover) var(--ease-out)}.input::placeholder{color:var(--faint)}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.navbtn{display:flex;align-items:center;gap:12px;width:100%;padding:12px 15px;border:0;background:transparent;color:var(--muted);border-radius:11px;font-family:var(--ui);font-weight:700;font-size:14px;cursor:pointer;transition:background-color var(--t-hover) var(--ease-out),color var(--t-hover) var(--ease-out);text-align:left}.navbtn:hover{background:var(--surface-2);color:var(--text)}.navbtn.active{background:linear-gradient(90deg,var(--accent-soft),transparent);color:var(--text);box-shadow:inset 2px 0 0 var(--accent)}.navbtn.active svg{color:var(--accent)}.chip{padding:9px 15px;border:1px solid var(--line);border-radius:var(--r-pill);background:var(--surface-1);color:var(--muted);font-family:var(--ui);font-weight:600;font-size:13px;cursor:pointer;transition:color var(--t-hover) var(--ease-out),border-color var(--t-hover) var(--ease-out),background-color var(--t-hover) var(--ease-out),transform var(--t-press) var(--ease-out)}.chip:hover{color:var(--text);border-color:var(--accent-glow)}.chip:active{transform:scale(.97)}.chip.active{background:var(--accent-ink);border-color:var(--accent-ink);color:var(--on-accent);box-shadow:0 8px 22px -8px var(--accent-glow)}.day{display:flex;flex-direction:column;align-items:center;gap:4px;padding:11px 6px;border:1px solid var(--line);border-radius:12px;background:var(--surface-1);cursor:pointer;transition:border-color var(--t-hover) var(--ease-out),background-color var(--t-hover) var(--ease-out);font-family:var(--ui)}.day:hover{border-color:var(--accent-glow)}.day .dw{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}.day .dn{font-weight:700;font-size:17px;color:var(--text)}.day .dc{font-family:var(--mono);font-size:10px;color:var(--accent-text)}.day.off{opacity:.4;cursor:not-allowed}.day.off .dc{color:var(--faint)}.day.off:hover{border-color:var(--line)}.day.sel{background:var(--accent-ink);border-color:var(--accent-ink);box-shadow:0 10px 26px -12px var(--accent-glow)}.day.sel .dw,.day.sel .dn,.day.sel .dc{color:var(--on-accent)}.cal-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.cal-title{font-family:var(--heading);font-weight:700;font-size:16px;color:var(--text)}.cal-nav{width:34px;height:34px;border:1px solid var(--line);border-radius:10px;background:var(--surface-1);color:var(--text);font-size:18px;line-height:1;cursor:pointer;transition:border-color var(--t-hover) var(--ease-out),background-color var(--t-hover) var(--ease-out)}.cal-nav:hover:not(:disabled){border-color:var(--accent-glow)}.cal-nav:disabled{opacity:.35;cursor:not-allowed}.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}.cal-wd{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);text-align:center;padding:2px 0}.cal-grid .day{padding:9px 4px}.sw{flex:none;width:48px;height:27px;border-radius:var(--r-pill);border:1px solid var(--line);background:var(--surface-3);position:relative;cursor:pointer;transition:background-color var(--t-hover) var(--ease-out),border-color var(--t-hover) var(--ease-out);padding:0}.sw i{position:absolute;top:2px;left:2px;width:21px;height:21px;border-radius:50%;background:var(--muted);transition:transform var(--t-press) var(--ease-out),background-color var(--t-hover) var(--ease-out)}.sw.on{background:var(--accent-ink);border-color:var(--accent-ink);box-shadow:0 6px 18px -8px var(--accent-glow)}.sw.on i{transform:translateX(21px);background:var(--on-accent)}.bar{height:9px;border-radius:var(--r-pill);background:var(--surface-3);overflow:hidden;border:1px solid var(--line)}.bar > i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--accent),var(--green));box-shadow:0 0 16px -2px var(--accent-glow)}.lvl{font-family:var(--mono);font-size:11px;font-weight:700;padding:4px 10px;border-radius:999px;letter-spacing:.03em;white-space:nowrap}.lvl-beg{background:var(--lvl-beg-bg);color:var(--lvl-beg-fg)}.lvl-int{background:var(--lvl-int-bg);color:var(--lvl-int-fg)}.lvl-adv{background:var(--lvl-adv-bg);color:var(--lvl-adv-fg)}.mod{display:flex;align-items:center;gap:11px;padding:12px 14px;border-radius:11px;transition:background-color var(--t-hover) var(--ease-out)}.mod .mk{width:23px;height:23px;flex:none;border-radius:7px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;font-weight:700;border:1px solid var(--line);color:var(--muted)}.mod.done .mk{background:var(--accent-ink);border-color:var(--accent-ink);color:var(--on-accent);box-shadow:0 0 14px -2px var(--accent-glow)}.mod.done .mt{color:var(--muted)}.mod.current{background:var(--accent-wash)}.mod.current .mk{border-color:var(--accent);color:var(--text)}.mod.locked{opacity:.4}.mt{font-size:13px;line-height:1.35}.obj{display:flex;gap:11px;align-items:flex-start;position:relative}/* `all` here animated width,height and border-width — layout properties,so ticking an objective forced a reflow mid-transition. Only the paint properties that actually differ between states are named. */ .obj .bx{width:20px;height:20px;flex:none;border-radius:7px;border:1.5px solid var(--line);display:flex;align-items:center;justify-content:center;margin-top:1px;transition:background-color var(--t-hover) var(--ease-out),border-color var(--t-hover) var(--ease-out),box-shadow var(--t-hover) var(--ease-out)}.obj .bx svg{opacity:0}.obj.done .bx{background:var(--accent-ink);border-color:var(--accent-ink);box-shadow:0 0 14px -2px var(--accent-glow)}.obj.done .bx svg{opacity:1}.obj.done .ot{color:var(--muted)}.ot{font-size:13px;line-height:1.4;min-width:0;overflow-wrap:anywhere}.row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 16px;border-radius:12px;transition:background-color var(--t-hover) var(--ease-out)}.row:hover{background:var(--surface-1)}.row.me{background:linear-gradient(90deg,var(--accent-soft),transparent);box-shadow:inset 2px 0 0 var(--accent)}/* -------------------------------------------------------------------------- Learning UI — typed lesson blocks,hints,notes,lock reasons. A lesson is a list of typed blocks rather than one slab of prose,so the shapes below are what the renderer in components/lesson-ui.js composes. -------------------------------------------------------------------------- */ .lblock{border-left:2px solid var(--line);padding-left:var(--s-4)}.lblock-head{margin-bottom:var(--s-3)}.lblock-tag{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;padding:3px var(--s-2);border-radius:var(--r-pill);background:var(--accent-soft);color:var(--accent-text);flex:none}.lblock-title{font-size:var(--fs-5);margin:0;font-weight:700}.lblock-mins{margin-left:auto}.lblock-body{margin:0;font-family:var(--reading);font-size:17px;line-height:1.68;color:var(--text);max-width:68ch;letter-spacing:.002em}.lblock-body + .lblock-body{margin-top:var(--s-4)}.lblock-body code{font-family:var(--mono);font-size:.9em;background:var(--surface-2);padding:1px 5px;border-radius:var(--r-1)}.lblock-body strong{color:var(--text);font-weight:700}/* Video placeholder. A designed absence,not a fake player — the lesson layout is the deliverable,the asset is honestly missing. This class used to be on the real <video> too,which is why an actual recording rendered with dashed borders,32px of padding and no size limit at all:a 1920x1080 file laid out 1962px wide inside a 1440px page and gave the whole document a horizontal scrollbar. A player is not a placeholder. */ .video-slot{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--s-2);padding:var(--s-7) var(--s-5);border:1px dashed var(--line);border-radius:var(--r-3);background:var(--surface-1);color:var(--faint)}/* Checklist. The native box is hidden but kept in the accessibility tree and in the tab order;.cl-box is only its skin. */ .checklist{list-style:none;margin:0;padding:0}.cl-row{display:flex;align-items:flex-start;gap:var(--s-3);cursor:pointer;position:relative}.cl-input{position:absolute;opacity:0;width:0;height:0;margin:0;appearance:none;-webkit-appearance:none;pointer-events:none}.cl-box{width:19px;height:19px;flex:none;margin-top:1px;border-radius:6px;border:1.5px solid var(--line);transition:background-color var(--t-hover) var(--ease-out),border-color var(--t-hover) var(--ease-out)}.cl-box.on{background:var(--accent-ink);border-color:var(--accent-ink);box-shadow:0 0 14px -2px var(--accent-glow)}.cl-input:focus-visible + .cl-box{outline:2px solid var(--accent);outline-offset:2px}.cl-text{font-size:var(--fs-3);line-height:var(--lh-tight);color:var(--muted);min-width:0;overflow-wrap:anywhere}.cl-input:checked ~ .cl-text{text-decoration:line-through;color:var(--faint)}/* Topics. A module is a list of topics;a topic is a list of blocks. The rule dividing them only appears when there is more than one,since a divider above a single topic is a heading for the whole page — which the module title already is. */ .topic + .topic{padding-top:var(--s-6);border-top:1px solid var(--line)}.topic-head{display:flex;flex-direction:column;gap:var(--s-1)}.topic-no{color:var(--accent-text);letter-spacing:.1em;text-transform:uppercase;font-size:var(--fs-1)}.topic-title{font-size:var(--fs-7);margin:0;letter-spacing:-.01em}.topic-title.solo{font-size:var(--fs-6)}/* Figures and players share one frame. * * Authors upload whatever they have:a 64px icon,a 900x1600 screenshot from a * phone,a 3200px panorama,a 4000x3000 photograph,a vertical video. The frame * has to make all of them look deliberate,so it centres the media on a recessed * surface and bounds it in both directions. * * Two rules do most of that work,and the old ones did neither:* * Never upscale. `width:100%` stretched a 64px icon to 532px of blur. With * `width:auto` and `max-width:100%` a small image simply stays its own size,* centred,which reads as a small image rather than a broken large one. * * Bound the height. A portrait filled the viewport and pushed the text that * explains it off screen;--media-h caps it so the figure stays part of the * lesson rather than an interruption to it. */ .lfig{margin:0}.lfig-media{--media-h:min(58vh,520px);display:flex;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:var(--r-3);background:var(--ink-well);padding:var(--s-3);overflow:hidden}/* Media keeps its own aspect ratio inside those bounds;`contain` only matters if something else ever forces a size on it. */ .lfig-img,.lfig-video{display:block;width:auto;height:auto;max-width:100%;max-height:var(--media-h);object-fit:contain;border-radius:var(--r-1)}/* A player needs a usable width even when the file is a tall one,and its controls need somewhere to sit. */ .lfig-video{min-width:min(320px,100%);background:#000}/* The diagram is drawn to fill the column rather than to a fixed size,so it is the one thing that does stretch. Below ~500px its labels stop fitting,so it scrolls inside its own frame rather than shrinking the type past legibility — the frame scrolls,never the page. */ .lfig-media.is-diagram{display:block;background:var(--surface-1);overflow-x:auto}.lfig-svg{min-width:0;width:100%}.lfig-svg svg{display:block;width:100%;height:auto;min-width:500px}.lfig-caption{margin:var(--s-3) 0 0;font-family:var(--reading);font-style:italic;font-size:14px;line-height:1.55;color:var(--faint);max-width:68ch}.lfig-svg .d-label{font-family:var(--heading);font-size:12px;font-weight:700;fill:var(--text);text-anchor:middle}.lfig-svg .d-sub{font-family:var(--mono);font-size:9.5px;fill:var(--faint);text-anchor:middle}.lfig-svg .d-note{font-family:var(--heading);font-size:11.5px;fill:var(--muted)}.lfig-svg .d-head{font-family:var(--mono);font-size:9.5px;letter-spacing:.14em;fill:var(--faint)}/* Objective. Stated before the material,so it reads as the contract for the module rather than a summary of it. */ .objective-card{border-left:3px solid var(--accent)}.objective-text{margin:0;font-family:var(--reading);font-size:17px;line-height:1.6;color:var(--text);max-width:68ch}.rail-objective{margin:6px 0 0;font-size:var(--fs-3);line-height:var(--lh-body);color:var(--muted)}.quiz-question{margin:0;font-size:var(--fs-4);font-weight:700;line-height:var(--lh-tight)}.quiz-tally-wrap{margin-left:auto;flex:none}.quiz-tally{color:var(--faint)}.quiz-q + .quiz-q{padding-top:var(--s-5);border-top:1px solid var(--line)}/* A question answered correctly is locked,so the tally cannot be undone by a later stray click. Keep it legible rather than greyed to uselessness. */ .quiz-radio:disabled + .quiz-mark{opacity:.85}.quiz-opt:has(.quiz-radio:disabled){cursor:default}.quiz-opt{display:flex;align-items:flex-start;gap:var(--s-3);cursor:pointer;position:relative;padding:10px var(--s-3);border:1px solid var(--line);border-radius:var(--r-2);background:var(--surface-1);font-size:var(--fs-3);line-height:var(--lh-tight);color:var(--muted);transition:border-color var(--t-hover) var(--ease-out),background-color var(--t-hover) var(--ease-out)}.quiz-opt:hover{border-color:var(--accent-glow)}.quiz-opt > span:last-child{min-width:0;overflow-wrap:anywhere}.quiz-radio{position:absolute;opacity:0;width:0;height:0;margin:0;appearance:none;-webkit-appearance:none;pointer-events:none}/* border-width is named rather than dropped:the checked state thickens the ring to 5px to fill the dot,so that one IS the effect. It is the only layout-affecting property here,on a 16px box that nothing else depends on. */ .quiz-mark{width:16px;height:16px;flex:none;margin-top:1px;border-radius:50%;border:1.5px solid var(--line);transition:border-color var(--t-press) var(--ease-out),border-width var(--t-press) var(--ease-out)}.quiz-radio:checked + .quiz-mark{border-color:var(--accent-ink);border-width:5px}.quiz-radio:focus-visible + .quiz-mark{outline:2px solid var(--accent);outline-offset:2px}.quiz-opt.is-answer{border-color:var(--green-line);background:var(--green-soft);color:var(--text)}.quiz-result{margin:0;font-size:var(--fs-3);line-height:var(--lh-body)}.quiz-result.right{color:var(--green)}.quiz-result.wrong{color:var(--danger)}.quiz-foot{margin-top:var(--s-5)}.quiz-foot .quiz-result{margin-bottom:var(--s-3)}.hint-list{list-style:none;margin:0;padding:0;counter-reset:hint}.hint-item{counter-increment:hint;font-size:var(--fs-3);line-height:var(--lh-body);color:var(--muted);padding-left:26px;position:relative}.hint-item::before{content:counter(hint);position:absolute;left:0;top:0;width:18px;height:18px;border-radius:6px;background:var(--accent-soft);color:var(--accent-text);font-family:var(--mono);font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center}.hint-approach{font-size:var(--fs-3);line-height:var(--lh-body);color:var(--text);padding:var(--s-3);border-radius:var(--r-2);background:var(--accent-wash);border:1px solid var(--accent-soft)}.notes-area{resize:vertical;min-height:96px;line-height:var(--lh-body)}.notes-saved{min-width:52px;text-align:right}.lock-note{display:flex;align-items:center;gap:6px;margin-left:34px;color:var(--faint)}.streak-grid{display:grid;grid-template-columns:repeat(14,1fr);gap:5px}.streak-day{aspect-ratio:1;border-radius:5px;background:var(--surface-2);border:1px solid transparent}.streak-day.on{background:linear-gradient(180deg,var(--accent),var(--accent-2));box-shadow:0 0 10px -3px var(--accent-glow)}.streak-day.today{border-color:var(--accent)}/* 12px text alone gave this link a 15px-tall hit area — under the ~24px a finger can reliably land on,and it is the one call to action on the card. */ .streak-risk{color:var(--danger);padding:5px 0;min-height:24px;display:inline-flex;align-items:center}/* -------------------------------------------------------------------------- Keyboard focus. Focus used to be styled on inputs only,which left buttons,links,chips and nav items with just the browser default — low contrast against these dark panels.:focus-visible keeps the ring off mouse clicks. -------------------------------------------------------------------------- */:where(a,button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}.btn:focus-visible,.ghost:focus-visible,.chip:focus-visible{outline-offset:3px}#shell.app-light:where(a,button,input,select,textarea,[tabindex]):focus-visible{outline-color:var(--accent-2)}.skip-link{position:absolute;left:12px;top:-60px;z-index:200;padding:11px 18px;border-radius:11px;background:linear-gradient(180deg,var(--accent),var(--accent-2));color:var(--on-accent);font-family:var(--heading);font-weight:700;font-size:var(--fs-4);transition:top var(--t-hover) var(--ease-out)}.skip-link:focus{top:12px;color:var(--on-accent)}/* -------------------------------------------------------------------------- Target size. WCAG 2.2 AA (2.5.8) asks for 24x24 CSS px on any pointer target;touch devices get the more comfortable 44px. Applied via min-height so the visual rhythm of the text itself is untouched. -------------------------------------------------------------------------- */ .navlink,.skip-link,a.chip,.field a,form a,.brandlink,.lablink,nav a:not(.btn):not(.ghost):not(.navbtn){min-height:24px;display:inline-flex;align-items:center}@media (pointer:coarse),(max-width:760px){.navlink,.skip-link,a.chip,.field a,form a,.brandlink,.lablink,nav a:not(.btn):not(.ghost):not(.navbtn){min-height:44px}}input,select,textarea{min-height:24px}.brandlink{min-height:34px}@media (pointer:coarse),(max-width:760px){.brandlink{min-height:44px}}/* The enrolment QR. The SVG arrives from the server at its own intrinsic size,which overflowed a fixed-size box and collided with the caption beneath it. The wrapper sets the size and the SVG is told to fill it,so the code scales instead of spilling. White background is not decoration:a QR needs light modules and a quiet zone to scan,and this page is dark. */ .mfa-qr{width:196px;height:196px;padding:10px;border-radius:12px;background:#fff;line-height:0;display:block}.mfa-qr svg{width:100%;height:100%;display:block}/* ── Modal ────────────────────────────────────────────────────────────────── Native <dialog>,so the focus trap,Escape,inert background and top-layer stacking come from the browser rather than from JavaScript that has to be right every time. Only the appearance is ours. */ .dlg{border:0;padding:0;background:transparent;max-width:min(440px,calc(100vw - 32px));color:var(--text)}.dlg::backdrop{background:var(--overlay-strong);backdrop-filter:blur(3px)}.dlg-body{display:flex;flex-direction:column;gap:var(--s-4);padding:var(--s-6);border-radius:var(--r-4);border:1px solid var(--line);background:var(--sheet);box-shadow:0 40px 90px -30px rgba(0,0,0,.9)}.dlg-title{font-size:var(--fs-7);margin:0}.dlg-intro{margin:0;font-size:var(--fs-4);line-height:var(--lh-body);color:var(--muted)}.dlg-actions{display:flex;gap:var(--s-2);justify-content:flex-end;margin-top:var(--s-1)}/* Destructive confirmations do not get the accent — the accent means "proceed,this is the good path",and deleting an account is not that. */ .dlg-danger{background:linear-gradient(180deg,var(--danger-fill-1),var(--danger-fill-2));box-shadow:none}@media (prefers-reduced-motion:no-preference){.dlg[open]{animation:dlg-in 180ms var(--ease-out)}.dlg[open]::backdrop{animation:dlg-fade 180ms var(--ease-out)}}@keyframes dlg-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}@keyframes dlg-fade{from{opacity:0}to{opacity:1}}/* ── Recovery codes ───────────────────────────────────────────────────────── Shown exactly once,so the block has to make that obvious and make saving them easy. A monospace grid rather than a run-on line:these get copied by eye as often as by button,and eight codes separated by dots invite transcription errors. */ .rc-box{padding:var(--s-5);border-radius:var(--r-3);border:1px solid var(--accent-glow);background:var(--accent-wash);display:flex;flex-direction:column;gap:var(--s-3)}.rc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:var(--s-2)}.rc-grid code{font-family:var(--mono);font-size:var(--fs-3);letter-spacing:.06em;padding:9px 11px;border-radius:var(--r-1);background:var(--ink-well);border:1px solid var(--line);color:var(--accent-text);text-align:center;user-select:all}.rc-note{display:flex;gap:9px;align-items:flex-start;font-size:var(--fs-3);line-height:var(--lh-tight);color:var(--text)}.rc-spent{opacity:.45;text-decoration:line-through}/* ── Card grids ───────────────────────────────────────────────────────────── Cards in one row already matched — grid stretches them. Rows did not,so a catalogue read as three bands of different heights:275px,then 254,then 233. `grid-auto-rows:1fr` makes every row the height of the tallest,so the whole grid is one size. That only works if a single card cannot run away with the height,which is what `.card-clamp` is for:a long description is cut at a fixed number of lines instead of setting the height of every other card on the page. */ .card-grid{display:grid;grid-auto-rows:1fr}/* Grid items refuse to shrink below their content without this,which is how a long unbroken word pushes a card past the viewport on a phone. */ .card-grid > *{min-width:0}.card-clamp{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden}/* ── Capped lists ─────────────────────────────────────────────────────────── A panel that grows with its data is not a box,it is a page. The active sessions list rendered every row it was given — 34,296px of one panel,on a 34,673px page. The box now has a size and the list scrolls inside it. min() so it is bounded on a phone too:58vh of a short viewport is small enough to keep the surrounding controls reachable. `overscroll-behavior` stops the scroll chaining out to the page when the list hits its end. */ .scroll-list{max-height:min(58vh,520px);overflow-y:auto;overscroll-behavior:contain;padding-right:4px}.scroll-list::-webkit-scrollbar{width:8px}.scroll-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:var(--r-pill)}.scroll-list::-webkit-scrollbar-track{background:transparent}.list-count{font-family:var(--mono);font-size:var(--fs-2);color:var(--faint)}/* Pager. Sits at the foot of a paged list;`margin-top:auto` pins it to the bottom of the panel so every page of the list is the same height whether it holds six rows or two. */ .pager{display:flex;align-items:center;gap:var(--s-3);margin-top:auto;padding-top:var(--s-2)}.pager .list-count{margin:0 auto;white-space:nowrap}.pager button{min-width:38px;justify-content:center}.pager button:disabled{opacity:.35;cursor:default}/* ── Select ───────────────────────────────────────────────────────────────── A <select> carrying .input picks up the field's box,but the platform draws its own arrow on its own background — which reads as a light chip stuck to the right-hand end of a dark field. `appearance:none` plus a drawn chevron puts the whole control back under the theme. The dropdown itself is rendered by the OS and cannot be styled,so the options are given explicit colours rather than left to inherit into nothing. Used wherever a picker has no ceiling — the badge and lab pickers in the admin portal — because a row of buttons per row of data is only a list until there are four hundred of them. */ select.input{appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:38px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23808a9b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");background-repeat:no-repeat;background-position:right 13px center;background-size:15px}select.input option{background:var(--bg);color:var(--text)}/* ── Panel grid ───────────────────────────────────────────────────────────── Settings and profile:panels as cells of one grid rather than two stacked columns that never lined up. Grid stretches every cell in a row to the same height,so the panels beside each other always match. Two columns fixed,not auto-fit. auto-fit gave three at 1440px,which left the last panel alone in a row with two empty cells beside it. Deliberately NOT grid-auto-rows:1fr here. These panels differ a lot in content — an on/off pair against a paged list — and forcing every row to the tallest padded the short ones with 400px of nothing. Rows size to their own content;within a row everything matches,which is what reads as tidy. */ .panel-grid{display:grid;grid-template-columns:1fr;gap:var(--s-5);align-items:stretch}.panel-grid > *{min-width:0}@media (min-width:900px){.panel-grid{grid-template-columns:1fr 1fr}}/* The brand mark. A fixed-size box so the lockup's height never depends on how the image decodes,and a tile that only appears where the theme needs one — see components/logo.js. */ .logo-mark{display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:var(--r-1);background:var(--logo-tile)}.logo-mark img{width:100%;height:100%;object-fit:contain;display:block}#shell.app-light .logo-mark{padding:var(--s-1)}table.table{border-collapse:collapse;font-family:var(--ui);font-size:13.5px}table.table th{text-align:left;padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);border-bottom:1px solid var(--line);white-space:nowrap}table.table td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:middle;color:var(--text)}table.table tbody tr:last-child td{border-bottom:none}/* ========================================================================== Marketing pages only:the hero 3D core and landing breakpoints. ========================================================================== */ .core{position:relative;width:300px;height:300px;transform-style:preserve-3d;animation:floaty 6s ease-in-out infinite}.cube{position:absolute;inset:0;margin:auto;width:170px;height:170px;transform-style:preserve-3d;animation:spin3d 20s linear infinite}.face{position:absolute;width:170px;height:170px;border:1px solid rgba(75,141,255,.6);background:rgba(75,141,255,.06);box-shadow:inset 0 0 40px rgba(75,141,255,.18)}.ring{position:absolute;inset:0;margin:auto;width:270px;height:270px;border-radius:50%;border:1.5px solid rgba(75,141,255,.35);animation:ringspin 12s linear infinite}.ring.r2{width:230px;height:230px;border-color:rgba(57,224,216,.4);animation:ringspin2 9s linear infinite}.coredot{position:absolute;inset:0;margin:auto;width:26px;height:26px;border-radius:50%;background:var(--accent);box-shadow:0 0 50px 8px var(--accent-glow);animation:pulse 2.6s ease-in-out infinite}@media (max-width:780px){.heroGrid{grid-template-columns:1fr !important}.heroCore{display:none !important}}/* ── Rotating hero pitch:the eyebrow pill and the headline ────────────────── Both slots use the same trick. Every variant occupies the SAME grid cell (1/1),so the box is always sized to the longest one and swapping them cannot move anything:the <h1> keeps its height,and the pill keeps its width instead of growing and shrinking under a fixed dot. Laying them out in flow and hiding all but one would collapse each box to a single variant and jolt the hero on every change. Only opacity moves. The variants are all still in the layout when faded out,which is why they carry aria-hidden and the heading carries its own aria-label — a screen reader gets one stable heading,not four stacked. */ .hero-tagline{display:grid;margin:20px 0 0;font-size:clamp(42px,6vw,84px);line-height:1.02}.hero-tagline>span{grid-area:1/1;opacity:0;transition:opacity 700ms var(--ease-out)}.hero-tagline>span.is-on{opacity:1}/* nowrap:the pill is one line by design,so its width is set by the longest eyebrow rather than by whichever one is showing. */ .hero-eyebrow-text{display:grid}.hero-eyebrow-text>span{grid-area:1/1;opacity:0;white-space:nowrap;transition:opacity 700ms var(--ease-out)}.hero-eyebrow-text>span.is-on{opacity:1}/* Under reduced motion the rotation never starts (see landing.js),so the first variant must be visible without the class the timer would have added. */ @media (prefers-reduced-motion:reduce){.hero-tagline>span,.hero-eyebrow-text>span{transition:none}.hero-tagline>span:first-child,.hero-eyebrow-text>span:first-child{opacity:1}}/* ========================================================================== Authenticated app shell:sidebar,topbar,mobile nav,lesson grid. ========================================================================== */ .navbtn .nshort{display:none}.navbtn.mobonly{display:none}@media (max-width:960px){#shell{flex-direction:column}#side{width:auto !important;flex:0 0 auto !important;flex-direction:row !important;overflow-x:auto;border-right:0 !important;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}#sideBrand,#sideFoot{display:none !important}#sideNav{flex-direction:row !important;padding:7px 6px !important;justify-content:space-between;gap:2px}.navbtn{flex:1;flex-direction:column;gap:5px !important;padding:8px 3px !important;font-size:10.5px !important;line-height:1.1;min-width:0;text-align:center;justify-content:flex-start;border-radius:9px}.navbtn.active{box-shadow:inset 0 -2px 0 var(--accent) !important;background:var(--accent-soft) !important}.navbtn svg{flex:none}.navbtn .nlabel{display:none}.navbtn .nshort{display:block}.navbtn.sec{display:none !important}.navbtn.mobonly{display:flex !important}#lessonGrid{grid-template-columns:1fr !important}}/* The sheet is always in the DOM and toggled with [hidden];the media query must not re-show it,so both rules exclude the hidden state. */ #moreSheet{display:none}@media (max-width:960px){#moreSheet:not([hidden]){display:flex}}#moreSheet[hidden]{display:none !important}/* The shell exists but has not been confirmed by the server yet — see renderShell() in components/app-shell.js. `visibility` rather than `display` so the layout is already settled when bootstrap.js reveals it,and the confirmed case has no reflow to pay for. */ #shell.shell-unverified{visibility:hidden}/* ========================================================================== Light theme for the app shell,plus the always-dark sandbox surface. Toggled by adding .app-light to #shell. Most of this file used to be per-component background overrides — one rule per component that had baked a white tint into its own declaration. With the surface tints behind tokens,the theme flips the tokens once and the components follow. What is left below is only what genuinely differs in light:hue choices,shadows,and the two surfaces that are not a tint. ========================================================================== */ #shell.app-light{--bg:#eef1f6;--text:#161b26;--muted:rgba(22,27,38,.74);--faint:rgba(22,27,38,.66);--line:rgba(22,27,38,.14);/* Deep surfaces. These were left dark,which was invisible until something used them on top of a light page:the modal rendered as a near-black panel with dark-ink text on it. --sheet backs the dialog and the mobile More tray,--ink-well backs inline code,and --overlay-strong is the scrim behind the modal,which still wants to be dark so the dialog reads as lifted off the page. */ --sheet:rgba(255,255,255,.97);--ink-well:rgba(22,27,38,.06);--overlay:rgba(22,27,38,.34);--overlay-strong:rgba(22,27,38,.44);/* Surfaces — the tints that must invert. A white .03 wash is invisible on a white panel,which is what these being literals used to cause. */ --surface-1:rgba(22,27,38,.04);--surface-2:rgba(22,27,38,.05);--surface-3:rgba(22,27,38,.08);--surface-hover:rgba(22,27,38,.07);--field-bg:#fff;/* Hue shifts for contrast against light surfaces. Every value here is the same hue as its dark counterpart,darkened until it clears 4.5:1 on both --bg (#eef1f6) and a white panel — the two grounds text actually lands on in this theme. The brand cyan itself is 1.73:1 on white,so light gets #0b6b78 (5.47 / 6.20). The marketing site has no light mode to copy,so these are derived rather than inherited. */ --accent-text:#0b6b78;--accent-soft:rgba(11,107,120,.13);--accent-wash:rgba(11,107,120,.1);--accent-hover:#084f59;/* The fill relationship flips back:a solid #26d9e8 chip on a white panel is a highlighter pen,and its edge all but disappears (1.73:1 against white). Light uses the deep teal as the fill and puts white on it. */ --accent-ink:#0b6b78;--on-accent:#fff;/* --accent-2 is the far stop of every accent gradient — the button,the skip link,the avatar chip. Leaving it at the dark theme's bright cyan put white text on #17b4c4 at 2.51:1 at the bottom of each button,which is the half a gradient is least likely to be checked. Darker than --accent-ink so the gradient still falls away downward. */ --accent-2:#08525c;--green:#166534;--green-soft:rgba(22,101,52,.13);--green-line:rgba(22,101,52,.4);--amber:#92610a;--amber-soft:rgba(146,97,10,.13);--amber-line:rgba(146,97,10,.4);--danger:#b91c1c;--danger-line:rgba(185,28,28,.35);--danger-fill-1:#b91c1c;--danger-fill-2:#8f1616;--grid:rgba(11,107,120,.1);/* A dark tile,so the near-white half of the mark still reads on a white sidebar. The mark itself is never recoloured. */ --logo-tile:#0e1620;--lvl-beg-bg:rgba(22,101,52,.13);--lvl-beg-fg:#166534;--lvl-int-bg:rgba(11,107,120,.13);--lvl-int-fg:#0b6b78;--lvl-adv-bg:rgba(185,28,28,.11);--lvl-adv-fg:#b91c1c;background:var(--bg);color:var(--text)}#shell.app-light #side{background:rgba(255,255,255,.92)!important}#shell.app-light .panel{background:#fff;border-color:rgba(22,27,38,.1);box-shadow:0 22px 48px -30px rgba(22,27,38,.4),inset 0 1px 0 rgba(255,255,255,.8)}#shell.app-light .navbtn.active{color:#0b1220}#shell.app-light .kick{color:var(--accent-text)}/* Solid accent fills keep their white foreground on both themes. Without this the selected chip and calendar day lose their fill and the white label disappears against the light surface. */ #shell.app-light .chip.active{background:var(--accent-ink);border-color:var(--accent-ink);color:var(--on-accent)}#shell.app-light .day.sel{background:var(--accent-ink);border-color:var(--accent-ink)}#shell.app-light .day.sel .dw,#shell.app-light .day.sel .dn,#shell.app-light .day.sel .dc{color:var(--on-accent)}#shell.app-light .mod.current .mk{color:var(--accent-text)}#shell.app-light #moreSheetPanel{background:#fff;border-top-color:rgba(22,27,38,.14);box-shadow:0 -20px 50px -22px rgba(22,27,38,.35)}/* -------------------------------------------------------------------------- The sandbox stays dark in both themes — it is a terminal,and a light terminal reads as a text field. Its foreground colours are therefore fixed rather than tokenised. -------------------------------------------------------------------------- */ .sandbox{background:linear-gradient(180deg,rgba(3,6,12,.94),rgba(3,6,12,.8))}.sandbox .shdr{color:rgba(238,245,248,.55)}.sandbox .ghost{color:#eef5f8;background:rgba(255,255,255,.07);border-color:rgba(255,255,255,.22)}.sandbox .ghost:hover{background:rgba(255,255,255,.13);border-color:var(--accent)}.sandbox input{color:#eef5f8}.sandbox input::placeholder{color:rgba(238,245,248,.4)}#shell.app-light .panel.sandbox{background:linear-gradient(180deg,rgba(3,6,12,.94),rgba(3,6,12,.8));border-color:rgba(255,255,255,.12)}#shell.app-light .sandbox .shdr{border-bottom-color:rgba(255,255,255,.14)}.pill-brand{background:var(--accent-soft);color:var(--accent-text)}#moreSheetPanel{background:var(--sheet);box-shadow:0 -20px 50px -20px rgba(0,0,0,.7)}/* Self-hosted webfonts. One family,three jobs. IBM Plex Sans sets the display and the interface;IBM Plex Mono sets data — ids,codes,counts,terminal output. Commissioned by IBM and used across their security tooling,it is drawn to look engineered rather than friendly:squared terminals,a low-contrast skeleton,no rounded warmth. That is the register this product wants. It replaced Archivo + Inter. Inter in particular is the single most-used interface typeface in modern SaaS,which is exactly why an interface set in it reads as a template rather than a product. Plex Sans is a variable font — ONE file per subset covering 400-700,so the weights are real instances and nothing is synthetically emboldened. Plex Mono is not variable,so it gets a file per weight. */ @font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url("../fonts/IBMPlexSans-latin.woff2") format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url("../fonts/IBMPlexSans-latin-ext.woff2") format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url("../fonts/IBMPlexMono-400-latin.woff2") format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url("../fonts/IBMPlexMono-400-latin-ext.woff2") format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url("../fonts/IBMPlexMono-500-latin.woff2") format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url("../fonts/IBMPlexMono-500-latin-ext.woff2") format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url("../fonts/IBMPlexMono-600-latin.woff2") format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url("../fonts/IBMPlexMono-600-latin-ext.woff2") format('woff2');unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}