/* 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) for the original photo, then 1212/1381 (0.878) for the
     one after it. The image is SQUARE as of 2026-09-26 (564x564, the neon
     avatar glyph), so the frame is square too.

     The first attempt that day kept the 0.878 frame and padded the file with
     black bars to match it. That is the wrong half of the trade: the bars are
     real pixels, so the artwork's own black rectangle sat inside a frame that
     is lighter than it, reading as a black box dropped on the page rather than
     a portrait. Matching the frame to the file is what this line is for.

     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: 1/1;

  /* The same ratio as a plain decimal. calc() cannot do arithmetic on the
     `1/1` 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: 1;

  /* 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}

/* Anchor jumps glide instead of teleporting. scroll-padding-top does the same job
   as the scroll-margin rule further down but for the scroll container itself, so a
   target never lands under the sticky topbar. Both are switched off under
   prefers-reduced-motion at the foot of this file. */
html{scroll-behavior:smooth;scroll-padding-top:calc(var(--head-h) + 14px)}

body{
  margin:0;background:var(--bg);color:var(--text);
  font-family:var(--sans);font-weight:300;
  -webkit-font-smoothing:antialiased;min-height:100vh;
  /* dvh after vh, in that order: on a phone 100vh is the height the viewport has
     with the browser chrome HIDDEN, so a vh-sized page is always taller than what
     is actually on screen and the address bar shows/hides against a page that was
     already long enough. dvh is the live value. vh stays first as the fallback. */
  min-height:100dvh;
  /* Safari inflates text in landscape unless this is set, which silently undoes
     every size decision below. 100% keeps the user's own zoom working. */
  -webkit-text-size-adjust:100%;text-size-adjust:100%;
}
/* 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. */
/* Side by side from 700px, stretched, 1:2. Tried 1.6fr with a centred square on
   2026-10-04 and reverted it: no column ratio stays flush as the page narrows,
   because the panel grows a line of wrapped bio exactly when the square loses
   height. Measured at 760px the gap held at 162px whatever the ratio. The frame
   is stretched again and the letterbox is dealt with where it belongs, on the
   frame's own background. See .portrait. */
.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, and that is the trade. */
/* Stacks at 700px, not 1040px, since 2026-10-04. At 1040 a 900x820 window fell
   into the stacked layout and gave the portrait 377x377, 46% of the fold, with
   the name pushed 570px down. 900px is a laptop, not a phone, and it has room
   for both columns: at the 700px floor the grid still resolves to a 240px
   portrait beside a 390px panel. Measured, not guessed. */
