  // `attr` (e.g. ` t="37"`) is part of the OPEN tag, so the marker run must cover it too —
  // the renderer colors by codepoint position, so a short marker run would un-tint the
  // attribute and break alignment. Width is identical to the text tag by construction.
  const open = `<${tag}${attr}>`;
  const close = `</${tag}>`;
  return `${mark.repeat(open.length)}\n${slotCopyBody(body)}\n${mark.repeat(close.length)}`;
}

/** Minify + tab-expand + join lines with ↵ sentinel. Returns null if text already
 *  contains ↵ (caller falls back to non-reflow path; vanishingly rare in practice). */
export function reflow(text: string): string | null {
  if (text.indexOf(NL_SENTINEL) >= 0) return null;
  return minifyForRender(text)
    .split('\n')
    .map(expandTabsInLine)
    .join(NL_SENTINEL);
}

/** Inverse of reflow: ↵ → '\n'. dereflow(reflow(text)) === minifyForRender(text) with tabs expanded. */
export function dereflow(reflowed: string): string {
  return reflowed.split(NL_SENTINEL).join('\n');
}

// --- atlas-miss escaping -----------------------------------------------------
//
// Codepoints absent from the atlas used to render as blank cells (droppedChars
// telemetry). Emoji and other astral glyphs can't be DRAWN at 5×8 px, but they
// can be PRESERVED: substitute a deterministic textual escape (`[U+HEX]`) before
// wrap, so the model reads the codepoint instead of a gap. Applied at the same layer
// as tab expansion (wrapLines + measureContentCols), so wrap math and canvas
// measurement stay consistent. Pure string→string ⇒ renders stay deterministic.
