/* Portfolio — Workshop Volt styling, ported from the M-OPS cockpit
   (apps/web/public/index.html). Tokens, mono/uppercase label idiom, 3px radii,
   1px panel borders and the scanline overlay are copied so the portfolio and
   the dashboard read as one system.

   The accent ramp (teal -> magenta -> gold -> blue -> red) is sampled from the
   silhouette artwork used as the portrait, so the chrome and the image share a
   palette instead of competing. */

:root{
  /* --- copied from M-OPS --- */
  --bg:#080c12; --panel:#0d1520; --border:#1a2d45;
  --accent:#00c8ff; --violet:#7b2dff; --warn:#ff6b35; --success:#00e5a0;
  --text:#c8dde8; --muted:#6b8496; --white:#e8f4ff;
  /* --muted was #4a6070 until 2026-09-09: 2.98:1 on --bg and 2.79:1 on --panel,
     which failed WCAG AA on 71 of 124 text nodes because this one token carries
     the section labels, the P/A/O labels and every stack tag. #6b8496 is the
     smallest lift that clears 4.5:1 on BOTH surfaces (5.01 and 4.69). Anything
     darker fails the panels, which is where the project cards live. */
  --mono:"SF Mono","Roboto Mono",Menlo,Consolas,monospace;
  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;

  /* --- sampled from the portrait artwork --- */
  --art-teal:#2ad3c8; --art-magenta:#e838d8; --art-gold:#e0a020;
  --art-blue:#1f47d8; --art-red:#e01f22;

  /* Portrait frame ratio — must match the source file, or object-fit:cover
     silently crops to fill it.

     Was 542/567 (0.956), the ratio of the ORIGINAL profile image. The current
     mypicture.jpg is 1212x1381 (0.878) — taller than that frame — so cover was
     scaling it to the frame's width and trimming the top and bottom off.
     Set to the source's own ratio 2026-08-27 so the whole photograph shows.

     Swapping the photo again? Re-measure and update this one line; nothing
     else needs to change. A ratio that does not match the file WILL crop. */
  --portrait-ratio: 1212/1381;

  /* The same ratio as a plain decimal. calc() cannot do arithmetic on the
     `1212/1381` form above, and --portrait-w below needs to convert a viewport
     HEIGHT budget into a width. Keep the two in step when swapping the photo. */
  --portrait-aspect: .878;

  /* Portrait width. Three ceilings, whichever bites first:
       380px  — it should never grow into a poster on a 27" display
       32vw   — it keeps its share of the row as the window widens
       66vh   — a short, wide window (a laptop with the dock and tab strip
                showing) used to push the whole bio below the fold, because the
                frame only ever answered to width. This is the one that fixes it.
     Overridden narrower in the <=760px stack below. */
  --portrait-w: min(380px, 32vw, calc(66vh * var(--portrait-aspect)));
}

*{box-sizing:border-box}

body{
  margin:0;background:var(--bg);color:var(--text);
  font-family:var(--sans);font-weight:300;
  -webkit-font-smoothing:antialiased;min-height:100vh;
}
/* scanline overlay — M-OPS signature */
body::after{
  content:"";position:fixed;inset:0;pointer-events:none;z-index:9999;
  background:repeating-linear-gradient(0deg,rgba(0,200,255,.025) 0 1px,transparent 1px 3px);
}
/* soft colour wash behind everything, echoing the artwork's gradient ground */
body::before{
  content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.5;
  background:
    radial-gradient(60vw 60vw at 12% 8%,  rgba(42,211,200,.10), transparent 60%),
    radial-gradient(55vw 55vw at 88% 4%,  rgba(232,56,216,.10), transparent 60%),
    radial-gradient(70vw 70vw at 50% 100%,rgba(31,71,216,.12),  transparent 65%);
}

.mono{font-family:var(--mono)}
.upper{text-transform:uppercase;letter-spacing:.18em}