@media(max-width:700px){
  .hero{grid-template-columns:1fr;--portrait-w:min(72vw,calc(46vh * var(--portrait-aspect)))}
  /* 40vh rather than the old 46vh for the same reason the phone band was cut to
     38vh: once this layout is reached the screen is genuinely small, and the
     frame holds a decorative avatar glyph rather than a photograph. */
  /* The min-height:0 here LOSES to the 260px floor in the base .portrait rule
     below: both selectors are (0,1,0) and that one comes later in the file, and a
     media query adds no specificity. The override that actually bites is the one
     immediately after that rule. Left in place because it is correct on its own
     terms and harmless. */
  .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{
  /* align-self:stretch, and the letterbox is painted rather than avoided.
     The frame takes the panel's height while the picture keeps its 1:1, so
     object-fit:contain leaves a band above and below. That was invisible while
     the artwork's edges were pure black. The rebuilt glyph is not: sampled off
     the 1024 export, its corners are rgb(5,10,5) but the middle of the top and
     bottom edges is rgb(10,32,13), because the ring's bloom reaches the border.
     Against that, a flat black band reads as two bars, which is what it did.
     Squaring the frame removes the band but costs the side-by-side alignment,
     and cover-cropping clips the ring, which starts 6% in from the edge.
     So the band stays and is filled with a gradient measured off that same edge.
     Re-sample if the artwork changes:
       canvas.drawImage(img,0,0); getImageData(x,0,1,1) across the top row.
  */
  position:relative;align-self:stretch;min-height:260px;
  border-radius:4px;
  background:linear-gradient(90deg,
    rgb(5,10,5) 0%, rgb(5,10,5) 16%, rgb(3,20,8) 31%, rgb(9,30,13) 50%,
    rgb(3,20,7) 69%, rgb(5,10,5) 84%, rgb(5,10,5) 100%);
  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;
}
/* Drop the 260px floor as soon as the hero has stacked. Once the frame carries
   aspect-ratio its height is derived from its width, so a floor no longer
   enlarges the frame, it stretches it out of ratio and `cover` crops to fill.
   Measured on a phone held sideways (812x375): the frame came out 151x260, an
   0.58 ratio against the photograph's 0.878, taking roughly a third off each
   side of the face. Must sit AFTER the rule above, not inside the @media block
   further up, or the base 260px wins on source order. */
@media(max-width:700px){.portrait{min-height:0}}

/* ---------- 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, at 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, and 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, because that would cut the ring, which
   is drawn 3px outside this box. The radius goes on the image itself. */
/* contain, not cover, since 2026-09-26.

   The frame is align-self:stretch on desktop (see .hero above - the two columns
   are meant to end level), so its height comes from the PANEL, not from
   --portrait-ratio. Measured at 1280x800: frame 343x409, ratio 0.84, against a
   square file - `cover` was taking 16% off each side and cutting the flat edges
   off the glyph's glow ring. --portrait-ratio only governs the stacked layout
   below 1040px, where the frame does carry aspect-ratio.

   `contain` shows the whole image at any frame height, and the background here
   is the artwork's own black rather than var(--panel), so the letterbox above
   and below it is invisible instead of reading as two grey bands. Change that
   black if a photo with lighter edges ever replaces this glyph.

   object-position is plain `center` for the same reason: `center 34%` existed to
   hold a face near the top of a cover crop, and there is no crop left to steer. */
/* <picture> added 2026-10-04 for the WebP. It is an inline box in the frame's
   flow, so it has to be laid out like the img was or the absolutely positioned
   img inside it has nothing to fill. */
.portrait picture{position:absolute;inset:0;display:block}
.portrait img{
  position:absolute;inset:0;width:100%;height:100%;
  /* cover since 2026-10-04, once avatar.png became the PADDED export from
     brand-os (build-avatar-padded.py, 1556x1556, PAD=266). The crop now eats
     266px of deliberate margin on each side instead of artwork, so a frame of
     any aspect fills edge to edge with the grid and the ground continuing into
     it. contain + a painted letterbox was the previous answer and is no longer
     needed; the gradient left on .portrait below is now only a fallback for the
     moment before the file paints. Swapping back to the square avatar@1024.png
     WILL reintroduce the bands, so change this to contain if you ever do. */
  object-fit:cover;object-position:center;
  border-radius:3px;background:transparent;
  /* was #000. The frame below now carries a gradient sampled from the artwork's
     own edge, and a black fill here would sit on top of it. */
}

/* ---------- 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}
/* "Malik" carries a blue-to-white gradient. The flat --accent below is NOT
   dead code: it is the fallback, and it has to stay first.

   background-clip:text only shows through because the glyphs are painted with
   -webkit-text-fill-color:transparent. Anywhere that pair is unsupported, the
   fill still applies and the name renders INVISIBLE, which is why the gradient
   is fenced behind @supports rather than declared inline.

   The blue stop is #2f8fff rather than --art-blue (#1f47d8). The token is the
   right hue but only about 2.3:1 on --panel, under the 3:1 that large text
   needs; #2f8fff clears it, and the gradient still reads as blue into white.
   Angle is 100deg so the sweep runs slightly upward across the word instead of
   flatly left to right. */
h1.name b{color:var(--accent);font-weight:700}

@supports ((-webkit-background-clip:text) or (background-clip:text)) {
  h1.name b{
    background:linear-gradient(100deg,#2f8fff 0%,var(--accent) 45%,var(--white) 100%);
    -webkit-background-clip:text;
            background-clip:text;
    -webkit-text-fill-color:transparent;
  }
}
.role{font-family:var(--mono);font-size:13px;letter-spacing:.16em;text-transform:uppercase;
  color:var(--art-teal);margin:0 0 20px}
/* The three disciplines are spans and the separators are generated, because the
   line used to be one string glued with &nbsp; around literal pipes. That glue
   left exactly one break opportunity in the whole line, the space inside
   "DevSecOps Professional", so on a phone it wrapped to
   "SOFTWARE | DEVSECOPS / PROFESSIONAL | AI" and split a job title in half.
   nowrap on each span plus a real space between them moves the only break
   opportunities to the separators, which is where a list should break.
   ::after rather than ::before so the pipe stays on the line it closes. */
.role span{white-space:nowrap}
.role span:not(:last-child)::after{content:"|";margin-left:.5em}
/* Location, sitting under the role line. Deliberately quieter than .role:
   same mono uppercase idiom, one step down in size and in --muted rather than
   --art-teal, so it reads as a qualifier of the discipline above it and not as
   a second job title. .role drops its bottom margin to 6px when this is
   present, so the two lines group as one block against the rule below. */
.role:has(+ .location){margin-bottom:6px}
.location{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
  color:var(--muted);margin:0 0 20px;display:flex;align-items:center;gap:10px}
/* An emoji rather than a Font Awesome glyph, by request 2026-09-13. It also
   drops a webfont dependency from the one line most likely to be read, and it
   renders identically whether or not the FA script loads.

   The pin needs its own optical nudge: the glyph carries whitespace below its
   point, so flex centring leaves it sitting high against uppercase mono. 1px
   down lines it up with the cap height. aria-hidden in the markup, because the
   text beside it already says the same thing to a screen reader. */
.location .pin{font-size:13px;line-height:1;position:relative;top:1px}

.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)}
/* Inline SVG icons replaced the FontAwesome kit on 2026-10-04. These mirror the
   .links a i rules above, which are kept only so a stray <i> would still render. */
