/* ============================================================================
   NabGol — CHAT CANVAS  (the middle region: where messages appear)
   ----------------------------------------------------------------------------
   Owns EXACTLY one thing: `#messages.chat-messages` and everything inside it.
   It does not touch the header, the composer, the sheets, the sidebar or the
   floating player — by design, so this file can be re-skinned for a new app
   without touching the shell.

   Why this file exists (measured on 2026-08-28 with tools/_chat_canvas_shot.js,
   the real CSS cascade + the real markdown pipeline, not by reading code):

     1. Body prose rendered at 15px / 1.75 on mobile. chat-flagship-polish.css
        asks for 16.75px / 2.18, but style-mobile.css + chat-mobile-flagship.css
        override it with `!important`. So the tuned scale never reached a phone,
        and the platform's own ">= 16px forever" rule was being broken in the one
        place it matters most — the answer text.
     2. Every <strong> carried a green highlighter gradient. Three bold words in
        one paragraph = three green blocks. That single rule is most of what read
        as "cheap" next to a first-class Persian product.
     3. Every colon-line (.nabgol-mag-section) was mint-green + bold, so a long
        answer shouted five times in green. Hierarchy has to come from weight and
        space, not from colour repeated on every label.
     4. A one-line courtesy reply ("خواهش می‌کنم…") was typeset as a 1.06em bold
        display lead, because .nabgol-mag-lead lands on the first paragraph of
        EVERY message regardless of length.
     5. Three nested boxes: canvas > assistant bubble > list card. The assistant
        bubble is the one that carries no information — only the user's own words
        need to be visually contained.
     6. The date was reprinted on every single bubble, plus five bordered action
        chips, under a four-word reply.
     7. blockquote got a box from chat-flagship-polish.css AND a second box from
        an earlier layer, so notes rendered as a card inside a card.

   Load order: LAST in both templates, after chat-flagship-polish.css and
   chat-mobile-flagship.css. `!important` here is not laziness — the layers below
   claim these same properties with `!important`, so a plain declaration loses.
   Every rule is scoped by `#messages` so the id keeps specificity above them.

   Proof harness (run it before claiming any change here is done):
     node live/tools/_chat_canvas_shot.js <outDir>        # 8 shots + geometry audit
     bash live/tools/_shot_slice.sh <shot.png> 1700       # readable slices
   ========================================================================== */

/* ---------------------------------------------------------------------------
   1) TOKENS — one scale, two themes. No other rule in this file may hard-code
   a colour or a size; a new app re-declares these and inherits the whole skin.
   Contrast is measured against the canvas each theme actually paints
   (dark #0e1621 mobile / #0d1117 desktop, light #ffffff / #f6f8fa).
   --------------------------------------------------------------------------- */