/* ---------- topbar (M-OPS pattern) ---------- */
.topbar{
  position:sticky;top:0;z-index:50;
  display:flex;align-items:center;justify-content:space-between;gap:16px;
  padding:14px 22px;border-bottom:1px solid var(--border);
  background:linear-gradient(180deg,#0a121c,#080c12);
}
.brand{display:flex;align-items:center;gap:12px}
.logo{font-family:var(--mono);font-weight:700;letter-spacing:.28em;color:var(--white);font-size:18px}
.logo b{color:var(--accent);font-weight:700}
/* The wordmark carries .28em of tracking, which on a three-letter suffix reads as
   three loose letters rather than a domain. Pull it back in and drop it a size. */
.logo .tld{color:var(--art-teal);font-weight:400;font-size:.72em;letter-spacing:.04em;margin-left:.18em}
.dot{
  width:9px;height:9px;border-radius:50%;background:var(--success);
  box-shadow:0 0 10px var(--success);animation:pulse 2s infinite;flex:none;
}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@keyframes ring-spin{to{--ring-angle:360deg}}
@keyframes ring-in{from{opacity:0}to{opacity:1}}
@keyframes ring-out{from{opacity:1}to{opacity:0}}
.topright{display:flex;align-items:center;gap:14px;font-family:var(--mono);font-size:12px}
.badge{
  border:1px solid var(--success);color:var(--success);
  padding:3px 9px;border-radius:3px;font-size:10px;letter-spacing:.14em;white-space:nowrap;
}

/* ---------- layout ---------- */
.wrap{position:relative;z-index:1;max-width:1100px;margin:0 auto;padding:34px 22px 60px}
.section-label{font-family:var(--mono);font-size:11px;color:var(--muted);letter-spacing:.18em;
  text-transform:uppercase;margin:0 0 12px}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:3px;padding:22px;
  display:flex;flex-direction:column}

/* stretch, not start: the panel matches the portrait's height so the two columns
   align on BOTH edges. .links then uses margin-top:auto to sit on the bottom
   line, which is what makes the block read as deliberately aligned. */
/* The two columns hold a fixed 1:2.4 proportion instead of a pixel width, so the
   relationship between picture and panel is the same at 900px and at 1900px —
   only the scale changes. 2 is not arbitrary: measured against the panel's real
   height it puts the frame within a few percent of the photograph's own 0.878
   ratio, so `cover` has almost nothing to crop. The 240px floor stops the
   picture collapsing to a stripe before the layout stacks. */
.hero{display:grid;grid-template-columns:minmax(240px,1fr) minmax(0,2fr);gap:26px;align-items:stretch}

/* Stacks at 1040px, not at the phone breakpoint. Below roughly 1080 the panel's
   text starts wrapping to more lines while its column keeps narrowing, so the
   frame — which takes its height from that panel — stretches into a tall stripe
   and `cover` has to cut the sides off. Measured: 1.1% cropped at 1100px, 10.8%
   at 1000, 20.4% at 900, 38.6% at 820. So the two-column form is kept only where
   it actually holds its proportion; below that the picture goes back to its own
   0.878 ratio, centred, cropping nothing. Tablets stack — that is the trade. */
@media(max-width:1040px){
  .hero{grid-template-columns:1fr;--portrait-w:min(72vw,calc(46vh * var(--portrait-aspect)))}
  /* 46vh is what keeps this sane on a laptop-shaped window at, say, 1000x800:
     72vw would be 720px of face. The vh term takes it to ~323px instead. */
  .portrait{align-self:start;justify-self:center;width:100%;max-width:var(--portrait-w);
    aspect-ratio:var(--portrait-ratio);min-height:0}
}

/* ---------- portrait ----------
   The frame takes its ratio from --portrait-ratio above, which is set to the
   source photograph's own dimensions so the image is never cropped. Because
   the ratio is declared, the frame's height is known before the file loads —
   the layout still does not shift on slow connections. */
/* align-self:start, not the grid's default stretch. The frame used to be
   stretched to the panel's full height while the image inside kept its own
   aspect ratio — so the gradient that was painted on the frame showed as a
   loose colour band under the photo. The gradient is gone now (below), but the
   ring pseudo-elements are pinned to this box's edges, so it has to end where
   the picture ends or the ring floats free of it. */
.portrait{
  position:relative;align-self:stretch;min-height:260px;
  border-radius:4px;
  background:var(--panel);border:1px solid var(--border);
  /* The gradient frame and its blurred bloom were removed 2026-09-04. The
     photograph is already five saturated colours; a sixth behind it was
     competing with it rather than framing it. The rotating ring below carries
     the accent instead, and it leaves once the bio has finished typing. */

  /* Drives the arc's position. Declared here as well as in @property so that a
     browser without registered custom properties still resolves the conic
     gradient and shows a static ring, rather than dropping it entirely. */
  --ring-angle:0deg;
  animation:ring-spin 1.9s linear infinite;
}