.links a svg{width:14px;height:14px;flex:none;fill:var(--muted);transition:fill .18s}
.links a:hover svg{fill:var(--accent)}
.links a.resume svg{fill:var(--success)}
.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)}
/* Was display:none below 700px until 2026-10-04, which left a phone with no way
   to reach Work, Skills or Contact except by scrolling the whole page. Three
   one-word links fit: at 10px mono they measure about 165px against a 375px
   screen once the logo is allowed to shrink alongside them. Tightened again at
   400px rather than hidden, because a nav that disappears is worse than a nav
   that is small. */
@media(max-width:700px){
  .navlinks{gap:13px;font-size:9.5px;letter-spacing:.12em}
  .topbar .logo{font-size:15px;letter-spacing:.2em}
}
@media(max-width:400px){
  .navlinks{gap:10px;letter-spacing:.08em}
  .topbar .logo{font-size:13px;letter-spacing:.14em}
}
.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}
/* min-width:0 is load-bearing, do not drop it.

   A grid item defaults to min-width:auto, i.e. it refuses to be narrower than its
   own min-content. The architecture diagram below carries min-width:520px, so on a
   375px phone every card in this grid was sized 562px wide and the PAGE scrolled
   sideways: the P/A/O prose in all four cards ran off the right edge and the site
   read as broken. The diagram keeps its 520px inside its own scroller; the card it
   sits in must be allowed to be narrower than its contents for that to work. */
.bento .card{grid-column:span 1;min-width:0}
.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){
  /* minmax(0,1fr), not 1fr: a bare 1fr is minmax(auto,1fr) and its auto floor is
     the widest thing inside it, which is the 520px diagram. Same fix as above,
     the track side of it. */
  .bento{grid-template-columns:minmax(0,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}

/* One-line result, sitting between the title and the P/A/O block. Added
   2026-09-12 from the hiring research in docs/WRITE-UP-RESEARCH.pdf: a reviewer
   gives a work grid seconds, not minutes, and decides inside that scan whether
   to read further. Leading with Problem spends that scan on the setup.

   --white rather than --text so it out-ranks the three bullets beneath it
   without needing a second font size. Deliberately no bold: the weight comes
   from position, and the page already carries enough uppercase mono. */
.card .headline{margin:0;font-size:13.5px;line-height:1.5;color:var(--white)}

/* 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}
/* Architecture diagram, added 2026-09-12. Sits between the P/A/O block and the
   stack tags, which fills the vertical slack the wide feature tile always had.
   .stack keeps margin-top:auto below, so the tags still pin to the card floor.

   The min-width plus overflow-x is the whole responsive story: the diagram
   carries 8px mono labels, and below roughly 520px they stop being readable
   rather than merely small. Scrolling a diagram sideways is normal; shrinking
   one into illegibility is not. Loaded with <img> rather than inlined, so it
   can be reused in the README and the docs PDF, which is also why its colours
   are hardcoded rather than reading the tokens above. */
/* overscroll-behavior-x:contain stops a sideways flick that runs out of diagram
   from chaining into the page (on iOS that is the back-swipe gesture). The
   diagram scrolls; the page does not move under it. */
.card .arch{margin:2px 0 0;overflow-x:auto;overscroll-behavior-x:contain;
  -webkit-overflow-scrolling:touch;padding-bottom:2px}
.card .arch img{display:block;width:100%;height:auto;min-width:520px}
.card .arch figcaption{font-family:var(--mono);font-size:9px;letter-spacing:.1em;
  color:var(--muted);padding-top:8px}

.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 ---------- */
/* Grid with auto-fit, align-items:start. Two earlier shapes were wrong:
     - plain grid stretched every row to its tallest group, so DevOps, Cloud and
       Languages each carried six rows of dead space down to Security's bottom
     - multi-column packed better but WILL NOT SPREAD here. Measured on the live
       page at a 1010px panel: forcing column-count to 2, 3, 4 or 5 put content
       in exactly two columns every time, leaving half the panel empty. Chrome's
       balancer is greedy, and Security is one unbreakable 276px block against
       four 94px ones, so it fills a tall first column and stops.
   auto-fit + align-items:start fixes both. Each group is its own cell and sits
   at the top of its row rather than stretching, and the track count follows the
   width. Measured at the same 1010px: 3 columns, 298px tall, full width used.
   260px is the floor because Security carries the longest labels on the page,
   "VULNERABILITY MANAGEMENT" and "BUSINESS CONTINUITY / DR". Below roughly 260
   those wrap inside their own tag, which reads as a broken chip rather than a
   narrow one. min(100%,260px) keeps that floor from forcing overflow once the
   panel itself is narrower than 260. */
.skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));
  gap:16px;align-items:start}