:root {
  /* ink */
  --nbgc-ink: #e8eef6;          /* 13.9:1 on #0e1621 */
  --nbgc-ink-strong: #ffffff;   /* bold runs and section labels */
  --nbgc-ink-soft: #94a3b6;     /*  7.1:1 — meta, captions, quiet chrome */
  --nbgc-ink-faint: #64748b;    /*  4.0:1 — hairline labels only, never prose */
  --nbgc-ink-faint-on-user: #748297; /* 3.5:1 on --nbgc-user-face over the mobile
     dark canvas (#0e1621) — ink-faint itself only reaches 2.87:1 there because the
     tinted user bubble is lighter than the plain canvas it was calibrated for. */

  /* brand */
  --nbgc-accent: #34d399;
  --nbgc-accent-ink: #6ee7b7;   /* accent used AS TEXT on the canvas */
  --nbgc-accent-wash: rgba(52, 211, 153, 0.10);
  --nbgc-accent-edge: rgba(52, 211, 153, 0.28);
  --nbgc-ink-on-accent: #000000; /* an icon painted ON a solid --nbgc-accent
     fill (the composer's send button) — --nbgc-accent itself is a bright
     mint on this theme, so white measures only 1.92:1 there; needs its own
     token because it answers a different question than --nbgc-accent-ink
     ("accent used as text on the canvas") answers. */

  /* THE AUDIENCE-MODE MARK (work 4, docs/CHAT_REDESIGN_MASTER_PLAN.md §7) —
     same two colours as the rail's own --nbgw-mode-mark (chat-workbench.css),
     for one visual vocabulary, but defined again here rather than referenced
     across files: this stylesheet has to keep painting a badge on an OLD,
     already-saved message even on a page whose workbench CSS never loaded (a
     robot reconfigured after the message was written). Declared on :root, not
     under #messages, so the file's own "no hard colour under #messages" check
     (below) still catches a literal hex creeping into the message region
     itself — the message rule references only the variable. */
  --nbgc-mode-mark-parent: #fbbf24;
  --nbgc-mode-mark-teacher: #60a5fa;

  /* THE CANVAS'S OWN FACE — an alias, deliberately, and not a fourth hex.
     body.desktop-chat paints `var(--bg)` (style-desktop.css:42) and every other
     surface token in this file is defined as something sitting ON that. The
     composer's fade (chat-desktop-shell.css, work 8) has to END on exactly that
     colour or the reader sees a seam where the band starts, so it reads the
     same variable rather than a copy of its value — a copy is how a page ends
     up with two blacks that were the same colour in the commit that made them.
     Aliased once here so the composer never has to know about --bg. */
  --nbgc-canvas-face: var(--bg, #0d1117);

  /* surfaces that sit ON the canvas */
  --nbgc-surface: rgba(148, 168, 200, 0.07);
  --nbgc-surface-2: rgba(148, 168, 200, 0.11);
  --nbgc-surface-raised: rgba(148, 168, 200, 0.17); /* "which control in a chrome
     bar is the primary one, without spending green on it?" — surface-2 is the
     HOVER weight of a ghost control, so a control that rests there is
     indistinguishable from any hovered neighbour. This is the step above it:
     the resting/hover surface of the single weighted control in a bar (today
     the header's "new chat" +), so weight can come from surface instead of
     from accent, which the canvas needs more than the chrome does. */
  --nbgc-hairline: rgba(148, 168, 200, 0.16);
  --nbgc-hairline-strong: rgba(148, 168, 200, 0.26);
  /* the header's own surface for the moments it stands ABOVE a panel's scrim
     (chat-header.css section 8). Opaque on purpose: a bar that is still live
     must not read as part of the dimmed page behind it, and a transparent one
     would show the scrim straight through and look disabled while working.
     Same value as the dark mobile canvas, so the lift is invisible until you
     look at what is dimmed and what is not. */
  --nbgc-header-lifted: #0e1621;
  /* the face of a panel that floats ABOVE the page — bottom sheets and the
     header dropdown. Answers a different question than --nbgc-header-lifted:
     that one hides a lift by matching the page, this one has to read as a
     raised surface sitting on top of it. */
  --nbgc-sheet-face: #0f172a;
  --nbgc-sheet-handle: rgba(255, 255, 255, 0.28);
  /* THE SECTION RULE gets its own token, and it is NOT --nbgc-hairline.
     A hairline that is right for the edge of a card is wrong for a rule that
     has to be read as structure across a whole answer — and the two themes do
     not need the same amount of it. This is the token that keeps day and night
     independently tuned instead of one being the other inverted: on the dark
     canvas a light rule at 0.16 alpha already reads, on white the same
     construction needs more, because dark-on-light loses less to the surround.
     Not shared with the header or the composer; nothing outside this region
     draws a structural rule. */
  --nbgc-rule: rgba(148, 168, 200, 0.20);

  /* the user's own words — the only bubble left */
  --nbgc-user-face: rgba(52, 211, 153, 0.15);
  --nbgc-user-edge: rgba(52, 211, 153, 0.30);
  --nbgc-user-ink: #eaf6f0;

  /* type scale — every number below was re-derived when the face changed to
     Nazli on 2026-09-02, because none of them survives a face swap.

     17.5px is not "bigger text". Nazli's Persian body height is 59.0% of the em
     where Vazirmatn RD's is 67.2% (measured: ink of سهصط above the baseline,
     _font_bakeoff_shot.js). The same px is therefore NOT the same size in the
     two faces, and shipping Nazli at 16.5 would have been a silent 12% shrink
     nobody chose. At 17.5px the rendered body height is 10.33px against the old
     16.5px's 11.09px — a deliberate 6.9% REDUCTION, which is the "it looks too
     big" half of the complaint, answered in the only unit that means anything:
     rendered pixels of letter, not the number in the stylesheet.

     lh 1.70 is likewise a measurement, and it is LOWER than the old 1.92 for a
     face that leaves less ink standing: window.__leadingProbe() at 17.5px puts
     Nazli's over-dots 13px above the baseline and its under-dots 4px below, so a
     line of real ink is 17px tall. At lh 1.70 the clear air between one line's
     under-dots and the next line's over-dots is 12.75px — against 13.7px on the
     old face at 1.92. Practically the same air, a smaller number, because the
     number was never the thing. The ratio air/ink lands at 0.75, which is where
     the previous pass argued a Persian paragraph belongs and this pass had no
     reason to move it. */
  /* Left at `normal`, and that is a measurement rather than an omission. Nazli's
     own word space renders 6.03px at 17.5px = 0.345em, which is already a
     generous, ordinary text space — nothing to correct. An intermediate
     candidate (Markazi Text) needed +0.10em here because its newspaper-tight
     0.167em let words run together; adding the same nudge to Nazli made the page
     measurably worse, so the token stays and the value does not.
     Kept as a token rather than a literal so the escape-hatch block below can
     restore the outgoing face's setting in one line with the rest of its pack. */
  --nbgc-wordspace: normal;
  --nbgc-prose: 17.5px;
  --nbgc-lh: 1.7;
  --nbgc-measure: 100%;
  --nbgc-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
  --nbgc-font: "Nazli", "Nazli Fallback", Tahoma, serif;
  /* The canvas owns its own typography. Before this token, answer text had no
     font-family of its own and inherited whichever <body> rule won the cascade
     that day — style-common.css:34, style-mobile.css:4, and
     chat-flagship-polish.css:24 each claim it with a different chain, and that
     last one leads with "IRANSansX", a file that is not on the server at all.
     Inheriting a font is not choosing one.

     Nazli since 2026-09-02, and the reason is a CATEGORY, not a taste.
     Readers reported that "everything looks bold" while body text has been
     font-weight: 400 the whole time — so the complaint was never about weight.
     It is how much black the TYPEFACE leaves per line, measured across six faces
     at weight 400 and 16.5px (_font_bakeoff_shot.js):

       Vazirmatn RD 0.0469   XB Zar 0.0420   XB Niloofar 0.0402
       Markazi Text 0.0399   Roya   0.0361   NAZLI       0.0342

     Nazli is the lightest of all of them — 27% less ink than the face it
     replaces, for the same words at the same rendered size.

     Why no amount of further Vazirmatn tuning could ever have found this: every
     face the project had compared — Vazirmatn, Vazirmatn RD, Shabnam, Sahel,
     Samim, Tanha, Gandom, Parastoo — is a geometric SANS off one designer's
     shared skeleton, so "they all look the same and they all look heavy" was the
     only answer the search could return. Nazli is a NASKH: the free GPL rebuild
     of NAZANIN by Sharif FarsiWeb, i.e. the letterform Persian schoolbooks and
     theses are actually set in. It also agrees with this region's own design
     thesis, written down by an earlier pass: the answer is a DOCUMENT, not a
     chat.

     And it survives the thing a naskh is supposed to fail at. Thin strokes
     normally go soft on a dark ground, which is where most of these students
     read; measured (_zwnj_night_probe.js), night edge sharpness as a share of
     day is 112.4% for Nazli against 95.4% for Vazirmatn RD. It gets crisper on
     the dark canvas where the outgoing face got softer.

     Rejected with reasons, so this is not re-run: XB Zar and XB Niloofar are the
     academic LaTeX standard and genuinely handsome, but 1.40MB and 1.25MB (six
     scripts each) on a server that compresses nothing, and their letters run
     75-76% of the em so matching today's size would mean declaring 13.4px and
     breaking the 16px floor. Markazi Text is variable and only 67KB but is a
     newspaper face, and 15% lighter where Nazli is 27%. Roya (the free Lotus) is
     the runner-up. Noto Naskh, Scheherazade and Amiri are static AND heavier.

     "Nazli Fallback" is the metric-matched local() face from
     assets/vendor/nazli/Nazli-fallback.css; it holds the layout still during the
     swap. The generic tail is `serif`, not `sans-serif`, because on a device
     where nothing else resolves a serif is the nearer miss. This token is the
     only place a font name may appear in this file — chat-header.css and
     chat-composer.css consume it so the whole chat page speaks one voice, and
     the vendor file additionally re-declares the same two .woff under the legacy
     name "Vazirmatn" so the other 22 declarations across the platform follow
     without a single edit. */

  /* ── RHYTHM ────────────────────────────────────────────────────────────────
     ONE modular scale. Every vertical distance inside #messages is a multiple
     of --nbgc-rhythm and nothing else, so the file cannot go back to
     accumulating 1.1em here and 13px there until no two gaps agree.

     32px is not a taste: it is the line box this page already paints. Mobile is
     16.5 x 1.92 = 31.68px, desktop is 17 x 1.90 = 32.30px. The grid was always
     there; before this token the spacing simply ignored it. If the type scale
     or the face ever changes, recompute this number FIRST — every other
     distance in the file follows it automatically.

     The steps below are the only vertical values this file may use. They exist
     as named tokens rather than as inline calc() so that `grep` can prove the
     rule holds (see the gate's rhythm check), and so a future app can retune
     the whole vertical feel by editing one line. */
  /* 31px since 2026-09-02, and it moved for exactly the reason the paragraph
     above says it must: the face changed, so the line box this page paints
     changed. Mobile is now 20 x 1.50 = 30.00 and desktop 20.5 x 1.55 = 31.78;
     the mean rounds to 31, by the same arithmetic that produced 32 from 31.68
     and 32.30. Nothing else in this file was touched to follow it — every gap is
     already calc(var(--nbgc-rhythm) * n), which is the whole point of the token,
     and the one-line edit re-tunes the entire vertical feel. */
  --nbgc-rhythm: 30px;
  --nbgc-r-025: calc(var(--nbgc-rhythm) * 0.25);   /*  7.5px */
  --nbgc-r-05:  calc(var(--nbgc-rhythm) * 0.5);    /* 15px   */
  --nbgc-r-075: calc(var(--nbgc-rhythm) * 0.75);   /* 22.5px */
  --nbgc-r-1:   var(--nbgc-rhythm);                /* 30px   */
  --nbgc-r-15:  calc(var(--nbgc-rhythm) * 1.5);    /* 45px   */
  --nbgc-r-2:   calc(var(--nbgc-rhythm) * 2);      /* 60px   */
  /* HORIZONTAL AND OPTICAL CONSTANTS. Vertical rhythm is a grid; horizontal
     padding is not, and forcing it onto the same multiples would make a table
     cell or a quote indent worse in order to make a number look tidy. They are
     still tokens, not literals, so `grep` can prove no loose px survives in any
     margin or padding in this file. */
  --nbgc-inset: 18px;           /* boxed blocks: note, question, step, pre     */
  --nbgc-inset-sm: 14px;        /* table cells, edit textarea                  */
  --nbgc-indent: 26px;          /* the blockquote's indent-instead-of-a-box    */
  --nbgc-gutter: 12px;          /* the phone canvas's side gutter              */
  /* THE DESKTOP CANVAS HAD NO SIDE GUTTER AT ALL, AND THAT IS WHAT "THE TEXT IS
     EATEN" WAS. `body.desktop-chat #messages.chat-messages` set
     `padding: var(--nbgc-r-1) 0` -- zero inline -- while the phone got 12px. So
     the first glyph of every RTL line and the right edge of the reader's bubble
     sat EXACTLY on the boundary of a box with `overflow-x: hidden`, and the
     slice was invisible to rect arithmetic: .text's right edge equalled the
     column's right edge, so "how far outside its parent" measured 0 while the
     paint was cut. Measured on the live page 2026-09-04 at 1920: the row needed
     518px of a 506px column, and "بسیار عالی." rendered as "ـسیار عالی.". */
  --nbgc-gutter-lg: 16px;       /* the desktop canvas's side gutter            */
  /* THE MARK'S OWN BOX. Four stylesheets write this number and only this one
     counts; the other three are kept in step anyway (2026-09-04), so grepping
     the size finds every writer instead of three that silently lose. */
  --nbgc-avatar: 34px;          /* desktop: the platform's identity mark size  */
  --nbgc-avatar-sm: 32px;       /* the phone pays 2px back to the measure      */
  --nbgc-avatar-radius: 9px;    /* the platform squircle, same as the header    */
  /* The gap between the mark and its answer. A token because the DESKTOP STAGE
     is derived from it (chat-desktop-shell.css) — it was a literal 10 in one
     file and a literal 40 of "mark + gap + slack" in the other, and growing the
     mark by 6px silently squeezed the answer's measure from 460 to 456 while
     the reader's own bubble kept its 460 cap. The gate caught it as "the
     reader's bubble is never wider than the answer". */
  --nbgc-row-gap: 10px;
  --nbgc-row-gap-sm: 8px;       /* the phone's, 2px tighter                     */
  --nbgc-avatar-nudge: -2px;    /* optical: drops the avatar onto the first
                                   text line's cap, not its box top. MEASURED,
                                   not chosen — _avatar_fit_probe.js reports
                                   markVsLine, and it must stay inside ±1.5px  */
  --nbgc-avatar-nudge-sm: -1px; /* the phone's own, measured the same way      */
  --nbgc-hair-nudge: 2px;       /* a 1-line optical shim that has nothing to do
                                   with the avatar. It used to borrow
                                   --nbgc-avatar-nudge, which meant retuning
                                   the avatar silently moved a UI-only footer  */

  --nbgc-gap-turn: var(--nbgc-r-15);   /* between two different speakers */
  --nbgc-gap-same: var(--nbgc-r-05);   /* two messages from the same speaker */
  --nbgc-radius: 18px;
  --nbgc-radius-sm: 12px;
}

html[data-theme="light"] {
  --nbgc-ink: #14181d;          /* 15.8:1 on #ffffff */
  --nbgc-ink-strong: #000000;
  --nbgc-ink-soft: #5b6672;     /*  6.4:1 */
  --nbgc-ink-faint: #78838f;    /*  4.5:1 */
  --nbgc-ink-faint-on-user: #78838f; /* the light user-face tint never dips below
     ink-faint's floor, so no separate value is needed here. */

  --nbgc-accent: #15803d;
  --nbgc-accent-ink: #0f6a37;   /*  5.6:1 — the mint greens all fail on white */
  --nbgc-accent-wash: rgba(21, 128, 61, 0.07);
  --nbgc-accent-edge: rgba(21, 128, 61, 0.22);
  --nbgc-ink-on-accent: #ffffff; /* 5.0:1 — this theme's accent is dark enough
     that white already works, unlike the dark theme's bright mint. */

  --nbgc-surface: rgba(15, 23, 42, 0.04);
  --nbgc-surface-2: rgba(15, 23, 42, 0.07);
  --nbgc-surface-raised: rgba(15, 23, 42, 0.11);
  --nbgc-hairline: rgba(15, 23, 42, 0.10);
  --nbgc-hairline-strong: rgba(15, 23, 42, 0.18);
  --nbgc-header-lifted: #ffffff;
  --nbgc-sheet-face: #ffffff;
  --nbgc-sheet-handle: rgba(15, 23, 42, 0.22);
  /* 0.16, not the dark theme's 0.20 mirrored to 0.10: at 0.10 the section rule
     was measured invisible against white in the day-mode comparison shots, and
     the whole structural device evaporated in one of the two themes. */
  --nbgc-rule: rgba(15, 23, 42, 0.16);

  --nbgc-user-face: rgba(21, 128, 61, 0.09);
  --nbgc-user-edge: rgba(21, 128, 61, 0.20);
  --nbgc-user-ink: #12241a;
}

body.desktop-chat {
  /* Same derivation as the mobile pair, re-run at the desktop size: rendered
     body height 0.590 x 18 = 10.62px against the old 17px face's 11.42px, i.e.
     the same deliberate 7% reduction the phone gets. __leadingProbe() at 18px
     reads 13px of over-dot and 4px of under-dot, a 17px band; lh 1.65 leaves
     12.7px of clear air, ratio 0.75 — the same paragraph texture as the phone
     and as the face this replaces. */
  --nbgc-prose: 18px;
  --nbgc-lh: 1.65;
  /* px, not em: this caps .message-bubble, whose own font-size is 14px, so an
     em value silently resolved to 532px instead of the intended 646.

     560px, and the old 646 was wrong — measured 2026-08-30 with
     window.__measureProbe() (live/tools/_canvas_measure.js), which buckets every
     character onto the line box it actually painted on and averages only the
     COMPLETE lines, because the ragged last line biases the naive
     length/lineCount by up to 8%.

       width   em    chars/line
        520   30.6      69.5
        560   32.9      74.9
        646   38.0      86.0   <- what shipped, labelled "~70 characters"
        700   41.2      93.5

     So the file's own comment was off by 16 characters. Two things fell out of
     the measurement that are worth keeping:

     1. A Persian character is NARROWER than a Latin one here, not wider:
        0.427em of average advance against Latin's ~0.5em. The script joins, and
        half the alphabet is a vertical stroke. Every "Persian is wider" instinct
        (this session started with one) points the wrong way.
     2. Because of that, neither "60-75 characters" nor "9-12 words" survives the
        trip from Latin — the character count comes out too permissive and the
        word count too strict (Persian words are short: 3.5 letters on average in
        this product's own prose, so 646px carries 19 words per line).

     What DOES transfer is the em-width of the line, which is what all three
     rules are proxies for. Latin's 66-character optimum is 33em; at 17px that is
     561px, and it lands at 74.9 Persian characters — inside the classic band as
     well. Two independent derivations agreeing on 560 is the reason to trust it.
     A 3-column practice table and the widest diagram card both still fit.

     ── RE-DERIVED 2026-09-02 FOR NAZLI: 560 -> 460 ──
     The paragraph above concluded that what transfers between faces is the
     em-width of the line, not the character count. That was true for the swap it
     was written about — Vazirmatn and Vazirmatn RD have byte-identical advances,
     so both derivations had to agree. Across a change of CATEGORY they stop
     agreeing, and only one of them is still measuring the thing that matters.
     Nazli is a narrow book face: the same Persian sentence is 21% shorter in it
     than in Vazirmatn RD (2385.06px against 3023.19px at 100px). 33em of Nazli
     at 18px is 594px, and __measureProbe() puts 88 characters on that line — far
     outside any reading rule. The em was only ever a proxy for how many words
     the eye crosses per line; the character count is what survives.

       width   em    chars/line   words/line     (Nazli @ 18px)
        440   24.4      65.5         15.0
       *460   25.6      68.0         15.5   <- chosen
        480   26.7      71.5         16.3
        500   27.8      74.9         17.0   <- at the ceiling
        560   31.1      84.3         18.8   <- what 560 now buys: too long

     460px, and it is the middle of the band rather than its edge: 68.0 sits with
     room on both sides where 480 and 500 lean on the upper limit, and 66 is the
     classic optimum this whole rule descends from. window.__canvasAudit()
     confirms the 3-column practice table and the widest diagram card still fit
     at 460 with nothing overflowing — the narrower column costs less than it
     looks like it should, because Nazli sets everything inside it narrower too.

     On the phone the reading width is the phone's, and __measureProbe() read
     51.5 characters at 334px; the mark going 24 -> 32px (2026-09-04) left 326px
     and ~50.3 characters. Below the band, and correctly so: a 390px screen has
     no 68-character line to give and never did. */
  --nbgc-measure: 460px;        /* 68.0 Persian characters at 18px, measured */
  --nbgc-gap-turn: var(--nbgc-r-2);
  --nbgc-gap-same: var(--nbgc-r-05);

  /* ── THREE NUMBERS, THREE JOBS ─────────────────────────────────────────────
     Until 2026-09-04 there were two, and one of them did two jobs: the whole
     desktop app column was DERIVED from --nbgc-measure
     (chat-desktop-shell.css: --stage-max = measure + avatar + gap + gutters).
     So widening the app meant lengthening the prose line, and every session
     that thought about it correctly refused — which is the complete explanation
     of "they always build it narrow". Measured on the live site the same day:
     #messages was 552px inside a 1440px window, 38% of the screen, with the
     table, the code block and the diagram all living inside those 552px.

     The knot is cut by giving the column its own number:

       --nbgc-rail     the workbench's own grid track. NOT part of the stage:
                       it is a sibling column, so the stage never has to pay
                       for it twice. 0 when no workbench is mounted, which is
                       how a robot with no learner chrome gets the full 820.
       --nbgc-stage    the APP column: header, composer, tables, code,
                       diagrams, images, the action row. 100% here is the chat
                       GRID TRACK (the rail is already subtracted by the grid),
                       so the -32px is only the window's own breathing room.
       --nbgc-measure  the PROSE line, unchanged at 460px, and now capping
                       nothing but p/li/h1..h6/blockquote.

     820 is the ceiling and not a target: _canvas_measure.js reads ~6.3px per
     Persian character at 18px, so 820px of prose would be ~130 characters a
     line — twice the readable band. That is exactly why the stage and the
     measure cannot be the same number. */
  --nbgc-rail: 0px;
  --nbgc-stage: min(100% - 32px, 820px);
}

/* The workbench rail is a real grid track (chat-desktop-shell.css), painted
   only when something actually mounts into it. The class is stamped server-side
   in templates/desktop.php so the track exists at FIRST PAINT — a rail that
   arrives after the first reply is the "sometimes it is there" fault that
   rule 1 of docs/CHAT_REDESIGN_MASTER_PLAN.md forbids. */
body.desktop-chat.nabgol-workbench-on {
  --nbgc-rail: 200px;
}

/* ── THE WAY BACK ───────────────────────────────────────────────────────────
   The owner asked for two things at once: ship Nazli everywhere, and be able to
   take it back — either wholly, or for particular robots that should not look
   like a textbook. This block is the CSS half of that; the other half is
   includes/nabgol_typeface.php, which decides which font files a page links and
   stamps the same id onto <body data-typeface>.

   HOW TO USE IT
     one robot   -> add  "typeface": "sans"  to that robot's record. No code.
     everything  -> set NABGOL_TYPEFACE_DEFAULT to 'sans'. One line.
   A robot created next year needs no action either way: an absent key resolves
   to the default, so the platform face follows automatically.

   WHY THIS BLOCK RESTORES SIX TOKENS AND NOT JUST THE FONT NAME
   A typeface is not a swappable skin here, and pretending otherwise would ship
   a broken page under the escape hatch. Every number below is a property of the
   face it was derived for:
     - Nazli's Persian body height is 59.0% of the em, Vazirmatn RD's is 67.2%,
       so the SAME px is not the same size — hence a different --nbgc-prose.
     - Their ink bands differ, so the leading that leaves the same air differs.
     - --nbgc-rhythm is round(prose x lh) and follows both.
     - Nazli sets the same sentence 21% shorter, so the column that carries
       60-75 characters is a different width.
     - Nazli's natural word space is wide enough that justification needs no
       help; Vazirmatn's is too, at `normal`.
   Restoring the font name alone would leave a sans face on a naskh's type
   scale, which is a worse page than either choice made properly.

   These are the values this file shipped before 2026-09-02, unchanged, with
   their original derivations recorded in the git history of this file. */
body[data-typeface="sans"] {
  --nbgc-font: "Vazirmatn RD", "Vazirmatn RD Fallback", Tahoma, sans-serif;
  --nbgc-prose: 16.5px;
  --nbgc-lh: 1.92;
  --nbgc-rhythm: 32px;
  --nbgc-wordspace: normal;
}

body.desktop-chat[data-typeface="sans"] {
  --nbgc-prose: 17px;
  --nbgc-lh: 1.9;
  --nbgc-measure: 560px;
}

/* ---------------------------------------------------------------------------
   2) THE CANVAS
   The background stays whatever the shell paints (var(--tg-bg) on mobile,
   var(--bg) on desktop): the middle region must not seam against the header and
   the composer, which this session does not own. The only thing removed is the
   green radial wash — it tinted the top of every conversation.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages.chat-messages {
  font-family: var(--nbgc-font) !important;
  background-image: none !important;
  display: flex !important;
  flex-direction: column !important;
  align-items: stretch !important;
  row-gap: 0 !important;                /* rhythm comes from .message margins */
  scroll-behavior: smooth;
  scrollbar-width: thin;
  scrollbar-color: var(--nbgc-hairline-strong) transparent;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

body.mobile-chat #messages.chat-messages {
  padding: var(--nbgc-r-05) var(--nbgc-gutter) var(--nbgc-r-075) !important;
}

/* THE BOTTOM PADDING IS A CONTRACT NOW, NOT BREATHING ROOM.
   Since work 8 (chat-desktop-shell.css, 2026-09-05) the composer floats over the
   end of this box instead of owning a grid row under it. --nbgc-canvas-reserve
   is the bar's measured height plus its fade, and spending it here is the ONLY
   reason a floating composer is allowed at all: scroll to the very end and the
   last line of the answer stops above the bar, because the scrollable content
   ends that many pixels early.
   It lives in THIS file and not in the shell because `#messages.chat-messages`
   is an id+class selector that no `body.desktop-chat .chat-messages` rule can
   out-specify, and chat-canvas.css loads last — a longhand in the shell was
   silently losing to this shorthand (measured: padding-bottom stayed 30px while
   scroll-padding-bottom took the new value, and the last line sat 49px under
   the bar). One owner for one box. */
body.desktop-chat #messages.chat-messages {
  padding: var(--nbgc-r-1) var(--nbgc-gutter-lg) var(--nbgc-canvas-reserve, var(--nbgc-r-1)) !important;
  scroll-padding-bottom: var(--nbgc-canvas-reserve, var(--nbgc-r-1)) !important;
}

body:is(.desktop-chat, .mobile-chat) #messages.chat-messages::-webkit-scrollbar {
  width: 10px;
}
body:is(.desktop-chat, .mobile-chat) #messages.chat-messages::-webkit-scrollbar-thumb {
  background: var(--nbgc-hairline);
  border: 3px solid transparent;
  background-clip: content-box;
  border-radius: 999px;
}
body:is(.desktop-chat, .mobile-chat) #messages.chat-messages:hover::-webkit-scrollbar-thumb {
  background: var(--nbgc-hairline-strong);
  background-clip: content-box;
}
body:is(.desktop-chat, .mobile-chat) #messages.chat-messages::-webkit-scrollbar-track {
  background: transparent;
}

body:is(.desktop-chat, .mobile-chat) #messages .message ::selection {
  background: var(--nbgc-accent-edge);
  color: var(--nbgc-ink-strong);
}

/* ---------------------------------------------------------------------------
   3) TURN RHYTHM
   A transcript reads as turns, not as a stack of equal cards: a new speaker
   gets a full gap, a follow-up from the same speaker gets a small one.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message {
  width: 100% !important;
  max-width: 100% !important;
  margin: 0 !important;
  padding: 0 !important;
  background: none !important;
  border: 0 !important;
  overflow: visible !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message + .message {
  margin-top: var(--nbgc-gap-turn) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant + .message.assistant,
body:is(.desktop-chat, .mobile-chat) #messages .message.user + .message.user {
  margin-top: var(--nbgc-gap-same) !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message-col,
body:is(.desktop-chat, .mobile-chat) #messages .message-row {
  width: 100% !important;
  max-width: 100% !important;
  min-width: 0 !important;
}

/* WHICH SIDE EACH SPEAKER SITS ON — a product decision, not a CSS accident.
   Owner's call (2026-08-29): **the reader's own message hugs the RIGHT edge; the
   robot's answer, its bubble and its avatar sit on the LEFT.** That is the
   arrangement Persian users are habituated to from WhatsApp and Telegram, and it
   is the one this product shipped with. It is NOT the textbook RTL mirror (which
   would put "mine" on the left), so nobody should "fix" it back later.

   Mechanics, because three layers fight over this:
   - The ROW runs on a PHYSICAL left-to-right axis (`direction: ltr`), so
     flex-start means the physical left for both speakers no matter what
     style-desktop.css:756/759 forces.
   - assistant: `justify-content: flex-start` -> avatar first, at the far left.
   - user: `justify-content: flex-end`       -> bubble pinned to the right.
   - The BODY switches back to `direction: rtl`, which does two jobs at once: the
     Persian text reads right-to-left, and a max-width block (the capped answer)
     sits at its RTL start edge — the right — so the answer's first-read edge
     lands on the canvas edge instead of floating mid-column.

   Avatar also rides the FIRST line, not the bottom of the card: bottom-aligning
   it put the robot mark under the action row, where it read as a stray badge. */