/* ---------- loading ring ----------
   Two copies of one clockwise arc: ::before is the crisp 2px band, ::after is
   the same arc blurred and pushed behind the frame so the glow spills outward
   only. Both are masked to a ring — a filled box XOR its own content box —
   which keeps the band an even 2px at any frame size, unlike a border. */
@property --ring-angle{syntax:"<angle>";initial-value:0deg;inherits:true}

.portrait::before,.portrait::after{
  content:"";position:absolute;inset:-3px;border-radius:6px;pointer-events:none;
  background:conic-gradient(from var(--ring-angle),
    transparent 0 52%,
    rgba(42,211,200,.28) 68%,
    var(--art-teal) 82%,
    var(--accent) 92%,
    var(--white) 98%,
    transparent 100%);
  padding:2px;
  -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;
          mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
          mask-composite:exclude;
  opacity:0;
  /* Fades in on load, and out again when script.js adds .ring-off — the moment
     the last character of the bio lands. The 20s leg is a dead-man's switch: if
     the typewriter throws, the ring still leaves instead of spinning forever.
     Typing runs ~6.5s, so that is 3x headroom — deliberately loose, because a
     background tab throttles setInterval to roughly 1 tick a second and the
     ring should not vanish while the text is genuinely still arriving. */
  animation:ring-in .45s ease-out .1s forwards, ring-out .8s ease-in 20s forwards;
}
.portrait::after{inset:-6px;padding:4px;filter:blur(10px);z-index:-1}

.portrait.ring-off::before,.portrait.ring-off::after{animation:ring-out .7s ease-in forwards}
/* set a beat after .ring-off, once the fade has played: stops the spin timer and
   drops the two gradients rather than leaving them repainting at opacity 0 */
.portrait.ring-gone{animation:none}
.portrait.ring-gone::before,.portrait.ring-gone::after{display:none}
/* contain, not cover: with the frame set to the source's own ratio the two are
   identical and either would do — but contain fails safe. If the photo is later
   swapped for one of a different shape and the ratio above is not updated, this
   letterboxes the image instead of quietly cutting the head off. */
/* Absolute, so the image contributes no intrinsic height: the row's height is
   decided by the panel's text alone, and the frame then matches it exactly. That
   is what squares the two blocks off at the bottom as well as the top.
   `cover` replaces the old `contain` because the frame no longer carries the
   photograph's ratio — with the column proportioned as above the crop is a few
   pixels off the sides. object-position sits above centre so that if a narrower
   window ever does crop vertically, it takes it off the feet, not the heads.
   No border-radius clipping via overflow:hidden — that would cut the ring, which
   is drawn 3px outside this box. The radius goes on the image itself. */
.portrait img{
  position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;object-position:center 34%;
  border-radius:3px;background:var(--panel);
}

/* ---------- identity block ---------- */
/* align-self is required: .panel is a flex column, so a flex child stretches to
   full width by default and the pill would span the panel. */
.eyebrow{display:inline-block;align-self:flex-start;font-family:var(--mono);font-size:10px;letter-spacing:.2em;
  text-transform:uppercase;color:var(--bg);background:var(--accent);padding:4px 10px;border-radius:3px}
h1.name{font-family:var(--mono);font-weight:700;font-size:clamp(30px,5vw,46px);line-height:1.05;
  color:var(--white);margin:16px 0 6px;letter-spacing:.02em}
h1.name b{color:var(--accent);font-weight:700}
.role{font-family:var(--mono);font-size:13px;letter-spacing:.16em;text-transform:uppercase;
  color:var(--art-teal);margin:0 0 20px}
.rule{height:1px;background:var(--border);margin:20px 0}
/* Typewriter slot. script.js clones the full text into a hidden ghost that holds
   the block open at its FINAL height, then types into an absolutely-positioned
   copy on top. The panel border is therefore static from first paint — it never
   grows line-by-line as the text arrives — and because the ghost is real text it
   stays correct at every viewport width, unlike a hard-coded min-height. */
.type-slot{position:relative}
.type-slot > *{display:block;font-size:15px;line-height:1.75;color:var(--text)}
.type-slot .ghost{visibility:hidden}
.type-slot #title{position:absolute;top:0;left:0;right:0}
#title::after{content:"▌";color:var(--accent);animation:pulse 1.1s infinite}
/* no-JS fallback: if the ghost never gets built, the text still shows normally */
.type-slot:not(:has(.ghost)) #title{position:static}

/* ---------- link row ---------- */
/* margin-top:auto anchors the row to the panel's bottom edge, so it lines up
   with the base of the portrait instead of floating under the text. */