.skills > .skillgroup{margin:0}
.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)}
/* Verification link under the certifications list, added 2026-10-04. Deliberately
   quiet: it is evidence for a sceptical reader, not a call to action. */
.certs-verify{margin:12px 0 0;font-family:var(--mono);font-size:10px;
  letter-spacing:.12em;text-transform:uppercase}
.certs-verify a{color:var(--muted);text-decoration:none;
  border-bottom:1px solid var(--border);padding-bottom:2px;transition:color .18s,border-color .18s}
.certs-verify a:hover{color:var(--success);border-color:var(--success)}

/* ---------- 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`, because 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)}

/* ============================================================================
   PHONES: added 2026-09-13. Everything above is written for a window; this
   block is written for a hand.

   The page was not broken on mobile in one place, it was broken in five, and
   only the first of them was visible: the architecture diagram sized the whole
   work grid to 562px, so a 375px phone scrolled sideways and the project prose
   ran off the edge. That one is fixed at source (see .bento .card min-width:0
   and the minmax(0,1fr) tracks above) rather than patched here with
   overflow-x:hidden, which would have hidden the symptom and kept the cause.

   What is left below is proportion and reach: chrome that takes a smaller share
   of a small screen, a label column that stops stealing a third of every
   sentence, and controls a thumb can actually hit.
   ========================================================================= */

/* The topbar loses its nav below 700px and stands 50px, not the 58px measured
   with the nav in it. --head-h feeds both the anchor offset and the smooth-scroll
   padding, so a stale value parks every jump 8px too low. */
@media(max-width:700px){
  :root{--head-h:50px}

  /* The 72px label column costs a third of the line on a narrow card, and the
     three labels are one word each. Stacked, the sentence gets the full width
     and the label still reads as a label because it keeps the mono/uppercase
     idiom. Applies from 700px because the cards are still two-up at 560-860. */
  .pao li{grid-template-columns:minmax(0,1fr);gap:1px}
  .pao b{padding-top:0}
}