body:is(.desktop-chat, .mobile-chat) #messages .message-col,
body:is(.desktop-chat, .mobile-chat) #messages .message-row {
  direction: ltr !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message-row {
  display: flex !important;
  align-items: flex-start !important;
  gap: var(--nbgc-row-gap) !important;
  flex-direction: row !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .message-row {
  justify-content: flex-start !important;   /* avatar + answer -> LEFT */
}
body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-row {
  justify-content: flex-end !important;     /* the reader's words -> RIGHT */
}
body:is(.desktop-chat, .mobile-chat) #messages .message-body {
  direction: rtl !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .msg-avatar {
  width: var(--nbgc-avatar) !important;
  height: var(--nbgc-avatar) !important;
  min-width: var(--nbgc-avatar) !important;
  min-height: var(--nbgc-avatar) !important;
  flex: 0 0 var(--nbgc-avatar) !important;
  margin: var(--nbgc-avatar-nudge) 0 0 !important;
  /* ── THE AVATAR RIDES WITH ITS ANSWER ─────────────────────────────────────
     The owner's rule (2026-08-29) is that the robot's mark sits on the LEFT of
     its words. It was implemented as "the left edge of the canvas", which was
     the same place while the answer was 646px wide inside a 780px canvas — 106px
     of gap. When the measure was re-derived to 460px for Nazli (2026-09-02) the
     answer moved right and the badge did not: measured 292px between the avatar
     and the first word it belongs to, a 28px mark alone in an empty field. The
     same defect the 2026-08-29 pass was written to fix ("آواتار ۷۵۰px دورتر از
     پاسخِ خودش"), re-created by a change in a different file.

     `margin-inline-start: auto` inside the row's ltr axis eats the free space
     BEFORE the avatar, so the pair [avatar][answer] is pushed to the row's end
     as one unit and the gap between them is the row's own 10px, at every answer
     length and every viewport width. The side does not change, the answer's
     first-read edge does not move, and the number can never drift again because
     it is no longer a number. */
  margin-inline-start: auto !important;
  border-radius: var(--nbgc-avatar-radius) !important;
  background: var(--nbgc-accent-wash) !important;
  color: var(--nbgc-accent-ink) !important;
  border: 1px solid var(--nbgc-accent-edge) !important;
  font-size: 15px !important;
  font-weight: 700 !important;
  line-height: 1 !important;
  box-shadow: none !important;
  overflow: hidden !important;
}

/* ── THE RING IS FOR A LETTER, NOT FOR A PORTRAIT ───────────────────────────
   The box is border-box, so that 1px wash ring is subtracted from the artwork
   inside it: measured 2026-09-04, a 28px mark drew the robot's photo at 26px and
   a 24px phone mark drew it at 22px. On a monogram the ring and the wash are the
   whole design — a bare Persian letter needs something to sit in. On a photo
   they are a sticker outline around art that already carries its own frame, paid
   for out of the only thing this pass is trying to buy: pixels of face.

   `:has(img)` and not a class, because the markup comes from ONE shared string
   (ROBOT_AVATAR in both templates) and no JS marks the two cases apart — so this
   holds for every robot that exists and every robot added later, which is the
   point. style-desktop.css:922 already said this for the desktop shell and lost
   the cascade to the rule above; now it is said where it wins. */
body:is(.desktop-chat, .mobile-chat) #messages .msg-avatar:has(img) {
  background: none !important;
  border: 0 !important;
  padding: 0 !important;
}

/* The avatar stays on a phone too — it is what marks the LEFT side as the robot's
   (owner's call 2026-08-29). It was hidden here before because 28px + a 12px gap
   is 10% of a 390px reading measure; paid for instead by trimming the canvas
   gutter, so the line still holds ~50 Persian characters:
   390 - 2x12 gutter - (32 + 8) avatar = 326px of text.

   2026-09-04: the phone mark went 24 -> 32 and the desktop one 28 -> 34. Not a
   taste change — the fleet's mark is a PHOTO (every robot but `A` resolves to
   default-robot-160.png, a lit brain over a seedling over roots), and at 24px,
   magnified pixel-for-pixel out of a real render, the two leaves and the stem do
   not separate at all: it is one warm smudge. 32px is where they do. The upper
   bound is the header's own mark (34/36px), because the answer's mark is not
   allowed to outrank the robot's identity at the top of the screen, and 40px was
   measured wrapping a line of the desktop measure. The 8px this costs the phone
   column is 2.4% of it — 51.5 Persian characters became ~50.3. */
/* A RUN OF ANSWERS IS ONE BLOCK, SO IT GETS ONE MARK.
   --nbgc-gap-same already binds consecutive messages from the same speaker into
   a group (15px against the 45/60px turn gap). Repeating the badge on every
   member of that group re-announces a speaker who has not changed — measured on
   the real page 2026-09-02: two answers 15px apart, each with its own 28px mark.
   `visibility: hidden` and not `display: none` on purpose: the box stays, so the
   answer column does not shift by the mark's own width plus the row gap (44px on
   a desktop, 40px on a phone) halfway down a run, and the first mark of
   the run still sits where the owner's rule puts it. The avatar is
   aria-hidden="true" decoration, so nothing is lost to a screen reader either.
   If anything ever lands between two answers, the sibling selector simply stops
   matching and both marks come back — the safe failure. */
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant + .message.assistant .msg-avatar {
  visibility: hidden !important;
}

body.mobile-chat #messages .msg-avatar {
  width: var(--nbgc-avatar-sm) !important;
  height: var(--nbgc-avatar-sm) !important;
  min-width: var(--nbgc-avatar-sm) !important;
  min-height: var(--nbgc-avatar-sm) !important;
  flex: 0 0 var(--nbgc-avatar-sm) !important;
  border-radius: var(--nbgc-avatar-radius) !important;
  font-size: 14px !important;
  margin-top: var(--nbgc-avatar-nudge-sm) !important;
}
body.mobile-chat #messages .message-row {
  gap: var(--nbgc-row-gap-sm) !important;
}
/* A user row has no avatar, so its bubble would otherwise stop 32px short of the
   right edge and look misaligned against the answers above it. */
body.mobile-chat #messages .message.user .message-row,
body.desktop-chat #messages .message.user .message-row {
  padding-inline-start: 0 !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message-body {
  min-width: 0 !important;
  flex: 1 1 auto !important;
}
/* The answer's column is the MEASURE, and it is the same width whether the
   answer is four words or forty lines. That constancy is what lets the avatar
   below park at one x for every message instead of chasing the text. */
/* ── THE ANSWER BLOCK IS AS WIDE AS THE ANSWER, BETWEEN TWO FLOORS ──────────
   `width: fit-content` with a floor of the measure and a ceiling of the stage,
   and each of the three terms is load-bearing:

   fit-content — because the MARK RIDES WITH ITS ANSWER (see the avatar block
     above, and the 2026-08-29 rule it implements). The avatar sits 10px to the
     left of this block, so a block that is always the full stage puts the mark
     294px from the first word of a prose answer — alone in an empty field. That
     is the exact defect the avatar comment says it exists to prevent, and the
     first build of this change re-created it. Caught by looking at a
     screenshot, not by a number: every rect involved was correct.

   min-width: the measure — because a three-word answer would otherwise shrink
     the block to three words, and the footer under it (which is one nowrap row
     of seven chips and a date, `justify-content: space-between`) would be
     crushed. The block was `width: 100%` before this change for exactly this
     reason; the floor keeps that guarantee without the ceiling.

   max-width: 100% — the row's remainder after the mark and its gap. This is
     what lets a table or a code block use the whole 820px stage.

   So: prose answers look precisely as they did, wide content gets the stage,
   and the mark is 10px from whatever the answer actually occupies. Gated by
   "the mark stays beside the words" in test_chat_stage_workbench_gate.js. */
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .message-body {
  width: 100% !important;
  max-width: 100% !important;
}

/* ── THE MARK PARKS AT ONE X, AND THE ANSWER STARTS BESIDE IT ───────────────
   Owner's rule, 2026-09-04, on seeing the wide column for the first time:
   «آواتار ربات کاملا بره طرف چپ … و متن پاسخ ربات هم همینطور کشیده بشه دقیقا
   کنار آواتار چسبیده به چپ … چون الان پاسخ‌ها عرض‌های مختلفی دارن هر پیام
   آواتار ربات جاش فرق داره».

   That is a real defect and it was mine. The pair [mark][answer] used to be
   pushed to the row's END by `margin-inline-start: auto` on the mark, which was
   right while every answer was exactly the measure wide — the mark landed on
   the same x every time. Once the block became as wide as its own content, the
   end-anchored pair made the mark's x a function of the answer's content:
   scrolling a conversation, the badge hopped left and right message by message.

   Anchoring the pair to the row's START instead fixes the mark at one x for
   every answer, forever, whatever the answer contains — and it does the second
   thing the owner asked for at the same time: the robot's side of the
   conversation is the left, the reader's own words stay on the right, and the
   two speakers are told apart by WHERE they are and not only by a tint.

   The prose inside then has to follow the mark, not the far edge — that is
   section 5A's `margin-inline-start: auto`. Desktop only: a phone column has no
   free space for any of this to be a question. */
body.desktop-chat #messages .message.assistant .msg-avatar {
  margin-inline-start: 0 !important;
}
/* A REJECTED SHAPE, RECORDED SO IT IS NOT TRIED AGAIN.
   Between the first and second drafts of this change the block was
   `width: fit-content` with a floor of the measure, so it hugged its own
   content. It solved the right problem the wrong way: with the pair anchored to
   the row's END it kept the mark beside the words, but it made the mark's x a
   function of what the answer happened to contain — which is precisely the
   thing the owner reported. Anchoring the pair to the START (above) removes the
   need for it: the mark cannot move whatever the block does, so the block goes
   back to taking the whole remaining width, and it is the PROSE that is pulled
   over to the mark (section 5A) instead of the box being shrunk to it.
   The rule below is the one that shipped before and after; nothing needs to be
   said per-shell, because nothing here depends on the measure any more. */
/* 2026-09-04 — this pair used to be capped at --nbgc-measure, and that is what
   made the whole desktop app 552px wide: the answer block WAS the prose line,
   so a table, a code block and a diagram all had to fit inside a box sized for
   68 Persian characters. The cap now lives one level down, on the prose
   elements themselves (section 5A below), so the answer block fills the stage
   while the sentences inside it keep the width they were derived for.
   `100%` and not `var(--nbgc-stage)`: the block's own containing block is the
   message row, which the stage already sizes — asking for the stage again here
   would ignore the avatar gutter the row spends and push the answer 44px past
   the column edge, into #messages' silent `overflow-x: hidden`. */
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .message-bubble {
  max-width: 100% !important;
}
/* The user's own message stays a contained quotation, never a full-width slab —
   and never a WIDER one than the answer either.

   Measured on the real page 2026-09-02, desktop: the answer sat in its 460px
   measure while a long question ran to 577px, i.e. the reader's own words got a
   25% wider column and ~80 characters a line where this file's own derivation
   put the comfortable line at 68. Two speakers, two measures, and the louder one
   belonged to the question rather than to the answer — which is the exact fault
   the saturated green bubble was removed for, still present in geometry after it
   was fixed in colour. `min()` keeps the percentage as the small-screen rule and
   the measure as the ceiling, so the two speakers share one column edge. */
body.mobile-chat #messages .message.user .message-body {
  flex: 0 1 auto !important;
  width: fit-content !important;
  max-width: min(86%, var(--nbgc-measure)) !important;
}
/* Desktop carries no percentage any more. While the stage was 780px the 74%
   resolved to 577px and never bound — the measure was always the smaller of the
   two, which is exactly what the paragraph above asked for. Now that the stage
   is the measure plus the avatar gutter (chat-desktop-shell.css), 74% resolves
   to 370px and WOULD bind: a long question would sit 90px short of the answer's
   edge and the two speakers would stop sharing a column edge — the fault this
   rule exists to prevent, re-created by a change in a different file. The
   measure alone is the cap, and it cannot drift with the stage again. */
body.desktop-chat #messages .message.user .message-body {
  flex: 0 1 auto !important;
  width: fit-content !important;
  max-width: min(100%, var(--nbgc-measure)) !important;
}

/* ---------------------------------------------------------------------------
   4) THE ONE BUBBLE
   Assistant: no container at all. The answer is a document on the canvas, which
   is what every first-class assistant product does — and it removes one of the
   three nested boxes an answer used to sit in.
   User: one soft brand-tinted card. Not the old saturated gradient with white
   text: a five-line question in white-on-green is the least readable surface in
   the whole product, and it made the user's own words shout louder than the
   answer they were asking about.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .message-bubble {
  background: none !important;
  background-image: none !important;
  border: 0 !important;
  border-radius: 0 !important;
  box-shadow: none !important;
  padding: 0 !important;
  overflow: visible !important;
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-bubble {
  background: var(--nbgc-user-face) !important;
  background-image: none !important;
  border: 1px solid var(--nbgc-user-edge) !important;
  border-radius: var(--nbgc-radius) !important;
  /* One tightened corner, and it belongs on the side the bubble is ANCHORED to.
     `end-end` resolves to block-end + inline-end, which under rtl is the BOTTOM
     LEFT — the far corner, pointing away from the edge the bubble is pinned to
     and into the empty canvas, where it reads as an asymmetry rather than as a
     tail. Every RTL messenger this product's readers already use (Telegram,
     WhatsApp, Bale) puts it on the anchored side. `end-start` is that corner
     under rtl: bottom right. */
  border-end-start-radius: 7px !important;
  box-shadow: none !important;
  padding: var(--nbgc-r-05) var(--nbgc-inset) var(--nbgc-r-025) !important;
  overflow: visible !important;
  color: var(--nbgc-user-ink) !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-bubble.has-media {
  padding: var(--nbgc-r-025) !important;
}

