zurich_kotak/src/components/ClampText.jsx
Yashas 119bea1e76 fix(console): centre modals via a portal, not inside a transformed ancestor
The New Lead form opened off-centre and its backdrop covered only part of the
screen. Cause: the dialog uses position:fixed, but .shell__main animates
transform on entrance, and an element that has animated transform keeps acting
as the containing block in Chrome even after the animation finishes. So "fixed"
resolved against the 1460px max-width, margin-auto content area — offset by the
sidebar — instead of the viewport.

Every scrim dialog shared this latent bug. Each now renders through
createPortal(…, document.body), so the fixed overlay is always relative to the
viewport regardless of any transformed ancestor. New Lead, the conversation and
call threads, the full lead file, the agent card, and the reasoning dialog all
now centre and dim the whole screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:28:37 +05:30

129 lines
4.8 KiB
JavaScript

import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import './ClampText.css'
/**
* Splits a block of AI prose into a finding and its working.
*
* The employees write the conclusion first and the evidence after it —
* "POSP-77341 is active and empanelled for motor." then four sentences of
* registry detail. So the first sentence already IS the summary, and lifting it
* out gives one without inventing text or asking the model for a second field
* it would have to be trusted to keep in step with the first.
*
* Returns null when the split would be useless: no sentence terminator, a first
* sentence so long it is the paragraph again, or one so short it is a fragment
* rather than a finding. Those fall back to plain folding.
*/
function splitLead(value) {
const m = value.match(/^(.{40,220}?[.!?])(\s+)(\S[\s\S]*)$/)
if (!m) return null
return { lead: m[1].trim(), rest: m[3].trim() }
}
/**
* The full text, in a dialog.
*
* The reasoning used to expand INLINE, and in the audit rail — 320px wide, one
* entry per step — a 1,500-character rationale pushed the rest of the lead's
* history off the screen to read one sentence of it. Nobody reads a paragraph
* in a sidebar. It opens over the page instead: the finding stays on the line,
* the working is one click away and one Escape back.
*/
function ProseDialog({ title, lead, body, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
// The page behind must not scroll under the dialog.
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
// Focus goes back to the button that opened this, not to the top of the
// document — otherwise a keyboard reader loses their place in the trail.
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg"
role="dialog"
aria-modal="true"
aria-label={title || 'Full text'}
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>{title || 'In full'}</h3>
<button type="button" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="prose__body">
{/* The finding stays at the top and stays emphasised — a reader who
opened this to check one thing should not have to find it again. */}
{lead ? <p className="prose__lead">{lead}</p> : null}
{body.split(/\n{2,}/).map((para, i) => <p key={i}>{para}</p>)}
</div>
</div>
</div>,
document.body,
)
}
/**
* Prose, reduced to its point.
*
* The AI employees write at length — a recommendation rationale runs past 1,500
* characters — and a screen that prints all of it is a document. Short text is
* shown as it is; anything longer shows its finding and puts the working behind
* one click.
*
* Whether to fold is decided on length, not by measuring the rendered box: a
* ref-and-measure pass would reflow on every resize to answer a question the
* string itself already answers.
*/
export default function ClampText({ text, title, lines = 3, threshold = 150 }) {
const [open, setOpen] = useState(false)
const value = String(text)
if (value.length <= threshold) return <p className="clamp__text clamp__text--short">{value}</p>
const split = splitLead(value)
return (
<div className="clamp">
{split
? <p className="clamp__lead">{split.lead}</p>
: <p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>}
<button type="button" className="clamp__more" onClick={() => setOpen(true)}>
{split ? 'Read the reasoning' : 'Read in full'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<ProseDialog
title={title}
lead={split?.lead}
body={split ? split.rest : value}
onClose={() => setOpen(false)}
/>
) : null}
</div>
)
}