@media(max-width:560px){
  /* Page gutters: 22px of the 375px screen was 12% of it spent on margin.
     16px holds the panel edge clear of the bezel and gives the cards 12px more
     line length, which is roughly two words per line of body copy. */
  /* The certification labels are the longest strings on the page: "AI FLUENCY
     FRAMEWORK & FOUNDATIONS (ANTHROPIC)" is 46 characters against 24 for the
     longest skill tag. At the shared 10px/.12em every one of them took a full
     row on a phone, so the block read as a list rather than as chips. Dropping
     to 9px/.07em lets the shorter ones pair up and takes rows off the block.
     The skill tags are NOT touched: they already pack two to four per row, and
     shrinking them would cost legibility for nothing. */
  .certs .tag{font-size:9px;letter-spacing:.07em;padding:3px 6px}

  .wrap{padding:26px 16px 46px}
  .topbar{padding:13px 16px}
  .block{margin:40px 0 0}
  .panel{padding:18px}
  .card{padding:16px;gap:11px}
  .contact{padding:26px 18px}

  /* The portrait and the panel below it are one block, so they should share an
     edge. It was 72vw centred inside a full-width panel, i.e. 30px narrower on
     each side than the thing it sits above, which reads as a mistake rather
     than as inset. Full column width, with a height ceiling so a short screen
     (a phone in landscape) does not hand the whole viewport to a photograph. */
  /* 52vh until 2026-10-04. On a 375x812 phone that resolved to a 343x390 frame,
     48% of the first screen, and on a 667px-tall SE it was 52%, which pushed the
     name and the three disciplines below the fold. The thing filling it is a
     decorative avatar glyph, not a photograph of a person, so it was buying very
     little with half the viewport. 38vh puts the frame near 309px tall and lifts
     "I'm Malik" and the role line back above the fold on both sizes. */
  .hero{gap:18px;--portrait-w:min(100%,calc(52vh * var(--portrait-aspect)))}

  h1.name{margin:14px 0 6px}
  /* 11.5px/.1em is the largest setting at which the three disciplines and both
     separators fit one line at 375px. A size above this wraps "AI" onto a line
     of its own under a trailing pipe, which reads as a fourth, empty item.
     Narrower than 360px it does wrap, and the spans above make sure it wraps
     between disciplines rather than through one. */
  .role{font-size:11.5px;letter-spacing:.1em}
  .type-slot > *{font-size:14.5px;line-height:1.7}
  .card .headline{font-size:14px}
  .pao li{font-size:13.5px;line-height:1.6}

  /* Reach. A 34px chip is a miss-and-retry on a phone; 44px is the floor both
     Apple and WCAG 2.5.5 give, and these are the only things on the page a
     visitor is meant to tap. The text metrics do not change, only the box. */
  .links a{min-height:44px;padding:10px 14px}
  .cardlinks a,.cardlinks span{display:inline-flex;align-items:center;min-height:38px}

  /* The diagram is the one thing here that legitimately scrolls sideways, so
     say so. The caption is the only place to put it that cannot itself be
     mistaken for part of the drawing. */
  .card .arch figcaption::after{content:" Scroll the diagram sideways to read it.";
    color:var(--art-teal)}

  /* A column, not a centred row: space-between put the name and the location at
     opposite ends of a 343px line, and centring them ran the two together as one
     sentence ("Malik Adebiyi Toronto - Canada"). Stacked, each is its own line. */
  footer{flex-direction:column;align-items:center;text-align:center;gap:7px}
  .signup button{min-height:44px;font-size:12px}
}

/* iOS Safari zooms the page when a text field smaller than 16px takes focus, and
   it does not zoom back out: the visitor is left on a page that no longer fits
   and has to pinch their way back. 16px is the threshold, so the one field on
   this page gets it. Everything else about the field stays as it is. */
@media(max-width:820px){
  .signup input[type=email]{font-size:16px;padding:11px 12px}
}

/* 320px, the narrowest screen still in use (an iPhone SE). Not a separate design,
   just one more turn of the same screw: the gutters come in again so the cards
   keep a usable line length. */
@media(max-width:360px){
  /* The brand and the three nav links stop sharing a line here, so the tld goes
     rather than the nav. "MALIKBIYI" still reads as the mark; a nav that is not
     there reads as a page with no sections. */
  .topbar .logo .tld{display:none}
  .wrap{padding:22px 13px 40px}
  .card{padding:14px}
  .panel{padding:15px}
  .links a{padding:10px 12px;letter-spacing:.1em}
}

@media(prefers-reduced-motion:reduce){
  /* Smooth scrolling is motion too: an anchor jump animating the whole page is
     exactly what this query is asked to stop. */
  html{scroll-behavior:auto}

  .dot,#title::after{animation:none}
  .card:hover{transform:none}
  /* No sweep and no glow, just a still accent arc that still fades out on cue. */
  .portrait{animation:none}
  .portrait::before{animation:none;opacity:.55}
  .portrait::after{display:none}
}

/* ============================================================================
   CONTAINER LAYER, added 2026-10-04
   ============================================================================
   Everything above this line sizes against the viewport. That is what produced
   the 1040px cliff: a 900x820 window is a laptop, but it matched a phone rule
   and handed 46% of the fold to a decorative avatar.

   The fix is to ask the right question. A card does not care how wide the
   screen is, it cares how wide IT is. Two cards side by side in an 1100px page
   are each ~540px and should lay out like a 540px thing, not like an 1100px one.

   This block is purely additive and sits last on purpose:
     - browsers with @container support apply these and they win on source order
     - browsers without it ignore the whole block and keep the media queries above
   So the media queries above are the fallback, not dead code. Keep them.

   cqi = 1% of the container's inline size, the container-relative answer to vw.
   ---------------------------------------------------------------------------- */