/* ---------------------------------------------------------------------------
   4B) THE AUDIENCE BADGE — work 4 of docs/CHAT_REDESIGN_MASTER_PLAN.md §7,
   signal 2 of 3 ("a mark ON the answer itself, saved WITH the message"). Only
   the first two signals (the rail's amber/blue title, the 3px rule at the top
   of the column — chat-workbench.css) describe TODAY's toggle; this one is a
   fact about a SPECIFIC turn, stamped once by script.js and never recomputed,
   so a parent who switches the mode back off and leaves cannot make an old
   answer stop admitting who it was written for.

   Coloured by the message's OWN `data-audience-mode`, never by the page's
   current `body[data-nabgol-mode]` (chat-workbench.css) — those two can
   legitimately disagree (a restored transcript mixing turns from before and
   after a toggle), and the badge answering "what is on now" instead of "what
   was this answer" is exactly the bug §7 exists to prevent. Same two colours
   as the rail for one visual vocabulary, but this rule owns them independently
   so the two features never have to agree on load order.

   Text carries the meaning (ink-soft, proven 4.89-5.85:1 light / 5.31-7.37:1
   dark on this exact canvas by the footer timestamp two sections down); the
   dot is a decorative accent, not the only signal, so its own contrast is not
   load-bearing. First child of the bubble, above the prose: the reader should
   not have to finish the answer before learning who it was for. */
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .msg-audience-badge {
  display: flex !important;
  align-items: center !important;
  gap: 6px !important;
  margin: 0 0 var(--nbgc-r-025) !important;
  padding: 0 !important;
  max-width: var(--nbgc-measure);
  font-size: 12.5px;
  line-height: 1.4;
  font-weight: 500;
  color: var(--nbgc-ink-soft) !important;
  opacity: 1 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .msg-audience-badge::before {
  content: '';
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: 0 0 auto;
  background: var(--nbgc-mode-mark-parent);
}
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .msg-audience-badge[data-audience-mode="teacher_mode"]::before {
  background: var(--nbgc-mode-mark-teacher);
}

/* ---------------------------------------------------------------------------
   5) PROSE — the part the whole product is judged on.
   16.5px / 1.92 on a phone, 17px / 1.9 on a desktop.

   The leading was re-measured rather than re-asserted (window.__leadingProbe,
   live/tools/_canvas_measure.js: real Persian rasterised at the real size, pixel
   rows scanned for actual ink instead of trusting the font's metric tables).
   What came back corrects this file's own previous claim, which said the dots
   COLLIDE below ~1.85. They do not — not at any value anyone would consider:

     ink of one line spans -14px (over-dots: ت ث ن ق, and the alef/lam risers)
     to +4px (under-dots and the connected ج ح چ bowls) at 16.5px, i.e. an ink
     band about 18px tall. Clear air between one line's under-dots and the
     next line's over-dots:
       lh 1.72 -> 10.4px      lh 1.90 -> 13.3px
       lh 1.85 -> 12.5px      lh 1.92 -> 13.7px      lh 2.18 -> 18.0px

   Nothing touches anything. So the choice is texture, not mechanics, and the
   honest reason to hold 1.90/1.92 is the ratio: air ends up ~0.76 of the ink
   band, at the generous end of a comfortable text setting, and a Persian
   paragraph needs to be at that end because its dots ARE the alphabet — ب پ ت ث
   are one skeleton and a dot count. 2.18 (what shipped before) put the air at
   1.0 of the ink band, which is why long answers read as loose lines instead of
   paragraphs. The compact direction's 1.76 (air 0.61) stayed legible in the
   comparison shots and was rejected for what it did to the hierarchy, not for
   what it did to the dots — see the operating plan for that decision.

   These two values are also what makes --nbgc-rhythm exact: 16.5 x 1.92 =
   31.68 and 17 x 1.90 = 32.30, both 32px to the eye. Changing either one means
   recomputing the rhythm token.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text {
  max-width: 100% !important;
  font-size: var(--nbgc-prose) !important;
  line-height: var(--nbgc-lh) !important;
  font-weight: 400 !important;
  letter-spacing: 0 !important;
  word-spacing: var(--nbgc-wordspace) !important;
  color: var(--nbgc-ink) !important;
  padding: 0 !important;
  text-wrap: pretty;
  hanging-punctuation: first last;
  overflow-wrap: anywhere !important;
  word-break: normal !important;
  font-variant-numeric: proportional-nums;
  /* The region CLAIMS this property instead of inheriting whatever the chain
     decides. chat-flagship-polish.css asked for `emoji` here, which forces emoji
     presentation onto every character that has one — the ASCII digits, `#` and
     `*` included, because they are keycap bases. That is how `1234567890` inside
     inline code became ten blue tiles (measured 2026-09-02). Claimed, so it
     cannot come back through a `:where()` upstream. */
  font-variant-emoji: normal !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message.user .text {
  color: var(--nbgc-user-ink) !important;
  max-width: 100% !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text > :first-child {
  margin-top: 0 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text > :last-child {
  margin-bottom: 0 !important;
}

/* ---------------------------------------------------------------------------
   5A) THE MEASURE STOPS AT THE SENTENCE
   The one rule that lets the app column be 820px without making the reading
   worse. Everything structural — table, code, diagram, image, the action row —
   uses the whole stage; only the running text is held to the width this file
   derived a line length for.

   THEY SHARE A START EDGE, they are not centred and not indented. In RTL that
   is the right edge of the answer block, which every one of these elements
   already sits on: capping the inline size alone leaves the start edge where it
   was and only pulls the far end in. A centred measure inside a wide stage is
   what makes a page look like two documents.

   THE BARE TOKEN, NOT `min(100%, …)`, AND THIS COST A BUILD.
   The defensive-looking version was written first, to keep the cap under the
   stage on a narrow window. It is not needed — a block-level <p> has
   `width: auto`, so it already fills its parent and can never exceed it; the
   max-width only ever caps it downward. And it was actively harmful: a
   percentage inside a max-width resolves against an INDEFINITE containing block
   during intrinsic sizing, so the browser drops the whole min() while computing
   max-content. The paragraph's max-content contribution became "the entire
   sentence on one line", the answer block's fit-content came back as the full
   744px stage instead of 460, and the robot's mark ended up 294px from its
   first word — measured, and only visible in a screenshot, because every rect
   in the row was exactly what the CSS asked for.

   `:not(.nabgol-diagram-shell *)` for the same reason every prose rule above
   carries it: a diagram card is a component with its own type scale, and a
   460px cap on the <p> inside one would reflow the card's internals.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text
  :is(p, li, blockquote, h1, h2, h3, h4, h5, h6, ul, ol):not(.nabgol-diagram-shell *) {
  max-width: var(--nbgc-measure) !important;
}

/* ── WHERE PROSE STOPS: `:not(.nabgol-diagram-shell *)` ─────────────────────
   Every bare-tag rule from here to the end of the list section carries this
   guard, and it means one thing: THE CANVAS STYLES PROSE, NOT THE INSIDE OF A
   COMPONENT. A diagram card is not a paragraph that happens to contain shapes —
   it is a component with its own type scale, its own rhythm and its own ink
   tokens (--nbgd-*), and chat-flagship-polish.css owns all three.

   Left off, these rules reach straight through the card's frame and restyle its
   internals with prose values, with !important, against component rules that
   have none. Measured on the desktop light shell, 2026-08-30
   (tools/_diagram_size_probe.js, `EXPLAIN=compare`):
     · the <h4> naming a compare column took the prose heading rhythm — 32px above
       and 8px below — inside a flex header, making it 92.8px tall for 28px of
       content, three times per card
     · its <ul>/<li> took prose line-height 1.9 and prose list margins, adding
       another ~34px per column
   Total: `compare` stood 597px tall in the chat and 341px in the fullscreen
   viewer — the SAME markup, at a NARROWER width. The viewer was never better
   designed; it simply lives outside `#messages`, so none of this reached it.
   Reproducing that boundary here is what closed most of the gap.

   The guard sits on the SUBJECT of each selector, never on an ancestor: it must
   ask "is the element I am about to style inside a card", not "is some ancestor".
   Prose outside a card is untouched — the `.nabgol-mag-*` rules below are
   class-scoped to shapes only prose emits, so they need no guard. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p:not(.nabgol-diagram-shell *) {
  margin: 0 0 var(--nbgc-r-05) !important;
  line-height: var(--nbgc-lh) !important;
  font-size: 1em !important;
  font-weight: 400 !important;
  color: var(--nbgc-ink) !important;
  background: none !important;
  border: 0 !important;
  padding: 0 !important;
}

/* ── EVEN LINE LENGTHS, AND WHY NOT BY JUSTIFYING ──────────────────────────
   The owner asked for this in as many words: "equal line lengths, tidy setting,
   standard — not overdone". The obvious answer is text-align: justify. It was
   built, measured, and rejected, and the numbers are the reason.

   Persian cannot be justified well by a browser. The engine stretches WORD
   SPACES; it does not extend kashida, the baseline elongation an Arabic
   typesetter would use, because no shipping engine implements it for this
   script. So every pixel of "equal edges" is bought with white between words.
   Measured on 20 real paragraphs in both shells with
   live/tools/_justify_river_probe.js — every space rect on every painted line,
   compared against the same paragraph with justification switched off:

     text-align: justify        widest word gap 0.95em, worst line-to-line
                                space ratio 1.57. One paragraph in five had a
                                hole in it wide enough to see.
     justify + text-wrap:balance   worse, not better: balance shortens each line,
                                then justify has to stretch further to fill it —
                                widest gap went to 2.45em.
    *text-wrap: balance alone   word spaces stay EXACTLY natural (stretch
                                0.001em) and the lines come out 7.3% apart on
                                average, 9.6% at worst.

   Against 40.3% average raggedness with neither, that last row is the owner's
   request answered — nearly equal lines — while the "not overdone" half is
   answered by there being no stretching at all. balance equalises the lines by
   choosing better BREAK POINTS instead of by inflating the spaces, which is
   what a typesetter would have done and what justification cannot do here.

   The honest limit, recorded rather than discovered later: browsers cap
   text-wrap: balance at a handful of lines and silently fall back to normal
   wrapping above it. Every paragraph in the real lesson content is 2-3 lines, so
   the cap is not reached today; a very long paragraph would simply wrap normally
   rather than break. That is a graceful floor, not a cliff.

   Where it is NOT applied, and why each one:
     .user            the user's own line is short and lives in a bubble
     mag-eyebrow/title/section/kicker/pull
                      headings and labels; balancing a two-word label just moves
                      the word
     table cells      never in the selector; balance fights a fixed column
     diagram internals
                      the standing rule of this file — the canvas styles prose,
                      not the inside of a component

   text-wrap: pretty stays on .text above and still governs everything this rule
   does not name. The two are alternatives, not a pair: both are text-wrap-style
   values, so a paragraph gets one or the other. */
body:is(.desktop-chat, .mobile-chat) #messages .message:not(.user) .text
  :is(p, li):not(.nabgol-diagram-shell *):not(.nabgol-mag-eyebrow):not(.nabgol-mag-title):not(.nabgol-mag-section):not(.nabgol-mag-kicker):not(.nabgol-mag-pull) {
  text-wrap: balance;
}

/* Bold is bold. Nothing else. The green highlighter behind every <strong> was
   the single loudest thing in the old canvas. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text strong:not(.nabgol-diagram-shell *),
body:is(.desktop-chat, .mobile-chat) #messages .message .text b:not(.nabgol-diagram-shell *) {
  font-weight: 700 !important;
  color: var(--nbgc-ink-strong) !important;
  background: none !important;
  background-image: none !important;
  padding: 0 !important;
  border-radius: 0 !important;
  box-shadow: none !important;
}

/* Persian has no real italic. A sloped naskh is a synthetic shear that eats
   legibility, so emphasis becomes a colour shift instead.
   It used to be a colour shift PLUS font-weight 500, which Nazli cannot paint:
   the face has 400 and 700 and nothing between, so CSS font matching resolved
   500 down to 400 and the rule quietly did half of what it claimed. Written as
   400 now, because a stylesheet that names a weight the font does not have is a
   stylesheet nobody can reason about. Colour does the work, and <em> in this
   product's prose is rare enough that it does not need more. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text em:not(.nabgol-diagram-shell *),
body:is(.desktop-chat, .mobile-chat) #messages .message .text i:not(.nabgol-diagram-shell *) {
  font-style: normal !important;
  font-weight: 400 !important;
  color: var(--nbgc-ink-strong) !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text a {
  color: var(--nbgc-accent-ink) !important;
  text-decoration: underline !important;
  text-decoration-thickness: 1px !important;
  text-underline-offset: 0.22em !important;
  text-decoration-color: var(--nbgc-accent-edge) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text a:hover {
  text-decoration-color: var(--nbgc-accent) !important;
}

/* Headings never reach chat prose (normalizeProse strips markdown '#' by design,
   gated by tools/test_chat_editorial.php) — but if a robot's custom pipeline ever
   emits one, it must land on a restrained scale rather than a 2em shout. All six
   levels collapse into two steps on purpose.

   `:not(.nabgol-diagram-shell *)` is load-bearing, and this is what it costs to
   leave out. A defensive fallback for a heading that "never happens" in prose was
   silently claiming every heading a DIAGRAM CARD writes on purpose — a compare
   card names each column with an <h4>, and this rule handed it 32px of prose
   rhythm above and 8px below, with !important, inside a flex header whose own
   `margin: 0` had no !important to fight back with. Measured 2026-08-30 with
   tools/_diagram_size_probe.js: `.nabgol-compare-head` was 92.8px tall in the chat
   against 52.4px in the fullscreen viewer, for identical 28px content — 40px of
   dead band per column, three columns per card. The viewer escaped it for one
   reason only: it lives outside `#messages`, so this rule never reached it. That
   is most of why "the chat version never reached the fullscreen's level".

   Third time this exact shape has bitten this area: `#messages *` in the
   reduced-motion reset killed the thinking plant, the `:is(img,video,svg)` reset
   killed KaTeX's radical, and this killed the card headers. A blanket declaration
   inside #messages must always ask whether a COMPONENT below it already decided
   the thing being reset. Prose rules stop where a component's own skin begins. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text :is(h1, h2):not(.nabgol-diagram-shell *) {
  margin: var(--nbgc-r-15) 0 var(--nbgc-r-05) !important;
  font-size: 1.3em !important;
  font-weight: 700 !important;
  line-height: 1.45 !important;
  color: var(--nbgc-ink-strong) !important;
  border: 0 !important;
  padding: 0 !important;
  background: none !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text :is(h3, h4, h5, h6):not(.nabgol-diagram-shell *) {
  margin: var(--nbgc-r-1) 0 var(--nbgc-r-025) !important;
  font-size: 1.1em !important;
  font-weight: 700 !important;
  line-height: 1.5 !important;
  color: var(--nbgc-ink-strong) !important;
  border: 0 !important;
  padding: 0 !important;
  background: none !important;
}

/* ---------------------------------------------------------------------------
   6) THE MAGAZINE CLASSES
   chat-markdown.js labels paragraphs by shape: a greeting becomes .eyebrow, a
   short colon-line becomes .section, the closing question becomes .question.
   That inference is right — the prompt in includes/chat_editorial.php asks the
   model for exactly those shapes. What was wrong is that each label also got a
   colour, so one answer carried five different greens.
   Here: hierarchy from WEIGHT, SPACE and — for the section marker — a RULE.
   Colour is spent once, on the closing question, because that is the only
   paragraph asking the reader to act.

   The section rule is the one device this region kept from the three directions
   that were built and compared on 2026-08-30 (see the operating plan). It does
   the job the green label was doing, it can repeat five times in one answer
   without the answer shouting five times, and it costs no colour at all — which
   matters because colour is the thing that breaks first in dark mode.
   --------------------------------------------------------------------------- */

/* Greeting / verdict line: quiet, tight to the paragraph it introduces. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-eyebrow {
  margin: 0 0 var(--nbgc-r-025) !important;
  font-size: 0.97em !important;
  /* 400, not 600. On Nazli a 600 resolves UP to 700, so this quiet greeting line
     would have come out as the boldest thing near it — the exact defect this
     session was opened to fix, reintroduced by a number nobody re-read after the
     face changed. It is already set apart by being smaller, softer and tight to
     the paragraph below it. */
  font-weight: 400 !important;
  letter-spacing: 0.01em !important;
  color: var(--nbgc-ink-soft) !important;
  background: none !important;
  border: 0 !important;
  padding: 0 !important;
}

/* First paragraph of the message. It is a paragraph, not a headline — a
   four-word "خواهش می‌کنم" used to be typeset as display copy. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-lead {
  margin: 0 0 var(--nbgc-r-05) !important;
  font-size: 1em !important;
  font-weight: 400 !important;
  line-height: var(--nbgc-lh) !important;
  color: var(--nbgc-ink) !important;
  background: none !important;
  border: 0 !important;
  padding: 0 !important;
}

/* .nabgol-mag-title fires on ANY first paragraph under 52 characters, so it lands
   on "خواهش می‌کنم." just as often as on a real opening hook. It gets a small
   weight lift and nothing else — treating it as a display title is what turned a
   four-word courtesy reply into a headline. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-title {
  margin: 0 0 var(--nbgc-r-05) !important;
  padding: 0 !important;
  font-size: 1em !important;
  font-weight: 700 !important;   /* two-weight face: 600 painted as 700 anyway */
  line-height: var(--nbgc-lh) !important;
  letter-spacing: 0 !important;
  color: var(--nbgc-ink-strong) !important;
  background: none !important;
  background-image: none !important;
  border: 0 !important;
  border-radius: 0 !important;
}

/* A TITLE WITH NOTHING TO TITLE IS NOT A TITLE.
   The same classifier that gives .nabgol-mag-title its weight lift fires on ANY
   first paragraph under 52 characters — including a message whose entire body
   IS that paragraph. "خواهش می‌کنم. هر وقت سؤال دیگری داشتی همین‌جا بپرس."
   then renders at 600 in ink-strong, which on that screen makes a four-word
   courtesy reply the HEAVIEST text in the whole conversation, louder than the
   answer it is thanking. Seen on the real page 2026-08-30, both shells.

   `:only-child` is the exact test and it needs no :has(): the footer and the
   action row are siblings of .text, not of the paragraph, so a paragraph that is
   the only child of .text is a message with nothing under it to introduce.
   Same reasoning as the conditional lead below — a label is only a label when
   there is something for it to label. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text > p.nabgol-mag-title:only-child {
  font-weight: 400 !important;
  color: var(--nbgc-ink) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text > p.nabgol-mag-eyebrow:only-child {
  font-weight: 400 !important;
  color: var(--nbgc-ink) !important;
}

/* THE LEAD, AND WHY IT IS CONDITIONAL.
   chat-markdown.js labels the first paragraph of EVERY message .nabgol-mag-lead
   (or .nabgol-mag-title when it is short). That is how "خواهش می‌کنم." became
   display copy in the first magazine pass, and it is why simply re-enabling a
   lead here would repeat that mistake at a larger size.

   A lead only exists inside an ARTICLE. So the rule asks the DOM whether this
   message IS one — does it carry a section label, a step card, a table, a
   diagram, or a display equation? — and only then lets the opening paragraph
   step up. A courtesy reply has none of those and stays a plain sentence. This
   is the "data-driven, not blanket" condition the direction was required to
   satisfy, expressed in the cascade instead of in JavaScript, so it cannot
   drift out of sync with the markup that produces those shapes.

   It lifts .nabgol-mag-lead and NOT .nabgol-mag-title, and that is load-bearing
   rather than fussy: chat-markdown.js already separates the two and they are
   different shapes. A title is a short colon-ended opener ("گام اول: مخرج مشترک
   را پیدا کن.") — a heading, which wants WEIGHT. A lead is the first real body
   paragraph — prose, which wants SIZE and air. The first pass here lifted both,
   and measured on the real scene, four headings silently lost their weight and
   turned into large light sentences.

   :is() wraps the :has() list on purpose: a browser that does not understand
   :has() drops the whole argument instead of throwing the rule away, and the
   lead simply stays a normal paragraph. Degrading to "correct but plain" is the
   only acceptable failure mode for a typographic enhancement. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text:is(
  :has(> p.nabgol-mag-section),
  :has(.nabgol-step),
  :has(table),
  :has(figure.nabgol-diagram),
  :has(.katex-display)
) > p.nabgol-mag-lead:first-child,
body:is(.desktop-chat, .mobile-chat) #messages .message .text:is(
  :has(> p.nabgol-mag-section),
  :has(.nabgol-step),
  :has(table),
  :has(figure.nabgol-diagram),
  :has(.katex-display)
) > p.nabgol-mag-eyebrow:first-child + p.nabgol-mag-lead {
  /* AIR, NOT SIZE — and that is a measurement, not a preference.
     This rule used to set 1.075em. Painted at the desktop scale that is 19.35px
     against the body's 18px, and against the section label's 19.08px: two
     "large" sizes 1.4% apart, which no reader can tell from each other, next to
     a step from the body that no reader can see at all. A size step below ~1.125
     does not read as hierarchy; it reads as an inconsistency, and it cost the
     section label the one thing that made it the loudest object in the answer.
     The lead keeps what actually separates it — a wider gap under it — and the
     file's own thesis holds without exception: hierarchy comes from weight and
     space. Leading follows the region's rhythm for the same reason; 1.85 against
     the body's 1.65 put one paragraph off the 31px grid every other line sits
     on. */
  font-size: 1em !important;
  font-weight: 400 !important;
  line-height: var(--nbgc-lh) !important;
  color: var(--nbgc-ink) !important;
  margin: 0 0 var(--nbgc-r-075) !important;
}