.links{display:flex;flex-wrap:wrap;gap:10px;margin-top:auto;padding:24px 0 0;list-style:none}
.links a{
  display:inline-flex;align-items:center;gap:9px;text-decoration:none;
  font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 14px;
  background:rgba(255,255,255,.02);transition:border-color .18s,color .18s,background .18s;
}
.links a:hover{border-color:var(--accent);color:var(--white);background:rgba(0,200,255,.07)}
.links a i{font-size:14px;color:var(--muted);transition:color .18s}
.links a:hover i{color:var(--accent)}
.links a.resume{border-color:rgba(0,229,160,.45);color:var(--success)}
.links a.resume:hover{border-color:var(--success);background:rgba(0,229,160,.08);color:var(--white)}
.links a.resume i{color:var(--success)}

footer{margin-top:40px;padding-top:18px;border-top:1px solid var(--border);
  font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);
  display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}


/* ============================================================================
   PAGE BODY — added 2026-08-27, from docs/reference-layout.html
   Structure per docs/PORTFOLIO-RESEARCH.md §3. Everything below reuses the
   tokens already declared above; no new colours, radii or fonts are invented.
   ========================================================================= */

/* ---------- section nav ----------
   The page is now several screens long, so the topbar earns in-page links.
   Hidden below 700px rather than turned into a burger: four anchors on a page
   this short do not justify a menu. */
.navlinks{display:flex;gap:18px;font-family:var(--mono);font-size:10px;letter-spacing:.16em;
  text-transform:uppercase}
.navlinks a{color:var(--muted);text-decoration:none;transition:color .18s}
.navlinks a:hover,.navlinks a:focus-visible{color:var(--accent)}
@media(max-width:700px){.navlinks{display:none}}
.topright{gap:18px}

/* Sticky topbar means an anchor jump would otherwise land under the header.
   --head-h is the measured height at the widest case (nav + badge on one row). */
:root{--head-h:58px}
[id]{scroll-margin-top:calc(var(--head-h) + 14px)}

.block{margin:56px 0 0}
.skip{position:absolute;left:-9999px}
.skip:focus{left:22px;top:10px;z-index:60;background:var(--accent);color:var(--bg);
  font-family:var(--mono);font-size:11px;letter-spacing:.14em;padding:8px 12px;border-radius:3px}
main:focus{outline:none}

/* ---------- work bento grid ----------
   A .card is the existing .panel with a hover state — a project tile was
   already designed, it just had nothing in it. The feature tile spans two
   columns so M-OPS reads as the headline piece without a second treatment. */
.bento{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}
.bento .card{grid-column:span 1}
.bento .card.feature{grid-column:span 2}
@media(max-width:860px){
  .bento{grid-template-columns:repeat(2,minmax(0,1fr))}
  .bento .card.feature{grid-column:span 2}
}
@media(max-width:560px){
  .bento{grid-template-columns:1fr}
  .bento .card.feature{grid-column:span 1}
}
.card{background:var(--panel);border:1px solid var(--border);border-radius:3px;padding:20px;
  display:flex;flex-direction:column;gap:12px;transition:border-color .18s,transform .18s}
.card:hover{border-color:var(--accent);transform:translateY(-2px)}
.card h3{font-family:var(--mono);font-size:15px;letter-spacing:.06em;color:var(--white);
  margin:0;font-weight:700}
.card .kind{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;
  color:var(--art-teal)}
.card.feature .thumb{width:100%;aspect-ratio:16/7;background:
  linear-gradient(150deg,rgba(42,211,200,.14),rgba(31,71,216,.18) 60%,rgba(232,56,216,.12));
  border:1px solid var(--border);border-radius:2px;display:flex;align-items:center;
  justify-content:center;font-family:var(--mono);font-size:10px;letter-spacing:.18em;
  color:var(--muted);text-align:center;padding:0 12px}

/* Problem / Approach / Outcome — research §1.3. Structural rather than a
   writing convention, so the frame cannot be quietly skipped when a tile is
   written. The 64px label column keeps the three sentences left-aligned. */
.pao{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:7px}
.pao li{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;font-size:13px;line-height:1.55}
.pao b{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--muted);padding-top:3px;font-weight:400}
.stack{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:4px}
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
  color:var(--muted);border:1px solid var(--border);border-radius:3px;padding:3px 7px}

/* Demo is styled as the primary action — research §1.1, live demos outrank
   repos. .na carries the private-work case in warn orange: saying "private"
   plainly reads better than omitting the project. */