/* Containment contexts. inline-size only: the block axis stays uncontained, so
   nothing here can collapse a height. .wrap already had position:relative, and
   the absolutely positioned things inside (.portrait ring, #title, .hp) all have
   a nearer positioned ancestor, so making .wrap a containing block changes
   nothing for them. Checked before this went in, not assumed. */
.wrap{container-type:inline-size;container-name:page}
.card{container-type:inline-size;container-name:card}
.skills{container-type:inline-size;container-name:skills}

@supports (container-type:inline-size){

  /* ---- fluid gutters and rhythm -------------------------------------------
     One continuous ramp instead of three step changes at 560, 400 and 360. The
     floors are the values the old narrow breakpoints had settled on, so nothing
     gets tighter than it was already proven to work at. */
  .wrap{padding:clamp(22px,3cqi,34px) clamp(13px,2.4cqi,22px) clamp(40px,5cqi,60px)}
  .block{margin:clamp(40px,5.5cqi,56px) 0 0}
  .panel{padding:clamp(15px,2.2cqi,22px)}
  .bento{gap:clamp(11px,1.6cqi,16px)}

  /* 5vw tied the name to the window; at 900px that is 45px regardless of how
     much room the panel beside the portrait actually has. cqi asks the page
     container instead. Same floor and ceiling as before. */
  h1.name{font-size:clamp(30px,5cqi,46px)}

  /* ---- hero ----------------------------------------------------------------
     Stacks on the container, not the viewport. Same 700px threshold that was
     measured above: at 700 the grid still resolves to a 240px portrait beside a
     ~390px panel, and the role line does not wrap. */
  /* These mirror the two media-query steps above deliberately. An earlier pass
     collapsed them into one and set 72vw at every narrow width, which put a
     270px frame above a 343px panel on a 375 phone: 36px narrower on each side
     than the thing it sits under, which the comment on the 560 block calls out
     as reading like a mistake rather than as inset. Two steps, same numbers. */
  @container page (max-width:700px){
    .hero{grid-template-columns:1fr;gap:clamp(14px,2.4cqi,26px);
      /* vh, not cqb: .wrap is an inline-size container, so it carries no block
         axis to query and cqb would silently fall back to the viewport anyway.
         The constraint here is genuinely "do not eat the fold", which is a
         viewport-height question, so say so. */
      --portrait-w:min(72vw,calc(46vh * var(--portrait-aspect)))}
    .portrait{align-self:start;justify-self:center;width:100%;
      max-width:var(--portrait-w);aspect-ratio:var(--portrait-ratio);min-height:0}
  }

  /* Full column width once the hero has stacked for real, so the frame and the
     panel under it share an edge. 52vh is the ceiling that keeps a phone held
     sideways from handing the whole viewport to the glyph; on any upright phone
     100% wins, which is the point. */
  @container page (max-width:560px){
    .hero{--portrait-w:min(100%,calc(52vh * var(--portrait-aspect)))}
  }

  /* ---- project grid --------------------------------------------------------
     Was two-up from 860 and one-up from 560 against the viewport. Against the
     page container the numbers barely move, but now they survive the page being
     put in a narrower shell, an iframe or a side-by-side window. */
  @container page (max-width:860px){
    .bento{grid-template-columns:repeat(2,minmax(0,1fr))}
    .bento .card.feature{grid-column:span 2}
  }
  @container page (max-width:560px){
    .bento{grid-template-columns:minmax(0,1fr)}
    .bento .card.feature{grid-column:span 1}
  }

  /* ---- the card's own problem ----------------------------------------------
     The 72px PROBLEM/APPROACH/OUTCOME label column costs a third of the line on
     a narrow card. The old rule stacked it below 700px viewport, with a comment
     conceding it was wrong in the 560-860 band where the cards are two-up and
     each one is genuinely narrow at a wide viewport. Asked of the card itself
     the question has one correct answer: stack when the CARD is under 380px,
     whatever the window is doing. */
  @container card (max-width:380px){
    .pao li{grid-template-columns:minmax(0,1fr);gap:1px}
    .pao b{padding-top:0}
  }

  /* ---- skills --------------------------------------------------------------
     auto-fit already follows the width on its own, so this only makes the gap
     scale with the block rather than sit at a fixed 16px. The 260px track floor
     stays fixed on purpose: it is set by the longest tag's text, which does not
     get shorter when the container does. */
  .skills{gap:clamp(12px,1.8cqi,18px)}
}