/* Section label ("آنچه می‌دانیم:", "راهنمایی مرحله به مرحله:") — the real
   structure of a NabGol answer. A rule above it, then the label, then a small
   gap so the label visibly belongs to what FOLLOWS it rather than floating
   between two blocks. Distance above (r-15 + the r-05 of padding) is four times
   the distance below: that asymmetry is the whole hierarchy. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-section,
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-kicker,
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-pull {
  margin: var(--nbgc-r-15) 0 var(--nbgc-r-025) !important;
  padding: var(--nbgc-r-05) 0 0 !important;
  font-size: 1.06em !important;
  /* ── TWO WEIGHTS, NOT FOUR ────────────────────────────────────────────────
     Nazli has 400 and 700 and nothing between, so every 500 and 600 this file
     used to carry was a number the browser silently rounded (500 down to 400,
     600 up to 700) — a hierarchy that existed only in the stylesheet. Weight is
     now a binary here, and the middle steps are written as what actually paints.

     700 stays on this label, and that is affordable for a reason that had to be
     measured rather than assumed: NAZLI'S BOLD IS LIGHTER THAN THE OLD FACE'S
     REGULAR. Ink coverage at 16.5px — Nazli 700 is 0.0439 against Vazirmatn RD
     400 at 0.0469 (_font_bakeoff_shot.js). So the answer gets lighter overall
     even with every structural label left at full bold, which is why this
     section did not have to trade hierarchy away to fix "everything looks bold".

     The one place 700 is genuinely wrong is the greeting eyebrow, which drops to
     400 and leans on size, space and colour instead — see that rule. Everything
     else that used to sit at 500 is written as 400 and everything at 600 as 700,
     which is what the browser was painting all along; the file no longer names a
     weight this face cannot produce. */
  font-weight: 700 !important;
  line-height: 1.5 !important;
  letter-spacing: 0 !important;
  color: var(--nbgc-ink-strong) !important;
  background: none !important;
  background-image: none !important;
  border: 0 !important;
  border-top: 1px solid var(--nbgc-rule) !important;
  border-radius: 0 !important;
}
/* A rule at the very top of an answer would separate it from the message above,
   not from anything inside it. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text > p.nabgol-mag-section:first-child,
body:is(.desktop-chat, .mobile-chat) #messages .message .text > p.nabgol-mag-kicker:first-child,
body:is(.desktop-chat, .mobile-chat) #messages .message .text > p.nabgol-mag-pull:first-child {
  margin-top: 0 !important;
  padding-top: 0 !important;
  border-top: 0 !important;
}

/* "نکته:" / "توجه:" — a hairline aside, one box only. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-note {
  margin: var(--nbgc-r-075) 0 !important;
  padding: var(--nbgc-r-05) var(--nbgc-inset) !important;
  font-size: 1em !important;   /* never 0.97em: nested inside a quote the ems
                                  compound and the prose drops under the 16px
                                  floor this platform treats as permanent. */
  font-weight: 400 !important;
  line-height: 1.85 !important;
  color: var(--nbgc-ink) !important;
  background: var(--nbgc-surface) !important;
  border: 0 !important;
  border-inline-start: 2px solid var(--nbgc-hairline-strong) !important;
  border-radius: 0 var(--nbgc-radius-sm) var(--nbgc-radius-sm) 0 !important;
}
html[dir="rtl"] body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-note {
  border-radius: var(--nbgc-radius-sm) 0 0 var(--nbgc-radius-sm) !important;
}

/* The closing question — "your turn". A NEUTRAL card with an accent edge, never
   a green fill: a green-filled card is what the user's own bubble is, and in the
   first light-mode pass the robot's question and the reader's own message were
   the same object at a glance. One meaning, one surface. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text p.nabgol-mag-question {
  margin: var(--nbgc-r-1) 0 0 !important;
  padding: var(--nbgc-r-05) var(--nbgc-inset) !important;
  font-size: 1em !important;
  font-weight: 400 !important;   /* two-weight face: 500 painted as 400 anyway */
  line-height: 1.8 !important;
  color: var(--nbgc-ink) !important;
  background: var(--nbgc-surface) !important;
  background-image: none !important;
  border: 0 !important;
  border-inline-start: 3px solid var(--nbgc-accent) !important;
  border-radius: var(--nbgc-radius-sm) !important;
}

/* ---------------------------------------------------------------------------
   7) LISTS — no card. A list is prose with markers, and the old rounded panel
   around every <ul> was the third nested box in an answer.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text :is(ul, ol):not(.nabgol-diagram-shell *) {
  list-style: none !important;
  margin: var(--nbgc-r-025) 0 var(--nbgc-r-075) !important;
  padding: 0 !important;
  padding-inline-start: 1.55em !important;
  background: none !important;
  background-image: none !important;
  border: 0 !important;
  border-radius: 0 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text :is(ul, ol) :is(ul, ol):not(.nabgol-diagram-shell *) {
  margin: var(--nbgc-r-025) 0 !important;
  padding-inline-start: 1.25em !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text li:not(.nabgol-diagram-shell *) {
  position: relative !important;
  margin: 0 0 var(--nbgc-r-025) !important;
  padding: 0 !important;
  font-size: 1em !important;
  line-height: var(--nbgc-lh) !important;
  color: var(--nbgc-ink) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text li:last-child:not(.nabgol-diagram-shell *) {
  margin-bottom: 0 !important;
}

/* Bullet: a small accent dot on the text's first line, no halo.
   ORDER MATTERS in these two blocks. The layers below set the PHYSICAL `right`
   with `!important`, so it has to be reset — but `inset-inline-start` is just an
   alias for `right` under rtl, so writing `right: auto` AFTER it silently threw
   the marker back to offset 0 and painted the dot on top of the first letter
   (seen in the 2026-08-28 shots). Physical resets first, logical last. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text li[data-kind="bullet"]:not(.nabgol-diagram-shell *)::before,
body:is(.desktop-chat, .mobile-chat) #messages .message .text ul > li:not(.nabgol-diagram-shell *)::before {
  right: auto !important;
  left: auto !important;
  content: "" !important;
  position: absolute !important;
  inset-inline-start: -1.05em !important;
  inset-inline-end: auto !important;
  top: calc(0.5 * var(--nbgc-lh) * 1em - 0.19em) !important;
  width: 0.38em !important;
  height: 0.38em !important;
  border-radius: 999px !important;
  background: var(--nbgc-accent) !important;
  box-shadow: none !important;
  display: block !important;
}

/* Ordered: the Persian numeral itself, tabular and quiet — not a filled pill.
   data-n is written by chat-markdown.js; never rely on Latin CSS counters. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text li[data-kind="num"]:not(.nabgol-diagram-shell *)::before,
body:is(.desktop-chat, .mobile-chat) #messages .message .text ol > li:not(.nabgol-diagram-shell *)::before,
body:is(.desktop-chat, .mobile-chat) #messages .message .text li:not([data-kind]):not(.nabgol-diagram-shell *)::before {
  right: auto !important;
  left: auto !important;
  content: attr(data-n) !important;
  position: absolute !important;
  inset-inline-start: -1.55em !important;
  inset-inline-end: auto !important;
  top: 0 !important;
  width: 1.3em !important;
  height: auto !important;
  display: block !important;
  text-align: center !important;
  font-size: 0.94em !important;
  font-weight: 700 !important;
  line-height: var(--nbgc-lh) !important;
  color: var(--nbgc-accent-ink) !important;
  background: none !important;
  border-radius: 0 !important;
  font-variant-numeric: tabular-nums;
}

/* ::marker still carried a mint colour from the layer below; the markers here
   are ::before, so the real one must stay invisible rather than double up. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text li:not(.nabgol-diagram-shell *)::marker {
  content: "" !important;
  color: transparent !important;
}

/* ---------------------------------------------------------------------------
   8) QUOTES, RULES, CODE
   --------------------------------------------------------------------------- */
/* AN INDENT, NOT A BOX, AND NOT A COLOURED BAR EITHER. A quote in a tutoring
   answer is almost always the model's own aside, and it already sits inside an
   answer that has a table, a step card and a closing card competing for edges.
   Indenting it and dropping the ink one step says "quieter, still mine" with no
   new object on the page — and it takes the accent bar off a paragraph that was
   never asking the reader to act, which is what leaves the accent free to mean
   exactly one thing (see the closing question). */
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote:not(.nabgol-diagram-shell *) {
  margin: var(--nbgc-r-075) 0 !important;
  padding: 0 !important;
  padding-inline-start: var(--nbgc-indent) !important;
  border: 0 !important;
  background: none !important;
  background-image: none !important;
  border-radius: 0 !important;
  color: var(--nbgc-ink-soft) !important;
  font-size: 1em !important;
  font-weight: 400 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote p {
  margin-bottom: var(--nbgc-r-025) !important;
  color: inherit !important;
  font-weight: 400 !important;
}
/* A markdown quote that starts with "نکته:" gets BOTH the quote rule and the
   .nabgol-mag-note rule, i.e. a card inside a bar. Inside a quote the note keeps
   only the type, never a second box. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote p.nabgol-mag-note,
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote p.nabgol-mag-question,
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote p.nabgol-mag-section {
  margin: 0 0 var(--nbgc-r-025) !important;
  padding: 0 !important;
  background: none !important;
  border: 0 !important;
  border-radius: 0 !important;
  font-weight: 400 !important;
  color: inherit !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote p:last-child {
  margin-bottom: 0 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text blockquote p:last-child {
  margin-bottom: 0 !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text hr {
  border: 0 !important;
  height: 1px !important;
  margin: var(--nbgc-r-1) 0 !important;
  background: var(--nbgc-hairline) !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text :not(pre) > code {
  font-family: var(--nbgc-mono) !important;
  font-size: 0.9em !important;
  padding: 0.1em 0.4em !important;
  border-radius: 6px !important;
  border: 0 !important;
  background: var(--nbgc-surface-2) !important;
  color: var(--nbgc-ink-strong) !important;
  direction: ltr;
  unicode-bidi: isolate;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text pre {
  margin: var(--nbgc-r-075) 0 !important;
  border-radius: var(--nbgc-radius-sm) !important;
  border: 1px solid var(--nbgc-hairline) !important;
  background: var(--nbgc-surface) !important;
  overflow-x: auto !important;
  max-width: 100% !important;
}
/* A BIGGER BUTTON NEEDS SOMEWHERE TO STAND.
   chat-components.css pins «کپی کد» `absolute; top:8px; left:8px` INSIDE the
   code block, which was survivable while it was 22px tall and stopped being so
   the moment it became a real target: on the phone it came to rest on the first
   line of the code. Seen in a screenshot of the change, not in any number —
   an absolute box overlapping text is not a finding any rect check reports.
   The block reserves the strip instead, so the mark sits above the code on the
   bubble's own ground and the code starts under it. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-code-block {
  padding-top: calc(var(--nbgc-r-1) * 1.2) !important;
}
@media (pointer: coarse) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-code-block {
    padding-top: calc(var(--nbgc-r-1) * 1.75) !important;
  }
}

/* «کپی کد» — 2026-09-06, found by looking at the day theme, not by a rect.
   chat-components.css paints it `rgba(255,255,255,.14)` with `color:#fff`, from
   the days when a code block was always a dark panel. This file made the panel
   --nbgc-surface, which is nearly white in the day theme, so the control was
   white text on white: present in the DOM, announced to a screen reader, and
   invisible to the reader who needs it. It was 43x22 as well — under the 24px
   the desktop audit asks of any control and far under a thumb.
   Tokens, not hexes, so the day theme is not a second thing to remember. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-copy-code {
  min-height: 26px !important;
  padding: 0.36em 0.9em !important;
  border: 1px solid var(--nbgc-hairline) !important;
  border-radius: 8px !important;
  background: var(--nbgc-surface-2) !important;
  color: var(--nbgc-ink) !important;
  font-family: inherit !important;
  font-size: 11px !important;
  font-weight: 600 !important;
  line-height: 1.4 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-copy-code:hover,
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-copy-code:focus-visible {
  background: var(--nbgc-surface-3) !important;
  color: var(--nbgc-ink-strong) !important;
}
/* A thumb needs the same target here as anywhere else in the thread. */
@media (pointer: coarse) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-copy-code {
    min-height: 40px !important;
    min-width: 56px !important;
    padding: 0.73em 1.1em !important;
  }
}

body:is(.desktop-chat, .mobile-chat) #messages .message .text pre code {
  font-family: var(--nbgc-mono) !important;
  font-size: 13.5px !important;
  line-height: 1.75 !important;
  direction: ltr !important;
  text-align: left !important;
  background: none !important;
  padding: var(--nbgc-r-05) var(--nbgc-inset-sm) !important;
  display: block !important;
}

