"""
Engineering corner — S10 + S10b.

The DEVELOPER escape hatch. Reached only via a SELECT+START+X hold-3s
gesture (wired in main.py, not here), so unlike every other AAhead Control
screen the raw kernel/BlueZ vocabulary is allowed: vermagic, dmesg, dtb,
sysfs, hci, btusb, a2dp. The aesthetic stays on-brand (same tokens,
rose→cyan focus, twilight palette) but leans monospace + dense — a
"developer corner" that still belongs to the product.

Recreates the Claude Design handoff (final-GHVscC):

  S10Engineering ⇄ S10-engineering.html
      "advanced · for support sessions" peach badge + tool counter,
      a scrollable list of debug/dev actions each with a trailing
      duration estimate (mono) and drill-down chevron, plus a real
      scrollbar track + thumb on the right.

  S10bLog ⇄ S10b-engineering-log.html
      live `tail -F` kernel-BT log: blinking LIVE chip in error red,
      a row of filter chips (all / btusb / hci / a2dp / err only) cycled
      with L/R, color-coded mono log lines with **bold** spans, the
      matched "hit" line marked with a cyan left-bar + tint, and a
      bottom fade implying more lines. X pauses the tail, Y saves a
      diagnostic bundle.

Both classes share the screen shape used across the app:
  name, __init__(self, app, ...), on_action(self, action),
  update(self, dt_ms), draw(self, surf).
Actions are strings: up/down/left/right/a/b/x/y/l/r.

This module only defines the two screens + local helpers; it never edits
main.py or widgets.py. Helpers absent from the shared kit (engineering
badge, dense tool row, scrollbar, live chip, filter chips, terminal log
surface) live here at module level.
"""

from __future__ import annotations

import pygame

from ..design import tokens as t
from ..shared import widgets as w
from ..shared import fonts, icons, render

SAFE = t.Space.SAFE_AREA
STAGE_W = t.VIEWPORT_WIDTH
STAGE_H = t.VIEWPORT_HEIGHT

# fixed regions (match the handoff .stage layout)
STATUS_H = t.Space.STATUS_BAR_HEIGHT      # 48
FOOTER_TOP = STAGE_H - t.Space.FOOTER_HINT_HEIGHT  # 448
BODY_LEFT = SAFE
BODY_RIGHT = STAGE_W - SAFE

# the dim treatment used for unfocused engineering rows / inactive chips
DIM_ALPHA = 0.6


# ─── module-level helpers ─────────────────────────────────────────────