.cardlinks{display:flex;gap:8px;flex-wrap:wrap;padding-top:12px;border-top:1px solid var(--border)}
.cardlinks a,.cardlinks span{font-family:var(--mono);font-size:10px;letter-spacing:.14em;
  text-transform:uppercase;text-decoration:none;color:var(--muted);border:1px solid var(--border);
  border-radius:3px;padding:6px 10px;transition:color .18s,border-color .18s}
.cardlinks a:hover{color:var(--white);border-color:var(--accent)}
.cardlinks a.demo{color:var(--accent);border-color:rgba(0,200,255,.4)}
.cardlinks .na{color:var(--warn);border-color:rgba(255,107,53,.35)}

/* ---------- skills strip ---------- */
.skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:16px}
.skillgroup h4{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;
  color:var(--art-teal);margin:0 0 10px;font-weight:400}
.skillgroup ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}
.certs{margin:20px 0 0;padding-top:18px;border-top:1px solid var(--border)}
.certs .tag{color:var(--success);border-color:rgba(0,229,160,.35)}
.certs .tag.wip{color:var(--muted);border-color:var(--border)}

/* ---------- experience ---------- */
.xp{list-style:none;margin:0;padding:0}
.xp li{display:grid;grid-template-columns:150px minmax(0,1fr);gap:18px;padding:14px 0;
  border-bottom:1px solid var(--border)}
.xp li:last-child{border-bottom:0}
.xp .when{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--muted);padding-top:3px}
.xp .what{font-size:14px;line-height:1.6}
.xp .what b{color:var(--white);font-weight:600;display:block;font-family:var(--mono);font-size:13px;
  letter-spacing:.03em;margin-bottom:3px}
/* .xp--plain: a single entry with no date column.
   MUST stay below `.xp li` — both selectors are specificity (0,1,1), so the one
   that wins is whichever comes last. Placed above it first and the role wrapped
   inside the empty 150px date column. */
.xp--plain li{grid-template-columns:minmax(0,1fr);gap:0}
@media(max-width:560px){.xp li{grid-template-columns:1fr;gap:5px}}

/* ---------- contact ---------- */
.contact{text-align:center;padding:34px 22px}
.contact h2{font-family:var(--mono);font-size:clamp(20px,3.5vw,28px);color:var(--white);
  margin:0 0 10px;letter-spacing:.02em}
.contact p{margin:0 0 22px;font-size:14px;color:var(--text)}
.contact .links{justify-content:center;margin-top:0;padding-top:0}

/* ---------- mailing list ---------- */
.signup{margin:26px auto 0;max-width:440px;text-align:left}
.signup-label{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.2em;
  text-transform:uppercase;color:var(--muted);margin-bottom:9px;text-align:center}
.signup-row{display:flex;gap:8px}
@media(max-width:420px){.signup-row{flex-direction:column}}
.signup input[type=email]{
  flex:1;min-width:0;background:var(--bg);color:var(--white);
  border:1px solid var(--border);border-radius:3px;padding:10px 12px;
  font-family:var(--mono);font-size:12px;letter-spacing:.04em;
}
.signup input[type=email]::placeholder{color:var(--muted)}
.signup input[type=email]:focus-visible{outline:none;border-color:var(--accent)}
.signup button{
  font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
  color:var(--bg);background:var(--accent);border:1px solid var(--accent);
  border-radius:3px;padding:10px 18px;cursor:pointer;white-space:nowrap;
}
.signup button:hover:not(:disabled){background:var(--art-teal);border-color:var(--art-teal)}
.signup[data-state="off"] input,.signup[data-state="off"] button,.signup button:disabled{
  opacity:.45;cursor:not-allowed}
/* Off-screen rather than display:none — a bot reading the DOM fills anything it
   can parse, and hidden inputs are the first thing a headless script skips. */
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
.signup-note{min-height:1.2em;margin:10px 0 0;font-family:var(--mono);font-size:11px;
  letter-spacing:.04em;color:var(--muted);text-align:center}
.signup-note[data-tone="ok"]{color:var(--success)}
.signup-note[data-tone="err"]{color:var(--warn)}

@media(prefers-reduced-motion:reduce){
  .dot,#title::after{animation:none}
  .card:hover{transform:none}
  /* No sweep and no glow — a still accent arc that still fades out on cue. */
  .portrait{animation:none}
  .portrait::before{animation:none;opacity:.55}
  .portrait::after{display:none}
}