/* ---------------------------------------------------------------------------
   9) MATH (KaTeX renders on the live DOM, after DOMPurify — see script.js)
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .katex {
  font-size: 1.06em !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .katex-display {
  margin: var(--nbgc-r-075) 0 !important;
  padding: var(--nbgc-r-025) 0 !important;
  overflow-x: auto !important;
  overflow-y: hidden !important;
  direction: ltr !important;
  text-align: center !important;
}
/* chat-flagship-polish.css puts `text-align: left !important` on `.katex` so that
   inline math keeps its LTR run. On a display equation that inner rule wins over
   the centered block, so `$$S = 12 \times 7 = 84$$` sat flush against the left
   edge of the column instead of centred under the sentence it answers. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .katex-display > .katex {
  font-size: 1.12em !important;
  display: block !important;
  text-align: center !important;
}

/* ---------------------------------------------------------------------------
   10) TABLES — hairline rows, no vertical rules, numbers on a tabular grid.
   The scroller and the expand button come from chat-markdown.js; only the skin
   is set here, and the button is pulled out of the first header cell it used to
   sit on top of.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-table-scroll {
  margin: var(--nbgc-r-075) 0 !important;
  border: 1px solid var(--nbgc-hairline) !important;
  border-radius: var(--nbgc-radius-sm) !important;
  background: none !important;
  overflow-x: auto !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text table {
  width: 100% !important;
  border-collapse: collapse !important;
  border: 0 !important;
  font-size: 0.96em !important;
  background: none !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text :is(th, td) {
  padding: var(--nbgc-r-025) var(--nbgc-inset-sm) !important;
  border: 0 !important;
  border-bottom: 1px solid var(--nbgc-hairline) !important;
  text-align: start !important;
  line-height: 1.75 !important;
  color: var(--nbgc-ink) !important;
  background: none !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text th {
  font-weight: 700 !important;
  font-size: 0.92em !important;
  letter-spacing: 0.01em !important;
  color: var(--nbgc-ink-soft) !important;
  background: var(--nbgc-surface) !important;
  border-bottom: 1px solid var(--nbgc-hairline-strong) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text tr:last-child td {
  border-bottom: 0 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text td.nabgol-table-num,
body:is(.desktop-chat, .mobile-chat) #messages .message .text th.nabgol-table-num {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-media-expand-btn {
  inset-block-start: 6px !important;
  inset-inline-end: 6px !important;
  background: var(--nbgc-surface-2) !important;
  border: 1px solid var(--nbgc-hairline) !important;
  color: var(--nbgc-ink-soft) !important;
  box-shadow: none !important;
}

/* ---------------------------------------------------------------------------
   11) MEDIA + DIAGRAMS
   Diagram internals belong to docs/CHAT_GRAPHICS_OPERATING_PLAN.md and its own
   gate — this file only gives the figure consistent outer spacing so it sits in
   the same rhythm as the prose around it.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text figure.nabgol-diagram {
  margin: var(--nbgc-r-1) 0 !important;
  max-width: 100% !important;
}
/* `:not(.katex *)` is load-bearing, and this is the bug it fixes.
   KaTeX does not draw a radical with a glyph. It draws the hook, the overbar,
   and every stretchy construction — \overline, \underline, \widehat,
   \xrightarrow, \overbrace, and the big delimiters — as an inline <svg> that
   katex.min.css sizes with `height: inherit`, taking the height its wrapper
   span computed. A blanket `height: auto` here overrode that, the SVG collapsed
   to zero, and the radical sign SILENTLY DISAPPEARED while the numbers under it
   stayed. On the owner's own screen the chat printed "16/25 = 16/25 = 4/5"
   where the full-screen view (which lives outside #messages, and so escaped
   this rule) printed the correct one. Verified restored in all four modes on
   the real page 2026-08-30, with a radical kept permanently in the lab scene
   and in _chat_real_shot.js so it can never regress unseen.

   The reset itself still matters for everything else: an <img> or a diagram
   <svg> arriving with only a width attribute would otherwise stretch. So the
   fix excludes KaTeX's internals rather than dropping the rule. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text :is(img, video, svg):not(.katex *) {
  max-width: 100% !important;
  height: auto !important;
}

/* AND THE SECOND HALF OF THE SAME BUG, which the first half hid.
   Excluding KaTeX from THIS file's reset was necessary and not sufficient:
   style-mobile.css:319 carries its own
     body.mobile-chat .message .text svg:not(.nabgol-diagram-svg) { height: auto !important }
   so on a phone — the shell almost every student actually uses — the radical
   stayed collapsed after the fix above. Measured, not guessed: the matched-rule
   walk over the real cascade reported the svg's height coming from
   style-mobile.css at 0.25px against a 45.83px .hide-tail parent, while the same
   query on the desktop shell already reported 47.22px.

   The repair belongs here rather than in the shell file: this region owns its
   own appearance, this sheet loads last in both templates, and restoring the
   vendor's own declared value is the narrowest possible correction. It is
   deliberately NOT folded into the rule above — that rule is about what this
   file resets, this one is about what a lower layer breaks. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .katex svg {
  height: inherit !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .attachments,
body:is(.desktop-chat, .mobile-chat) #messages .message .attachment {
  border-radius: var(--nbgc-radius-sm) !important;
  overflow: hidden !important;
}

/* ---------------------------------------------------------------------------
   12) FOOTER — actions under the text's own edge, timestamp whisper-quiet.
   Nothing is removed from the DOM and nothing becomes unreachable: the actions
   stay visible on touch (where there is no hover) and only settle back to a
   quieter opacity on a pointer device.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .footer {
  display: flex !important;
  flex-direction: row !important;
  direction: rtl !important;      /* style-desktop.css:762 forces ltr, which put
                                     the action row at the far LEFT of a 740px
                                     column — 750px from the text it acts on. */
  align-items: center !important;
  /* ── ONE ROW, ON EVERY SCREEN ─────────────────────────────────────────────
     Owner's rule (2026-09-02): every action AND the timestamp live on a single
     line at every width, and the timestamp sits hard against the left end. So
     the row may not wrap and may not scroll — the only variable left is how
     wide each control gets, and that is solved by sharing the line instead of
     sizing the chips (see the TOUCH block below).
     `space-between` with the actions at order -1: under rtl the first item goes
     to the right end and the timestamp to the left, which is exactly the two
     ends the rule asks for, without a spacer element or a margin auto. */
  flex-wrap: nowrap !important;
  justify-content: space-between !important;
  gap: 6px !important;
  margin: var(--nbgc-r-025) 0 0 !important;
  padding: 0 !important;
  border: 0 !important;
  border-top: 0 !important;
  background: none !important;
  min-height: 30px !important;
  /* THE FOOTER FOLLOWS THE PROSE, NOT THE STAGE (2026-09-04).
     Every chip in this row is `flex: 1 1 0` with a 40px cap, so the row is
     exactly full at the width it is given and pure empty space beyond it: handed
     the 820px stage, seven 40px marks plus the timestamp would leave ~460px of
     nothing between the actions and the date, and the row would stop looking
     like it belongs to the answer above it. It shares the prose's start edge and
     the prose's end edge — which is also §12.4 of the master plan: prose, footer
     and chips, one column.
     The bare token and not min(100%, …): see section 5A. A percentage in a
     max-width is dropped while the browser computes max-content, so the version
     with min() left this row contributing its full unwrapped width to the
     answer block's intrinsic size — which is one of the two things that kept
     the block at 744px when its prose was 460. */
  max-width: var(--nbgc-measure) !important;
}
/* The GROUP moves to the inline start; the chips inside keep their own ltr axis
   and their `order` values, which tools/test_chat_chrome_flagship.php gates
   (action_dom_order / action_css_order_and_transparent_regen). */
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions {
  order: -1 !important;
  /* Everything the timestamp does not use, and the region has to CLAIM that:
     style-controls.css:296 caps this box at `calc(100% - 72px)` and hands the
     overflow to `overflow-x: auto` with `scrollbar-width: none` — 72px reserved
     for the date, and any action past it reachable only by dragging a row that
     shows no scrollbar. That is not a visible control, and it is why every
     attempt to resize these targets kept measuring 262px whatever was asked
     for: 262 is exactly calc(334 - 72), the phone column of the day (326 since
     the mark grew, 2026-09-04). Same lesson as `opacity` and
     `direction` earlier in this file. */
  flex: 1 1 auto !important;
  min-width: 0 !important;
  max-width: none !important;
  overflow: visible !important;
  direction: ltr !important;
  display: flex !important;
  align-items: center !important;
  justify-content: flex-end !important;
  gap: 2px !important;
  margin: 0 !important;
  padding: 0 !important;
  background: none !important;
  border: 0 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta {
  /* ORDER 0, AND IT STAYS THERE. A pass on 2026-09-02 moved this group ahead of
     the action chips, reasoning that a timestamp is part of the record and the
     chips are chrome. The owner looked at it and rejected it: the footer's
     furniture does not move. The only thing that changes here is the order of the
     two spans INSIDE this group. */
  order: 0 !important;
  /* It holds its size: the line gives way in the chips, never in the date. */
  flex: 0 0 auto !important;
  white-space: nowrap !important;
  /* THE TIMESTAMP RUNS LEFT-TO-RIGHT INSIDE ITSELF, AND THAT IS THE OWNER'S CALL.
     The GROUP still sits at the row's inline start — the right edge, next to the
     answer's own first word. Only its two spans read the other way: date on the
     left, time on its right, so scanning the numeral run left-to-right gives the
     big unit first. That matches how the run is actually read: digits are an LTR
     island in a Persian line no matter what direction the box declares, which is
     the same fact that made the un-separated version read time-first.
     `direction`, not `order` or `row-reverse`: the separator below is built from
     LOGICAL properties, so it follows this flip on its own and stays between the
     two groups. */
  direction: ltr !important;
  display: flex !important;
  align-items: center !important;
  gap: var(--nbgc-r-025) !important;
  margin: 0 !important;
  font-size: 12.5px !important;
  line-height: 1.4 !important;
  font-weight: 400 !important;   /* two-weight face: 500 painted as 400 anyway */
  /* ── ink-SOFT, not ink-faint, and opacity CLAIMED ──────────────────────────
     Measured on the real page 2026-09-02, all four theme/shell combinations:
     the timestamp painted between 2.21:1 and 2.63:1. The project's own floor for
     a label is 4.5:1 (ROUTER, «هیچ کنترلی از دسترس خارج نمی‌شود»), so the most
     repeated text in the product was the one line that failed it — twice per
     message, on every message.

     TWO causes, and neither was visible in this file:
     1. --nbgc-ink-faint is not what its comment claimed. #78838f measures
        3.86:1 on white, not 4.5:1, and 3.22:1 on the user bubble's tint.
     2. This rule set a colour but never claimed `opacity`, so three other sheets
        did: chat-flagship-polish.css .62, style-mobile.css .75 and
        chat-mobile-flagship.css .72 !important. The winner multiplied every
        carefully chosen ink by 0.72 — the file's own note two rules below says
        "one opacity, set on the parent", and then set it on the CHILDREN only,
        which is why the reset never reached the number that was doing the harm.

     --nbgc-ink-soft is the token whose own definition already says "meta,
     captions, quiet chrome", and it measures 4.89-5.85:1 (light) and 5.31-7.37:1
     (dark) across canvas AND user tint, phone AND desktop. So the fix needs no
     new token: use the one that was designed for this, at the opacity this file
     says it wants. */
  color: var(--nbgc-ink-soft) !important;
  opacity: 1 !important;
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum";
  letter-spacing: 0.01em !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta .msg-date,
body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta .msg-time {
  color: inherit !important;
  font-size: inherit !important;
  font-weight: inherit !important;
  line-height: inherit !important;
  background: none !important;
  padding: 0 !important;
  border: 0 !important;
  /* style-desktop.css:764-765 gave the date .72 and the time .58, so the two
     halves of one timestamp faded differently. One opacity, set on the parent. */
  opacity: 1 !important;
  /* EACH NUMERAL GROUP IS ITS OWN BIDI ISLAND. Two adjacent digit runs in an RTL
     line are two left-to-right islands with nothing between them, and the
     bidi algorithm is free to merge them: that is how a conversation title
     rendered as «۱۱۴۰۵/۰۶/۱۱» — a list number welded to a year. `isolate`
     makes the boundary a rule of the layout instead of an accident of the
     content.
     And the DIRECTION is claimed here, on the spans, not inherited from the
     group: measured 2026-09-02, with the group set to ltr these two still
     computed rtl, which put the separator on the wrong side of the digits.
     style-desktop.css:819 also forces direction: rtl !important on exactly this
     pair. Whatever wins, the region says what it wants — the same lesson this
     file already had to learn about opacity two blocks up. */
  direction: ltr !important;
  unicode-bidi: isolate !important;
}
/* THE ORDER WAS RIGHT AND STILL READ WRONG.
   The date IS the first thing on the line (measured on the live page: date
   325..378, time 289..318 on a phone — right to left, exactly as the owner
   wants). But a reader does not scan «۱۱:۱۲ ۱۴۰۵/۶/۱۱» right-to-left: digits
   are a left-to-right island, both groups are set in the same tabular figures,
   and 7px of flex gap is the only thing between them. So the eye takes the whole
   run left-to-right and reads the TIME first. The fix is a visible boundary, not
   a different DOM order.

   style-desktop.css:766 already tried this with a "·" — and used
   `margin-right`, a PHYSICAL margin, which in an RTL row pushed the dot to the
   far edge of the line. That is why the separator was removed here rather than
   repaired. Logical margins make it land where it belongs: `::before` sits at
   the span's inline START, which under rtl is its right side, i.e. between the
   date and the time. `margin-inline-end` then opens the air on the dot's left,
   and the row's own 7px gap is the air on its right — symmetrical, and it
   cannot drift when the direction changes. */
body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta .msg-time::before {
  content: "" !important;
  display: none !important;
}
/* THE DOT HANGS OFF THE DATE, AND CARRIES ITS OWN ISOLATION.
   First attempt put it on .msg-time::before — the time span's inline START,
   which should be its left side now that the group runs ltr. Measured: it
   painted on the RIGHT of the digits instead, because the span's content is a
   neutral character next to a run of Arabic-Indic digits and the bidi algorithm
   is free to resolve that neutral either way. A separator whose side depends on
   what happens to sit beside it is not a separator.
   So: ::after on the DATE (its inline end under ltr = its right side, i.e.
   between the two groups), and `unicode-bidi: isolate` on the pseudo-element
   itself so the dot is its own run and no neighbouring digit can drag it. Air on
   both sides comes from one token — the margin here and the row gap below — so
   the dot is optically centred between the two numbers. */
body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta .msg-date::after {
  content: "·" !important;
  display: inline !important;
  margin: 0 !important;
  margin-inline-start: var(--nbgc-r-025) !important;
  direction: ltr !important;
  unicode-bidi: isolate !important;
  opacity: 0.5;
  font-variant-numeric: normal;
}

/* THE TWO GROUPS READ AS ONE STRIP AT 6px — 2026-09-08, owner report.
   `.footer`'s base gap (above) is the exact number that keeps a full
   timestamp from overlapping the marks at <360px (the 320px fix a few
   sections up shaved the DATE, not this gap, to buy that clearance — so the
   base value stays untouched below 360px). From 360px up there is real
   slack in the row, and until now every extra pixel there went to bigger
   touch targets instead of a bigger seam. This widens the seam instead: the
   buttons stay one tight cluster (their own 2px gap is unchanged, so they
   still read as a single group) and the timestamp gets a clearly separate
   block of air next to it. */
@media (min-width: 360px) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .footer {
    gap: 18px !important;
  }
}

/* Ghost icon buttons. Border + fill on five chips under every message was the
   noisiest chrome in the canvas; the shape now appears on hover/press only. */
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip,
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > button {
  width: 32px !important;
  height: 32px !important;
  min-width: 32px !important;
  min-height: 32px !important;
  max-width: 32px !important;
  padding: 0 !important;
  margin: 0 !important;
  border: 0 !important;
  border-radius: 9px !important;
  background: none !important;
  background-image: none !important;
  box-shadow: none !important;
  color: var(--nbgc-ink-soft) !important;
  opacity: 0.75;
  transition: background-color 0.16s ease, color 0.16s ease, opacity 0.16s ease;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip .ico,
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > button .ico {
  width: 16px !important;
  height: 16px !important;
  stroke-width: 1.9 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip:hover,
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip:focus-visible {
  background: var(--nbgc-surface-2) !important;
  color: var(--nbgc-ink-strong) !important;
  opacity: 1;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip:active {
  background: var(--nbgc-surface-2) !important;
  transform: scale(0.94);
}

/* THE SEVENTH MARK USED TO BE A WORD.
   installTutorIssueButtons() in script.js appended a real <button> reading
   «اشکال؟» into this row. Every sizing rule here matched it as `> button` and
   forced it into the icon square, and style-controls.css:80 additionally gave
   every button in the row `font-size: 0` — so the word was never painted at
   all. Measured on the real page 2026-09-02: seven controls holding space in
   that row and SIX drawing anything; the seventh a blank 17px target that
   still announced its title to the accessibility tree. Tappable and invisible
   is worse than visible and dead, because the reader never learns to complain.

   It carries the triangle-alert mark now (ACTION_ICONS.issue) and the
   .action-chip class, so it is simply the seventh equal member of the row and
   needs no rule of its own — six marks plus one word read as an accident even
   after the word was drawn. What it still needs is the border off:
   style-controls.css:86 puts a 1px box around it that its six neighbours do
   not have. */
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .msg-issue-btn {
  border: 0 !important;
  padding: 0 !important;
}

/* ── THE CHIPS SHARE THE LINE INSTEAD OF BEING SIZED ────────────────────────
   One row is the rule, so a fixed chip width can only ever be wrong: 7 x 40 +
   the timestamp is 414px against 326px of phone column, and 7 x 32 + a WORD
   («اشکال؟») does not fit either. `flex: 1 1 0` makes every mark take an equal
   share of whatever the timestamp leaves, so the row is exactly full at every
   width — ~28px a mark on a 390px phone, ~23px on a 360px Android, up to the
   40px cap on a desktop column — and it never wraps, never scrolls sideways,
   and never overlaps a neighbour.
   Basis 0 and not `auto` on purpose: from `auto` each chip starts at its own
   content width and only the surplus is shared, which measured as six marks at
   46px and one at 34px. From 0 they are equal by construction.
   HEIGHT is free — it costs the line nothing — so on a touch device the box is
   40px tall even where it is 29px wide. That is a bigger target than the 32x32
   this row shipped with (1160px^2 against 1024px^2) while obeying the one-row
   rule, and the icon inside stays 16px either way. Reaching 44px in BOTH axes
   is arithmetically impossible here with seven controls and a full date on one
   line; the owner chose the single row, and this is the largest target that
   choice allows. */
@media (pointer: coarse) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions {
    gap: 2px !important;
  }
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip,
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > button {
    /* 2026-09-06 — BACK TO THE SEVEN-CONTROL ARITHMETIC, BY THE OWNER'S CHOICE.
       Step 7 raised these numbers to 44/44/32 on the strength of there being
       only five items in the row. The owner reversed that: all seven actions
       are in the row again, so the numbers go back with them, and the note at
       the top of this section applies again word for word — 44px in both axes
       is arithmetically impossible here with seven controls and a full date on
       one line. Re-measured after the reversal and written into the master plan
       (§32-ب): ~30px a chip at 390px and ~25px at 360px, 40px tall. That is the
       price of one row with everything in it, and it is the price the owner
       chose knowingly.
       `flex: 1 1 0` and not a 44px basis: from 0 every mark takes an equal
       share of whatever the timestamp leaves, so the row is exactly full at
       every width and never wraps. From `auto`/44 the row asks for more than
       the column has and the browser distributes the shortfall unevenly —
       measured once as six marks at 46px and one at 34px. */
    flex: 1 1 0 !important;
    width: auto !important;
    min-width: 20px !important;
    max-width: 40px !important;
    height: 40px !important;
    min-height: 40px !important;
    border-radius: 11px !important;
  }
}
/* THE NARROWEST PHONES STILL GET ONE ROW.
   Measured: at 320px the column is 264px, and seven controls plus a full
   date at 12.5px overlapped the timestamp by 11px — the only width where the
   one-row rule and the full timestamp genuinely collide. The timestamp gives
   two points of type there rather than the marks giving three more pixels,
   because a 10.5px date is still a readable date at the same contrast while an
   18px target is not a target. 320px is also the last generation of phone this
   size: everything from 360 up has clearance without this block. */
@media (pointer: coarse) and (max-width: 359px) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta {
    font-size: 10.5px !important;
  }
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip,
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > button {
    min-width: 18px !important;
  }
}
/* MERGED, NOT OVERWRITTEN — 2026-09-06. The block below is cc810bf, another
   session's shipped fix for the same defect, adopted verbatim rather than
   replaced by this branch's own version of it. Two notes it could not carry,
   because they only became true when the answer row went back to seven chips:

     · its `min-width: 44px` now sits against a shared `max-width: 40px`
       instead of 44px. min-width beats max-width in the cascade, so the row
       is still 44px wide — but a pair of declarations that only agrees by
       that rule is a trap, so the ceiling is written here too.
     · the HEIGHT half of its «44x44» came from the shared coarse-pointer
       block, which is 40px again. It is deliberately not raised back for
       this row alone: a reader's row and an answer's row sit a few
       centimetres apart in the same thread, and 44 against 40 is a
       difference you can see. So the reader's row is 44x40 — width from
       cc810bf, height shared with the answer row, and still above the 40px
       the audit asks of a touch target. */
/* THE READER'S OWN ROW IS TWO CHIPS, NOT FIVE — SO IT STOPS BORROWING THE
   ANSWER ROW'S FLOOR. 2026-09-06, master plan step 7.

   The two blocks above pick ONE min-width for every footer on the page: 32px,
   dropping to 18px below 360. That number is derived from the ANSWER's row —
   five chips plus a full date on one line, which at 320px leaves about 190px to
   share. It was applied to the user's row as well, and there it is not a floor
   the row stands on, it is the number the BUBBLE IS SIZED FROM: a short
   question («فهمیدم، ممنون.») shrink-wraps, the bubble's min-content is computed
   from this floor, and ویرایش/حذف came out 18px wide at 320 and 32px at 360 and
   390 — three widths of untappable control, not one. (§32-4 of the master plan
   recorded only the 320px case; measuring 360 and 390 found the same defect.)

   The user's row has TWO chips and the same date, so its arithmetic is a
   different arithmetic and it can afford the full 44px target. Raising ITS
   floor does not squeeze anything: min-content is what `width: fit-content`
   clamps up to, so the bubble simply becomes as wide as its own footer needs —
   measured 214px at 320px, 228px at 360/390, against a ceiling of min(86%,
   measure) that is 254px at the narrowest. The answer row is untouched by
   construction (`.message.user` is in the selector), so five chips still share
   what the timestamp leaves and 320px still gets one row.

   Specificity, not order, is what makes this win: adding `.user` puts this at
   (1,5,1) against the (1,4,1) of both blocks above, so it holds wherever it is
   moved to and whichever of the two would otherwise apply. */
@media (pointer: coarse) {
  body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-actions > .action-chip,
  body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-actions > button {
    min-width: 44px !important;
    max-width: 44px !important;
  }
}
/* A pointer device has a wider column and does not need the 40px box, but it
   does need the same one-row sharing, or the seven fixed 32px squares overflow
   a narrow desktop window. */
@media (hover: hover) and (pointer: fine) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip,
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > button {
    flex: 0 1 auto !important;
    min-width: 24px !important;
  }
}
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip.copied {
  color: var(--nbgc-accent-ink) !important;
  opacity: 1;
}

/* Pointer devices: the row rests at half strength and comes up on hover. Touch
   devices keep it at full strength, because there is no hover to reveal it. */
@media (hover: hover) and (pointer: fine) {
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions {
    opacity: 0.55;
    transition: opacity 0.18s ease;
  }
  body:is(.desktop-chat, .mobile-chat) #messages .message:hover .message-actions,
  body:is(.desktop-chat, .mobile-chat) #messages .message:focus-within .message-actions {
    opacity: 1;
  }
  /* The TIMESTAMP does not fade. An icon row that comes up on hover is a
     progressive disclosure; a line of text that rests below its contrast floor
     is not — it is unreadable text with a hover-only remedy, and a hover is not
     a remedy on the device most of these students hold. The ink is already the
     quiet one (--nbgc-ink-soft); it is quiet by colour, at 4.9:1, not by
     dimming. */
}