def _eng_badge(label: str) -> pygame.Surface:
    """
    Peach "advanced · for support sessions" pill from .eng-hero .badge:
    warn-glyph + mono label, peach text on a faint peach fill with a
    peach hairline border + pill radius.
    """
    peach = t.Color.ACCENT_PEACH
    ic = icons.icon("ph-warning", 12, peach)
    lbl = fonts.render(t.Type.MONO_TECH, label, peach)
    pad_x, gap = 10, 6
    h = 20
    w_total = pad_x + ic.get_width() + gap + lbl.get_width() + pad_x
    s = pygame.Surface((w_total, h), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(peach, 0.10), (0, 0, w_total, h),
                     border_radius=t.Radius.PILL)
    pygame.draw.rect(s, t.with_alpha(peach, 0.35), (0, 0, w_total, h),
                     width=1, border_radius=t.Radius.PILL)
    s.blit(ic, (pad_x, (h - ic.get_height()) // 2))
    s.blit(lbl, (pad_x + ic.get_width() + gap, (h - lbl.get_height()) // 2))
    return s


def _eng_row(surf, rect, *, icon_name, label, meta, focused=False, dim=False):
    """
    Dense engineering list row (.scroll-list .row, 52px):
    leading 20px glyph · primary label · trailing mono duration estimate ·
    14px chevron. Focused rows take the signature rose→cyan glow + gradient
    border + slight scale; unfocused rows are flat surface; "dim" fades the
    rest of the (long) list slightly so the focused row reads first.
    """
    radius = t.Radius.BUTTON
    if focused:
        body = render.rounded_gradient((rect.w, rect.h),
                                       t.Color.SURFACE_ELEVATED,
                                       t.Color.SURFACE_BASE, radius, 160)
    else:
        body = render.rounded_gradient((rect.w, rect.h),
                                       t.Color.SURFACE_BASE, t.Color.BG_MID,
                                       radius, 160)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=radius)

    pad = 14
    # leading glyph (rose when focused, secondary otherwise)
    icol = t.Color.ACCENT_ROSE if focused else t.Color.TEXT_SECONDARY
    ic = icons.icon(icon_name, 20, icol)
    body.blit(ic, (pad, (rect.h - 20) // 2))

    # trailing chevron (measured first so the meta can sit just left of it)
    chev = icons.icon("ph-caret-right", 14,
                      t.Color.TEXT_PRIMARY if focused else t.Color.TEXT_MUTED)
    chev_x = rect.w - pad - chev.get_width()
    body.blit(chev, (chev_x, (rect.h - chev.get_height()) // 2))

    # trailing duration estimate (mono, muted) just left of the chevron
    meta_s = fonts.render(t.Type.MONO_TECH, meta, t.Color.TEXT_MUTED)
    meta_x = chev_x - 10 - meta_s.get_width()
    body.blit(meta_s, (meta_x, (rect.h - meta_s.get_height()) // 2))

    # label, clipped so a long name never collides with the meta column
    lab = fonts.render(t.Type.BODY_STRONG, label, t.Color.TEXT_PRIMARY)
    lab_x = pad + 20 + 12
    max_w = meta_x - 10 - lab_x
    if max_w > 0 and lab.get_width() > max_w:
        lab = lab.subsurface((0, 0, max_w, lab.get_height()))
    body.blit(lab, (lab_x, (rect.h - lab.get_height()) // 2))

    if dim and not focused:
        body.set_alpha(int(255 * DIM_ALPHA))

    if focused:
        w.draw_focused_card(surf, rect, radius, scale=1.03, base_surface=body)
    else:
        surf.blit(body, rect.topleft)


def _scrollbar(surf, x, top, bottom, *, frac_start, frac_len):
    """
    The .scrollbar affordance: a faint full-height track (4px) with a
    secondary-colored thumb covering [frac_start, frac_start+frac_len].
    """
    width = 4
    height = bottom - top
    # track
    track = render.rounded_fill((width, height), t.Color.BORDER_SOFT,
                                t.Radius.PILL)
    track.set_alpha(int(255 * 0.5))
    surf.blit(track, (x, top))
    # thumb
    th_y = top + int(height * frac_start)
    th_h = max(24, int(height * frac_len))
    th_h = min(th_h, bottom - th_y)
    thumb = render.rounded_fill((width, th_h), t.Color.TEXT_SECONDARY,
                                t.Radius.PILL)
    surf.blit(thumb, (x, th_y))


def _live_chip(rate_label: str, *, blink_on: bool) -> pygame.Surface:
    """
    Status-bar LIVE chip (.live-chip): blinking dot + "LIVE · 2.1k/s" in
    error red on a faint error fill + error hairline pill. `blink_on`
    toggles the dot's brightness so the tail reads as "hot".
    """
    err = t.Color.ERROR
    lbl = fonts.render(t.Type.MONO_TECH, f"LIVE · {rate_label}", err)
    pad_x, gap = 10, 6
    dot_r = 3
    h = 18
    w_total = pad_x + dot_r * 2 + gap + lbl.get_width() + pad_x
    s = pygame.Surface((w_total, h), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(err, 0.10), (0, 0, w_total, h),
                     border_radius=t.Radius.PILL)
    pygame.draw.rect(s, t.with_alpha(err, 0.40), (0, 0, w_total, h),
                     width=1, border_radius=t.Radius.PILL)
    dot_col = err if blink_on else t.with_alpha(err, 0.35)
    pygame.draw.circle(s, dot_col, (pad_x + dot_r, h // 2), dot_r)
    s.blit(lbl, (pad_x + dot_r * 2 + gap, (h - lbl.get_height()) // 2))
    return s


def _filter_chip(text: str, *, active: bool, danger: bool = False) -> pygame.Surface:
    """
    A .log-filters .chip-pill: mono text in a hairline pill. Active chips
    use the rose→cyan tint fill + brighter text; the "err only" chip is
    tinted error-red. Returns the rendered surface (caller positions it).
    """
    base_col = t.Color.ERROR if danger else t.Color.TEXT_SECONDARY
    txt_col = t.Color.TEXT_PRIMARY if active else base_col
    lbl = fonts.render(t.Type.MONO_TECH, text, txt_col)
    pad_x = 12
    h = 22
    w_total = pad_x + lbl.get_width() + pad_x
    s = pygame.Surface((w_total, h), pygame.SRCALPHA)
    if active:
        # rose→cyan faint tint (matches the .active gradient)
        fill = render.rounded_gradient(
            (w_total, h), t.with_alpha(t.Color.ACCENT_ROSE, 0.16)[:3],
            t.with_alpha(t.Color.ACCENT_CYAN, 0.16)[:3], t.Radius.PILL, 135)
        s.blit(fill, (0, 0))
        pygame.draw.rect(s, t.with_alpha(t.Color.ACCENT_ROSE, 0.40),
                         (0, 0, w_total, h), width=1, border_radius=t.Radius.PILL)
    else:
        pygame.draw.rect(s, t.with_alpha(t.Color.BG_DEEP, 0.40),
                         (0, 0, w_total, h), border_radius=t.Radius.PILL)
        border = (t.with_alpha(t.Color.ERROR, 0.45) if danger
                  else t.Color.BORDER_SOFT)
        pygame.draw.rect(s, border, (0, 0, w_total, h),
                         width=1, border_radius=t.Radius.PILL)
    s.blit(lbl, (pad_x, (h - lbl.get_height()) // 2))
    return s


# log level → token color (constrained palette; no neon green)
_LVL_COLOR = {
    "INFO": t.Color.INFO,
    "WARN": t.Color.WARNING,
    "ERR": t.Color.ERROR,
    "OK": t.Color.SUCCESS,
}
# which filter each line belongs to (besides the implicit "all")
_LINE_MODULE = {
    "btusb": "btusb",
    "hci": "hci",
    "a2dp": "a2dp",
}


def _log_line(body, y, line, *, ts_w, lvl_w, gap, line_h, max_w):
    """
    Render one terminal log line into `body` at y. `line` is a dict with
    ts / lvl / and segs (a list of (text, bold) tuples making up the
    message, so we can bold the load-bearing token like the HTML <b>).
    Three columns: timestamp (muted) · level (color-coded) · message.
    """
    cy = y + line_h // 2
    # timestamp column (muted mono)
    ts = fonts.render(t.Type.MONO_TECH, line["ts"], t.Color.TEXT_MUTED)
    body.blit(ts, (0, cy - ts.get_height() // 2))
    # level column (color-coded)
    lvl = line["lvl"]
    lvl_s = fonts.render(t.Type.MONO_TECH, lvl,
                         _LVL_COLOR.get(lvl, t.Color.TEXT_SECONDARY))
    body.blit(lvl_s, (ts_w + gap, cy - lvl_s.get_height() // 2))
    # message column: render segment by segment, bold spans in primary
    mx = ts_w + gap + lvl_w + gap
    x = mx
    for text, bold in line["segs"]:
        col = t.Color.TEXT_PRIMARY if bold else t.Color.TEXT_SECONDARY
        seg = fonts.render(t.Type.MONO_TECH, text, col)
        if x + seg.get_width() > mx + max_w:
            # clip the overflowing tail of the message
            avail = mx + max_w - x
            if avail > 4:
                seg = seg.subsurface((0, 0, avail, seg.get_height()))
                body.blit(seg, (x, cy - seg.get_height() // 2))
            break
        body.blit(seg, (x, cy - seg.get_height() // 2))
        x += seg.get_width()


# ═══════════════════════════════════════════════════════════════════════
# S10 — Engineering menu
# ═══════════════════════════════════════════════════════════════════════


class S10Engineering:
    """
    Engineering menu: a scrollable list of debug/dev tools, each with a
    duration estimate and a drill-down chevron. D-pad up/down moves focus
    (auto-scrolls), A runs the focused tool (drills into the log viewer for
    the log-style tools), Y copies the last log, B backs out.
    """
    name = "engineering"

    # body sub-layout
    HERO_TOP = STATUS_H + 12              # 60  (.eng-hero padding-top)
    HERO_H = 28
    LIST_TOP = HERO_TOP + HERO_H + 8      # scroll-area margin-top
    LIST_BOTTOM = FOOTER_TOP - 8
    ROW_H = 52
    ROW_GAP = 6
    SBAR_W = 4                            # scrollbar track width
    LIST_RIGHT_GUTTER = 12               # .scroll-list inset right 12px

    def __init__(self, app):
        self.app = app
        self.elapsed = 0.0
        # tool list — raw developer vocabulary is intentional here.
        # each: (icon, label, duration-estimate, kind)
        #   kind "log"  → drills into S10bLog
        #   kind "run"  → fires + toasts (action stub)
        self.tools = [
            ("ph-bug", "USB / kernel diagnostics", "5s", "log"),
            ("ph-bluetooth", "BlueZ controller status", "1s", "log"),
            ("ph-file-text", "Show full trace log", "inf", "log"),
            ("ph-arrow-clockwise", "BT stack reset", "~8s", "run"),
            ("ph-headphones", "A2DP-only connect", "~3s", "run"),
            ("ph-game-controller", "Volume key probe", "live", "log"),
            ("ph-terminal-window", "dmesg ring buffer", "2s", "log"),
            ("ph-list-magnifying-glass", "sysfs / dtb dump", "1s", "log"),
            ("ph-broadcast", "hci0 vermagic check", "1s", "run"),
            ("ph-download-simple", "Save diagnostic bundle", "~4s", "run"),
        ]
        self.focus = 0
        self.scroll = 0  # index of the first visible row

    # how many rows fit in the scroll viewport
    @property
    def _visible(self) -> int:
        span = self.LIST_BOTTOM - self.LIST_TOP
        return max(1, (span + self.ROW_GAP) // (self.ROW_H + self.ROW_GAP))

    def _clamp_scroll(self):
        vis = self._visible
        if self.focus < self.scroll:
            self.scroll = self.focus
        elif self.focus >= self.scroll + vis:
            self.scroll = self.focus - vis + 1
        self.scroll = max(0, min(self.scroll, max(0, len(self.tools) - vis)))

    # ── input ─────────────────────────────────────────────────────────
    def on_action(self, action):
        if action == "up":
            self.focus = max(0, self.focus - 1)
            self._clamp_scroll()
        elif action == "down":
            self.focus = min(len(self.tools) - 1, self.focus + 1)
            self._clamp_scroll()
        elif action == "a":
            icon_name, label, _meta, kind = self.tools[self.focus]
            if kind == "log":
                self.app.push(S10bLog(self.app, title=label))
            else:
                self.app.toast(f"Running: {label}")
        elif action == "y":
            self.app.toast("Last log copied")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        self.elapsed += dt_ms

    # ── draw ──────────────────────────────────────────────────────────
    def draw(self, surf):
        w.status_bar(surf, title="Engineering", back=True)
        self._draw_hero(surf)
        self._draw_list(surf)
        w.footer_hints(surf, [("A", "run"), ("Y", "copy log"), ("B", "back")])

    def _draw_hero(self, surf):
        y = self.HERO_TOP
        badge = _eng_badge("advanced · for support sessions")
        surf.blit(badge, (SAFE, y + (self.HERO_H - badge.get_height()) // 2))
        # right meta: "10 tools · focus/total"
        meta = fonts.render(
            t.Type.MONO_TECH,
            f"{len(self.tools)} tools · {self.focus + 1} / {len(self.tools)}",
            t.Color.TEXT_MUTED)
        surf.blit(meta, (BODY_RIGHT - meta.get_width(),
                         y + (self.HERO_H - meta.get_height()) // 2))

    def _draw_list(self, surf):
        self._clamp_scroll()
        vis = self._visible
        # list occupies body width minus the right gutter that holds the bar
        list_w = (BODY_RIGHT - SAFE) - self.LIST_RIGHT_GUTTER
        y = self.LIST_TOP
        last = min(self.scroll + vis, len(self.tools))
        for i in range(self.scroll, last):
            icon_name, label, meta, _kind = self.tools[i]
            rect = pygame.Rect(SAFE, y, list_w, self.ROW_H)
            focused = (i == self.focus)
            # dim the non-focused rows slightly (long power-tools list)
            _eng_row(surf, rect, icon_name=icon_name, label=label, meta=meta,
                     focused=focused, dim=not focused)
            y += self.ROW_H + self.ROW_GAP

        # scrollbar: thumb proportional to visible/total, positioned by scroll
        total = len(self.tools)
        if total > vis:
            frac_len = vis / total
            denom = (total - vis) or 1
            frac_start = (self.scroll / denom) * (1 - frac_len)
        else:
            frac_len, frac_start = 1.0, 0.0
        bar_x = BODY_RIGHT - self.SBAR_W
        _scrollbar(surf, bar_x, self.LIST_TOP, self.LIST_BOTTOM,
                   frac_start=frac_start, frac_len=frac_len)


# ═══════════════════════════════════════════════════════════════════════
# S10b — Engineering log viewer (live kernel BT tail)
# ═══════════════════════════════════════════════════════════════════════


class S10bLog:
    """
    Live `tail -F` log viewer. A blinking LIVE chip marks the tail as hot;
    filter chips (all / btusb / hci / a2dp / err only) are cycled with L/R;
    the matched error "hit" line is highlighted with a cyan left-bar; X
    pauses/resumes the tail, Y saves a diagnostic bundle, B backs out.
    """
    name = "engineering-log"

    FILTERS = ["all", "btusb", "hci", "a2dp", "err only"]

    # body sub-layout
    FILTER_TOP = STATUS_H + 10           # .log-filters padding-top
    FILTER_H = 22
    LOG_TOP = FILTER_TOP + FILTER_H + 8
    LOG_BOTTOM = FOOTER_TOP - 6
    # log line grid (78px ts · 56px lvl · rest msg), matches the handoff
    TS_W = 78
    LVL_W = 56
    COL_GAP = 8
    LINE_H = 21                          # ~1.55 line-height on 12px mono
    LOG_PAD_X = 14
    LOG_PAD_Y = 12

    def __init__(self, app, title="kernel BT tail"):
        self.app = app
        self.title = title
        self.elapsed = 0.0
        self.blink_on = True
        self.paused = False
        self.filter_idx = 0
        self.rate_label = "2.1k/s"
        self.total_lines = 142
        # the tail buffer. Each line: dict(ts, lvl, mod, hit, segs)
        # segs = [(text, bold)] so the load-bearing token renders <b>-style.
        self.lines = [
            dict(ts="14:32:01.4", lvl="INFO", mod="hci", hit=False, segs=[
                ("hci0: Bluetooth: ", False),
                ("HCI Event hci0: cmd 0x0c1a", True),
                (" complete", False)]),
            dict(ts="14:32:01.7", lvl="OK", mod="hci", hit=False, segs=[
                ("bluetoothd[412]: ", False),
                ("device 04:52:C7:11:0F:22", True),
                (" connected (AirPods Pro)", False)]),
            dict(ts="14:32:02.0", lvl="INFO", mod="a2dp", hit=False, segs=[
                ("a2dp-sink: profile connected, codec=AAC 256kbps", False)]),
            dict(ts="14:32:02.3", lvl="WARN", mod="btusb", hit=False, segs=[
                ("btusb: rx urb buffer underrun, retrying (1/3)", False)]),
            dict(ts="14:32:02.5", lvl="ERR", mod="btusb", hit=True, segs=[
                ("btusb: ", False),
                ("vermagic mismatch", True),
                (" on btusb.ko — module disabled", False)]),
            dict(ts="14:32:02.5", lvl="WARN", mod="hci", hit=False, segs=[
                ("bluetoothd: fallback to in-tree driver, scan range reduced",
                 False)]),
            dict(ts="14:32:02.9", lvl="INFO", mod="a2dp", hit=False, segs=[
                ("pulseaudio: bluez_sink.04_52_C7_11_0F_22 -> default",
                 False)]),
            dict(ts="14:32:03.1", lvl="INFO", mod="a2dp", hit=False, segs=[
                ("avrcp: passthrough event 0x44 (play)", False)]),
            dict(ts="14:32:03.5", lvl="OK", mod="a2dp", hit=False, segs=[
                ("a2dp: stream started, latency 142 ms", False)]),
            dict(ts="14:32:04.0", lvl="INFO", mod="hci", hit=False, segs=[
                ("battery_report: peer level 50% (was 51%)", False)]),
        ]

    # ── filtering ───────────────────────────────────────────────────────
    def _filtered(self):
        f = self.FILTERS[self.filter_idx]
        if f == "all":
            return self.lines
        if f == "err only":
            return [ln for ln in self.lines if ln["lvl"] == "ERR"]
        return [ln for ln in self.lines if ln["mod"] == f]

    # ── input ─────────────────────────────────────────────────────────
    def on_action(self, action):
        if action in ("l", "left"):
            self.filter_idx = (self.filter_idx - 1) % len(self.FILTERS)
        elif action in ("r", "right"):
            self.filter_idx = (self.filter_idx + 1) % len(self.FILTERS)
        elif action == "x":
            self.paused = not self.paused
            self.app.toast("Tail paused" if self.paused else "Tail resumed")
        elif action == "y":
            self.app.toast("Saving diagnostic bundle…")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        self.elapsed += dt_ms
        if not self.paused:
            # blink the LIVE dot on a ~1.2s period (matches @keyframes blink)
            self.blink_on = (int(self.elapsed / 600) % 2) == 0
        else:
            self.blink_on = True

    # ── draw ──────────────────────────────────────────────────────────
    def draw(self, surf):
        self._draw_status(surf)
        self._draw_filters(surf)
        self._draw_log(surf)
        w.footer_hints(surf, [("X", "pause tail"), ("Y", "save bundle"),
                              ("B", "back")])

    def _draw_status(self, surf):
        # back chevron + title (left), then LIVE chip + battery + clock (right)
        w.status_bar(surf, title=self.title, back=True)
        # the standard status bar already painted battery+clock on the right;
        # overlay the LIVE chip to the left of that cluster.
        live = (_live_chip(self.rate_label, blink_on=self.blink_on)
                if not self.paused
                else _filter_chip("PAUSED", active=False, danger=True))
        # right cluster start: clock width + gap + battery width + gap
        clk = fonts.render(t.Type.MONO_TECH, "14:32", t.Color.TEXT_PRIMARY)
        batt = w.battery_chip(87)
        cluster_w = clk.get_width() + t.Space.L + batt.get_width() + t.Space.L
        lx = BODY_RIGHT - cluster_w - live.get_width()
        surf.blit(live, (lx, (STATUS_H - live.get_height()) // 2))

    def _draw_filters(self, surf):
        y = self.FILTER_TOP
        x = SAFE
        gap = 6
        for i, name in enumerate(self.FILTERS):
            chip = _filter_chip(name, active=(i == self.filter_idx),
                                danger=(name == "err only"))
            surf.blit(chip, (x, y + (self.FILTER_H - chip.get_height()) // 2))
            x += chip.get_width() + gap
        # right meta: "tail -F · N lines"
        meta = fonts.render(t.Type.MONO_TECH,
                            f"tail -F · {self.total_lines} lines",
                            t.Color.TEXT_MUTED)
        surf.blit(meta, (BODY_RIGHT - meta.get_width(),
                         y + (self.FILTER_H - meta.get_height()) // 2))

    def _draw_log(self, surf):
        rect = pygame.Rect(SAFE, self.LOG_TOP, BODY_RIGHT - SAFE,
                           self.LOG_BOTTOM - self.LOG_TOP)
        # terminal surface: very dark translucent fill + soft border + radius
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.BG_DEEP, 0.85),
                         (0, 0, rect.w, rect.h), border_radius=t.Radius.BUTTON)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=t.Radius.BUTTON)

        lines = self._filtered()
        inner_w = rect.w - self.LOG_PAD_X * 2
        msg_max = inner_w - (self.TS_W + self.COL_GAP + self.LVL_W + self.COL_GAP)
        y = self.LOG_PAD_Y
        max_y = rect.h - self.LOG_PAD_Y
        for ln in lines:
            if y + self.LINE_H > max_y:
                break
            if ln["hit"]:
                # cyan left-bar + faint cyan tint, pulled to the card edge
                hit_rect = pygame.Rect(self.LOG_PAD_X - 8, y,
                                       inner_w + 8, self.LINE_H)
                pygame.draw.rect(body, t.with_alpha(t.Color.ACCENT_CYAN, 0.06),
                                 hit_rect)
                pygame.draw.rect(body, t.Color.ACCENT_CYAN,
                                 (self.LOG_PAD_X - 8, y, 2, self.LINE_H))
            _log_line(body, y, ln, ts_w=self.TS_W, lvl_w=self.LVL_W,
                      gap=self.COL_GAP, line_h=self.LINE_H,
                      max_w=msg_max)
            y += self.LINE_H

        # bottom fade implying more lines below (.log::after)
        fade_h = 28
        fade = render.linear_gradient((rect.w - 2, fade_h),
                                      t.Color.BG_DEEP, t.Color.BG_DEEP, 180)
        fade = fade.convert_alpha()
        for fy in range(fade_h):
            a = int(0.85 * 255 * (fy / fade_h))
            pygame.draw.line(fade, t.with_alpha(t.Color.BG_DEEP, a / 255),
                             (0, fy), (rect.w - 2, fy))
        # round the fade's bottom corners by masking to the card shape
        body.blit(fade, (1, rect.h - fade_h - 1))

        surf.blit(body, rect.topleft)