/* The user's actions live on a tinted card — same ghost treatment, tuned ink. */
body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-actions > .action-chip {
  color: var(--nbgc-ink-soft) !important;
  background: none !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message.user .message-actions > .action-chip:hover {
  background: var(--nbgc-surface-2) !important;
  color: var(--nbgc-user-ink) !important;
}

/* A UI-only assistant turn (buttons, no prose) has nothing to copy or read
   aloud, so it should not print an empty footer under itself. */
body:is(.desktop-chat, .mobile-chat) #messages .message.assistant .message-bubble.ui-only > .footer {
  margin-top: var(--nbgc-hair-nudge) !important;
}

/* ---------------------------------------------------------------------------
   13) VERSION SWITCH + INLINE EDIT — same hairline language as everything else.
   --------------------------------------------------------------------------- */
/* `:not([hidden])` is load-bearing. appendMessage() renders .version-controls and
   .edit-area on EVERY message and relies on `hidden` / a display rule to keep
   them out of sight; a bare `display: flex !important` here made "قبلی / بعدی"
   appear under every single bubble (caught on the real page, not in the lab). */
body:is(.desktop-chat, .mobile-chat) #messages .message .version-controls:not([hidden]) {
  display: flex !important;
  align-items: center !important;
  gap: 6px !important;
  margin: var(--nbgc-r-025) 0 0 !important;
  padding: 0 !important;
  background: none !important;
  border: 0 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .version-controls button {
  height: 28px !important;
  padding: 0 var(--nbgc-inset-sm) !important;
  border-radius: 8px !important;
  border: 1px solid var(--nbgc-hairline) !important;
  background: none !important;
  color: var(--nbgc-ink-soft) !important;
  font-family: inherit !important;
  font-size: 12px !important;
  font-weight: 700 !important;   /* two-weight face: 600 painted as 700 anyway */
}
body:is(.desktop-chat, .mobile-chat) #messages .message .version-controls button:hover {
  background: var(--nbgc-surface-2) !important;
  color: var(--nbgc-ink-strong) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .version-controls .version-badge {
  font-size: 12px !important;
  color: var(--nbgc-ink-faint) !important;
  font-variant-numeric: tabular-nums;
}

/* Belt and braces for every element the app hides with the attribute. */
body:is(.desktop-chat, .mobile-chat) #messages .message [hidden] {
  display: none !important;
}

body:is(.desktop-chat, .mobile-chat) #messages .message .edit-area textarea {
  font-family: inherit !important;
  font-size: var(--nbgc-prose) !important;
  line-height: 1.8 !important;
  color: var(--nbgc-ink) !important;
  background: var(--nbgc-surface) !important;
  border: 1px solid var(--nbgc-hairline-strong) !important;
  border-radius: var(--nbgc-radius-sm) !important;
  padding: var(--nbgc-r-025) var(--nbgc-inset-sm) !important;
}

/* ---------------------------------------------------------------------------
   14) MOTION
   --------------------------------------------------------------------------- */
/* This block used to read `#messages, #messages *`, and that universal
   descendant was a wall violation hiding in plain sight: it matches the
   thinking ritual (#typing, the plant, its signal rings) that this file is
   explicitly forbidden to touch, and it does so in a form no selector-text gate
   catches — the gate looks for `#typing|.typing-indicator|.nabgol-think`, and
   `*` names none of them. Reduced motion still has to be honoured, so the reset
   now names exactly the two things THIS file animates (the arrival of the newest
   message and the streaming caret) plus the region's own smooth scrolling. Any
   other animation inside the region belongs to the file that declared it, and
   that file gets to decide how it behaves under reduced motion. */
@media (prefers-reduced-motion: reduce) {
  body:is(.desktop-chat, .mobile-chat) #messages {
    scroll-behavior: auto !important;
  }
  body:is(.desktop-chat, .mobile-chat) #messages > .message,
  body:is(.desktop-chat, .mobile-chat) #messages .message .text > p:last-child::after,
  body:is(.desktop-chat, .mobile-chat) #messages .message .text > li:last-child::after,
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions,
  body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip,
  body:is(.desktop-chat, .mobile-chat) #messages .message .footer-meta {
    transition: none !important;
    animation: none !important;
  }
}

/* ---------------------------------------------------------------------------
   15) THE EMPTY STATE — the first thing anyone sees in the middle region.
   Structure and chip hierarchy belong to includes/starter_chips.php and its gate
   (tools/test_action_buttons_gate.php); only the type and the card are set here.
   The subtitle was 13px, which is the robot's own one-line description — the
   sentence that tells a parent what this tutor is for, set smaller than the
   timestamps. The card also stops being an opaque slab: on the canvas a hairline
   is enough to hold the group together.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .empty {
  border: 1px solid var(--nbgc-hairline) !important;
  border-radius: 20px !important;
  background: var(--nbgc-surface) !important;
  box-shadow: none !important;
}
body.mobile-chat #messages .empty {
  padding: var(--nbgc-r-1) var(--nbgc-inset) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .empty .title {
  font-size: 20px !important;
  font-weight: 700 !important;
  line-height: 1.6 !important;
  letter-spacing: 0 !important;
  color: var(--nbgc-ink-strong) !important;
  margin: 0 0 var(--nbgc-r-025) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .empty .subtitle {
  font-size: 15.5px !important;
  font-weight: 400 !important;
  line-height: 1.85 !important;
  color: var(--nbgc-ink-soft) !important;
  max-width: 34em !important;
  margin: 0 auto var(--nbgc-r-05) !important;
  text-wrap: pretty;
  /* chat-mobile-flagship.css:206 clamps this to 2 lines at 0.8rem and 0.62 opacity.
     It is the robot's own one-sentence description — the only text that tells a
     parent what this tutor does — and it was being cut off mid-word with "…".
     Nothing else in the empty state competes for the space. */
  display: block !important;
  -webkit-line-clamp: none !important;
  overflow: visible !important;
  opacity: 1 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .empty .big-icon {
  border-radius: 16px !important;
  box-shadow: none !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .empty .nabgol-free-intro-note {
  font-size: 13px !important;
  line-height: 1.8 !important;
  color: var(--nbgc-ink-faint) !important;
  margin-top: var(--nbgc-r-025) !important;
}
/* Chips: readable label, comfortable target. Colour, order and the
   mission/hero hierarchy stay exactly where their own gate put them. */
body:is(.desktop-chat, .mobile-chat) #messages .empty .suggestion-chip {
  font-size: 14.5px !important;
  line-height: 1.6 !important;
  min-height: 42px !important;
}

/* ---------------------------------------------------------------------------
   16) THE JUMP-TO-LATEST BUTTON floats over the canvas, so it belongs to the
   canvas language even though it lives outside #messages.
   --------------------------------------------------------------------------- */
/* 2026-09-05 — `.chat-main >` became `:is(.chat-main, .chat-input) >`, and the
   reason is a move, not a refactor: on desktop the button is now a child of
   the composer (assets/script.js, work 11.1 of the master plan) so it rides
   inside the band the canvas pays padding for instead of floating over the
   conversation. Left as a direct-child selector on purpose — the paint below
   belongs to THIS button and not to anything else that might one day carry
   the class — and widened rather than loosened, so the light-theme fix in the
   block underneath keeps applying wherever the node ends up. */
body:is(.desktop-chat, .mobile-chat) :is(.chat-main, .chat-input) > .scroll-bottom-btn {
  border: 1px solid var(--nbgc-hairline-strong) !important;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22) !important;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}
/* …including its PAINT, which this block used to leave to style-desktop.css:467
   — `rgba(30,30,36,0.8)` with `#e4e4e7` ink and no light variant. Measured on
   the real page 2026-09-02, desktop light: a near-black disc floating in the
   middle of a #f6f8fa canvas, the single darkest object on the screen, sitting
   over the action row of whatever message it happens to cover. That is the
   accent-budget rule («chrome هرگز از محتوا بلندتر حرف نمی‌زند») broken by a
   control that is not even an accent. Same lifted-surface language as every
   other floating control in this region, in both themes; geometry, z-index and
   show/hide behaviour are untouched — they belong to the section that fixed
   them (2026-08-31). */
body.desktop-chat :is(.chat-main, .chat-input) > .scroll-bottom-btn {
  background: var(--nbgc-header-lifted) !important;
  color: var(--nbgc-ink) !important;
}
body.desktop-chat :is(.chat-main, .chat-input) > .scroll-bottom-btn:hover {
  background: var(--nbgc-surface-raised) !important;
  color: var(--nbgc-ink-strong) !important;
}

/* ---------------------------------------------------------------------------
   17) STEP CARDS (آکاردئون قدم) — theme-aware, and part of the canvas language.
   chat-flagship-polish.css:860 hard-codes rgba(30,41,59,.95) with #f8fafc text
   and has no light variant, so on a white page the steps rendered as three black
   slabs in the middle of the answer (measured on the real page 2026-08-28). The
   card structure and the feature's behaviour belong to
   docs/EDTECH_FEATURES_WALL.md and its gate; only the skin is set here.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step {
  margin: 0 0 var(--nbgc-r-025) !important;
  border-radius: var(--nbgc-radius-sm) !important;
  background: var(--nbgc-surface) !important;
  border: 1px solid var(--nbgc-hairline) !important;
  overflow: hidden !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step > summary {
  padding: var(--nbgc-r-025) var(--nbgc-inset) !important;
  gap: 10px !important;
  color: var(--nbgc-ink-strong) !important;
  font-size: 1em !important;
  font-weight: 700 !important;   /* two-weight face: 600 painted as 700 anyway */
  line-height: 1.7 !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step > summary:hover {
  background: var(--nbgc-surface-2) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step > summary::after {
  border-right-color: var(--nbgc-ink-soft) !important;
  border-bottom-color: var(--nbgc-ink-soft) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step-n {
  width: 1.55em !important;
  height: 1.55em !important;
  background: var(--nbgc-accent-wash) !important;
  border: 1px solid var(--nbgc-accent-edge) !important;
  color: var(--nbgc-accent-ink) !important;
  font-size: 0.86em !important;
  font-weight: 700 !important;
  font-variant-numeric: tabular-nums;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step-body {
  padding: 0 var(--nbgc-inset) var(--nbgc-r-025) !important;
  color: var(--nbgc-ink) !important;
  font-size: 1em !important;
}
/* پاسخ نهایی — the destination card. An accent edge, not a filled green box. */
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step-final {
  border: 1px solid var(--nbgc-accent-edge) !important;
  border-inline-start: 3px solid var(--nbgc-accent) !important;
  background: var(--nbgc-accent-wash) !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .text .nabgol-step-final > summary {
  color: var(--nbgc-ink-strong) !important;
}

/* ---------------------------------------------------------------------------
   18) ARRIVAL — the one piece of motion this region owns.

   Scope note, deliberately narrow: the thinking ritual (#typing, the plant with
   its signal rings and its video) is NOT touched here. It was expensive to build
   and it stays exactly as it is; nothing in this section selects it.

   Only the NEWEST message animates — `:last-child`, not every `.message`. That
   is what keeps a history restore cheap: the app appends the whole stored
   transcript inside one synchronous loop, so the intermediate messages never
   survive a painted frame as :last-child and never start an animation at all.
   When the next message arrives the previous one simply loses the selector, and
   because the animation's end state IS the element's natural state, dropping it
   causes no jump.

   Composited properties only (opacity + transform): neither triggers layout, so
   this cannot add a layout shift — asserted by test_chat_canvas_perf_gate.js,
   which measures CLS across a 100-message build. No `will-change`: it would pin
   a compositor layer per message for a 260ms effect.
   --------------------------------------------------------------------------- */
@keyframes nbgc-arrive {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* style-common.css:292 puts `animation: fadeIn 0.25s` on EVERY .message. Measured
   2026-08-29 on the real page: opening a 100-message conversation started 100
   concurrent fade+translate animations in one frame. Same visual result for the
   one message a human is looking at, a hundredth of the work. */
body:is(.desktop-chat, .mobile-chat) #messages > .message {
  animation: none !important;
}

/* `:last-child` does NOT work here and looked like it did: #messages ends with
   the thinking indicator, so the newest message is never the last CHILD.
   `:nth-last-child(1 of .message)` is the precise tool — "last element matching
   .message" — and it lives inside `:is()` so a browser that does not understand
   `of S` ignores that argument instead of throwing the whole rule away. */
body:is(.desktop-chat, .mobile-chat) #messages > .message:is(:last-child, :nth-last-child(1 of .message)) {
  animation: nbgc-arrive 0.26s cubic-bezier(0.22, 1, 0.36, 1) !important;
}

/* The caret says "still writing" INSIDE the answer, where the eye already is.
   `.is-streaming-reply` is set by script.js on the message being streamed and
   removed when it finishes, so this needs no JS of its own. It rides the last
   paragraph rather than the .text block, so it sits at the end of the sentence
   (the LEFT edge in RTL) instead of alone on a new line. A message that ends in
   a table or a diagram simply gets no caret — better than a bar floating under
   a card. */
body:is(.desktop-chat, .mobile-chat) #messages .message.is-streaming-reply .text > p:last-child::after,
body:is(.desktop-chat, .mobile-chat) #messages .message.is-streaming-reply .text > li:last-child::after {
  content: "";
  display: inline-block;
  width: 2px;
  height: 1.05em;
  margin-inline-start: 0.18em;
  vertical-align: -0.16em;
  border-radius: 1px;
  background: var(--nbgc-accent);
  animation: nbgc-caret 1s steps(2, start) infinite;
}
@keyframes nbgc-caret { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

/* ── AND THE ANSWER LIVES ON THE ROBOT'S SIDE ───────────────────────────────
   Owner, 2026-09-04: «کشیده شدن پاسخ ربات به سمت چپ … چون در حال حاضر عیناً
   زیر نوشتهٔ کاربر هست».

   That is what a 460px paragraph inside an 820px column does on its own: the
   block is RTL, so a box narrower than its parent settles at the inline START,
   which here is the far right — directly underneath the reader's own bubble,
   with a 290px empty field between it and the robot's mark. Two speakers in one
   vertical strip, told apart only by a tint.

   `margin-inline-start: auto` in an RTL block is `margin-right: auto`, which
   pushes the box to the physical LEFT. So the sentences hug the mark, the
   reader's words stay on the right, and the answer and the question stop
   sharing a column. Full lines run the whole 460px and touch the mark's gutter;
   only a short last line falls short, on the left, where a ragged edge belongs.

   The footer goes with it (the action row is part of the answer, not of the
   canvas), and so does anything wide: a table or a code block is already the
   full block width, so it starts on this same left edge without being told.
   ONE start edge for the whole answer — §12.4 of the master plan — it is just
   the left one now, and the left one is the side the robot speaks from.
   Desktop only: on a phone the block IS the column and there is no free space
   for a box to settle in the wrong half of.

   A LIST IS ANCHORED AS A LIST, not item by item: the <ul> carries the cap and
   the auto margin, and its <li>s fill it. Anchoring each <li> would leave the
   markers' 1.55em gutter behind and stagger a nested list against its parent. */
/* THE WHOLE DOM PATH IS SPELLED OUT, ON PURPOSE.
   The short form of this selector — `.message.assistant .text :is(p, …)` — is
   (1 id, 5 classes) and it LOSES. The magazine rules of section 6 and of
   chat-flagship-polish.css set the `margin` SHORTHAND with !important, and the
   most specific of them,
     `… .text:is(.nabgol-mag-article, .nabgol-mag-prose) > p.nabgol-mag-eyebrow:first-child + p.nabgol-mag-lead`
   is (1 id, 7 classes, 3 elements). Measured in the lab: the lead paragraph of
   a magazine-styled answer stayed at 574..1034 while its own action row had
   already moved to 290..750 — two halves of one answer in two halves of the
   column, which is worse than either side alone. An inline `margin-right: auto`
   on the same element moved it to 290, so the box model was never the problem;
   the cascade was.
   Writing the real chain (.message-col > .message-row > .message-body >
   .message-bubble > .text) takes this to (1 id, 9 classes) and outranks every
   one of them, and it does it with the elements that are actually there rather
   than with a `:not(#x)` trick a future reader would have to decode.
   Both the logical and the physical property, for the same reason: a shorthand
   writes the physical longhands, and under this canvas's hardcoded rtl the two
   say exactly the same thing, so neither can be the one that loses. */
body.desktop-chat #messages .message.assistant
  .message-col .message-row .message-body .message-bubble .text
  :is(p, blockquote, h1, h2, h3, h4, h5, h6, ul, ol):not(.nabgol-diagram-shell *),
body.desktop-chat #messages .message.assistant
  .message-col .message-row .message-body .message-bubble > .footer {
  margin-inline-start: auto !important;
  margin-right: auto !important;
}
/* A nested list belongs to its parent item, not to the column — and it needs
   the same spelled-out path to outrank the rule above it. */
body.desktop-chat #messages .message.assistant
  .message-col .message-row .message-body .message-bubble .text
  :is(ul, ol) :is(ul, ol):not(.nabgol-diagram-shell *) {
  margin-inline-start: 0 !important;
  margin-right: 0 !important;
  max-width: 100% !important;
}

/* ---------------------------------------------------------------------------
   9) COST CEILING — a long transcript must cost what a short one costs
   ----------------------------------------------------------------------------
   Measured on 2026-09-02 with live/tools/_chat_perf_probe.js (real page, real
   phone metrics, real CPU throttling — not by reading code):

     Every message the conversation has ever held sits in the DOM, because the
     message-record law says a message is a record and restore replays the whole
     transcript. That is right, and it is not the bug. The bug is that NOTHING
     told the browser those messages could be skipped, so every single layout
     pass walked all of them. Typing one letter runs a layout pass — the composer
     autosize sets height:auto and reads scrollHeight — so on a long history the
     cost of one keystroke grew with the number of messages ever exchanged.
     That is the freeze the user reported, and it gets worse forever.

   `content-visibility: auto` is the fix that has no ceiling: the browser skips
   layout, style and paint for any message far enough off-screen, so the cost of
   a keystroke is the cost of what is ON SCREEN — the same at 20 messages and at
   20,000. No windowing, no recycling, no message removed from the DOM: history
   parity, print, in-page search and the record law are all untouched.

   `contain-intrinsic-size: auto <len>` makes the placeholder remember the real
   height each message had the last time it was rendered, so the scrollbar and
   the scroll position never jump. The literal length is only ever used for a
   message that has not been laid out even once — and there is none, because
   script.js only sets data-nbg-skippable AFTER the enhance chain has painted
   and measured that message. That ordering is the whole safety argument:
   KaTeX, hljs and above all the Mermaid polish (which reads real
   getBoundingClientRect values) always run while the message is fully rendered.
   --------------------------------------------------------------------------- */
body:is(.desktop-chat, .mobile-chat) #messages .message[data-nbg-skippable="1"] {
  content-visibility: auto;
  contain-intrinsic-size: auto 260px;
}

/* Printing and "save as PDF" must contain the WHOLE conversation, never just
   the part that happened to be on screen. Chrome renders skipped subtrees when
   printing, but a browser that only half-implements that would silently print
   blank boxes — and a blank page is the kind of defect nobody notices until a
   parent has it in their hand. Turning the property off for print costs one
   line and removes the question. */
@media print {
  body:is(.desktop-chat, .mobile-chat) #messages .message[data-nbg-skippable="1"] {
    content-visibility: visible;
    contain-intrinsic-size: none;
  }
}

/* The kill switch, and the way anything that must see the WHOLE transcript asks
   for it. Same specificity as the rule above and written after it, so it wins.
   Used by nabgolRenderAllMessages() in script.js — history-parity digests, and
   any future measurement that needs every turn laid out at once. */
body.nbg-render-all #messages .message[data-nbg-skippable="1"] {
  content-visibility: visible;
  contain-intrinsic-size: none;
}

/* ===========================================================================
   THE ROW UNDER AN ANSWER SAYS WHAT IT DOES  (step 5 · work 11.2)
   docs/CHAT_REDESIGN_MASTER_PLAN.md §24.2. Desktop only: the phone column is
   326px and every one of these marks is already fighting for it there.

   §24.2 asks for two things and this block does one of them on purpose. The
   MEASUREMENT is why:

     the action row is capped by `.footer { max-width: var(--nbgc-measure) }`
     a few hundred lines above — the PROSE line, 460px, not the 820px stage.

   That cap is trap §10.2 of the plan, and it is deliberate: seven 40px chips
   spread across 820px would be a row of marks floating in half a column of
   nothing. So «با ستونِ ۸۲۰px حالا جا هست» is not true of THIS row — it never
   got the step-1 width and was never meant to. What the row does have is
   ~364px once the timestamp is paid for, and seven icon squares only spend
   ~190 of it. The surplus buys words, which is the half of §24.2 that pays:
   four marks a student uses on every answer stop being a guess.

   The other half — «کم‌کاربردها پشتِ یک «…» بروند» — is NOT built, and the
   reason is the same number. A menu that hides three chips behind a click,
   out of a row that already fits with room to spare, adds a click and removes
   nothing; and it would put a second markup path into a region the message
   record law says has exactly one (`nabgolCommitMessageRender`). Secondary is
   said here the way a toolbar says it — the rare marks stay marks while the
   common ones become words — which needs no JS and cannot desynchronise from
   the stored record. If the row ever stops fitting, THAT is when the menu
   earns its click, and `buildOverflowMenu()` in script.js is already written.
   =========================================================================== */
@media (min-width: 768px) {
  /* ── A ROW WITH WORDS IN IT CANNOT REST DIMMED ────────────────────────────
     Caught by looking at the first screenshot of this change, not by a number.
     The row rests at `opacity: 0.55` on pointer devices and each chip adds its
     own `0.75`, so anything in it paints at 41% — which is a fine way to keep
     an ICON quiet and a broken way to keep a WORD quiet. That is this file's
     own argument, made a few rules above about the timestamp: «quiet by
     colour, not by dimming», because a hover is not a remedy and a label
     nobody can read is worse than no label.
     So on desktop the row rests fully opaque and --nbgc-ink-soft does the
     quieting, at the 4.9:1 that token is measured at. Hover still steps up to
     ink-strong plus a surface, so the two states still differ. The phone keeps
     the old behaviour untouched — it has no hover and no labels. */
  body.desktop-chat #messages .message .message-actions,
  body.desktop-chat #messages .message:hover .message-actions,
  body.desktop-chat #messages .message:focus-within .message-actions {
    opacity: 1 !important;
  }
  body.desktop-chat #messages .message .message-actions > .action-chip,
  body.desktop-chat #messages .message .message-actions > button {
    opacity: 1 !important;
  }

  /* NO WORDS INSIDE THE BOXES — owner, 2026-09-06, reversing clause 1 of
     master-plan step 7. What stood here was a rule that gave every chip
     `width: auto`, a 10px font, `padding-inline` and a 32px height so its
     `.action-label` could be drawn as inline text on the chip, plus a
     companion rule turning that label from a tooltip into static text. The
     owner does not want it: «راهنما داخل دکمه که بزرگش میکنه بیخود».
     Both rules are gone rather than narrowed back to three chips, because
     three labelled chips beside four bare ones is exactly the mixed row that
     started this. Every chip in the row is now the 32px icon square the base
     rule at the top of this section already gives it, and the ONLY thing that
     names a chip is the hover/focus tooltip in style-controls.css:122.
     So: nothing to add here on desktop. The absence is the rule. */

  /* 4px, unchanged. This was raised from 2px when three chips carried words and
     one chip's last letter was landing against the next chip's mark. The words
     are gone, so 2px would be defensible again — but the owner asked for the row
     it had before, not for a row two pixels tighter than it, and a desktop
     column has the 12px to spare. Left alone on purpose. */
  body.desktop-chat #messages .message .message-actions {
    gap: 4px !important;
  }

  /* The §16-7 tooltip-overhang fix used to live here, desktop-only and keyed on
     `:first-child` / `:last-child`. It moved to the end of this file, for both
     shells and keyed on the VISUAL ends — see the section there for what it was
     measured to be doing wrong. */
}

/* ═══════════════════════════════════════════════════════════════════════════
   THE 12px THAT HUNG OFF THE EDGE — §16-7, and where the edge really is
   ═══════════════════════════════════════════════════════════════════════════
   Both shells, and keyed on the chips that can hold a VISUAL end of the row.

   `.action-label` is a hover/focus tooltip centred on its chip
   (style-controls.css:122 — `left: 50%; transform: translateX(-50%)`). The row
   is `justify-content: flex-end` inside a `space-between` footer, so its right
   end is flush against the bubble's inner edge and about 12px from the thread
   column, while its left end has the whole bubble behind it. Half a wide card
   on the chip at the right end is therefore wider than what is left, and
   `#messages { overflow-x: hidden }` slices it with no scrollbar — invisible to
   a probe that asks "how far outside the PARENT is it", because the chip's own
   edge and the column's edge are the same x (§10.12).

   WHY NOT `:first-child` / `:last-child`, which is what this fix used to be:
   those are DOM positions and this row is reordered by CSS. Measured on the
   phone at 390px, 2026-09-06 — DOM order is dossier · delete · copy · صوت ·
   بازتولید · سؤال مشابه · اشکال, and the `order` rules in style-controls.css
   (:430) lay it out as اشکال(0) · حذف(1) · پرونده(2) · کپی(3) · صوت(4) ·
   بازتولید(5) · مشابه(6). So `:last-child` is «این پاسخ اشکال داشت», which
   sits at the row's LEFT end with 170px of clearance, while the chip actually
   at the right end — «سؤال مشابه» — kept its centred tooltip and hung out of
   the column by 3px at 390, 5px at 360 and 7px at 320. The DOM-position rule
   was anchoring the wrong two chips, in silence, in both directions.

   So the ends are named by class. Right end: `.similar-btn` (order 6, the
   tutor rows) or `.regenerate-btn` (order 5, when there is no practice CTA) or
   `.delete-btn` (order 1, the reader's own two-chip row). Left end:
   `.msg-issue-btn` (order 0) or `.edit-btn` (order 0 in the reader's row).
   Naming a chip that is NOT at an end costs nothing: an edge-anchored tooltip
   with room to spare is still inside the column, just not centred on its mark.

   THE GATE IS THE PART THAT LASTS. `node live/tools/_chat_scenario_audit.js`
   hovers every chip in the row with a real CDP mouse move and measures each
   tooltip against the CLIP box at 1008/1440/1920 and 390/360/320 in both
   themes ([TIP_OUT_OF_COL]). A chip added later that takes an end and forgets
   this section cannot pass it. */
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip:is(.similar-btn, .regenerate-btn, .delete-btn) > .action-label {
  left: auto !important;
  right: 0 !important;
  transform: none !important;
}
body:is(.desktop-chat, .mobile-chat) #messages .message .message-actions > .action-chip:is(.msg-issue-btn, .edit-btn) > .action-label {
  left: 0 !important;
  right: auto !important;
  transform: none !important;
}
