'use client' /* ============================================================================ * ⛔️ FROZEN — DO NOT MODIFY (AI agents & contributors, read this first) * ---------------------------------------------------------------------------- * `TitleBlock` is the FINALIZED title/subtitle/back-button/actions chrome used * by `PageLayout`. It is a locked, complete component — treat it as read-only. * * Do NOT: change the markup/CSS, alter the title typography (`text-h2`) or * subtitle (`text-h6`), change the image/title 2-column layout, re-route this * through a `PageHeader`/`PageWithHeader` primitive, or add/rename props. Do * NOT "unify"/"refactor"/"simplify" it or restyle it to match another surface. * * Why this rule exists (the incident it prevents): a refactor once rewrote * this to delegate to a new `PageHeader` (title bumped to `text-h1`, new * subtitle styling) to "unify" page chrome — it silently changed every page * using `PageLayout` and had to be fully reverted. This code IS that reverted, * correct baseline. * * Downstream consumers (OpenFrame pages, `DevSectionPage`, `DocViewer`, and the * multi-platform hub via its local `PageWithHeader`) depend on the EXACT * current output. If a new design needs different chrome, build a SEPARATE new * component — never mutate this one. If an edit here seems unavoidable, STOP * and get explicit human sign-off first. * * SANCTIONED EXCEPTION (2026-06, explicit human sign-off): the OPTIONAL * `titleSize` prop. It defaults to `'h2'` — i.e. the frozen baseline above is * unchanged for EVERY existing caller. A caller may pass `titleSize="h1"` to * opt the title typography up to `text-h1` (used by the unified Help Center * pages). This is additive and default-preserving; do NOT change the default or * touch anything else here. * * SANCTIONED EXCEPTION (2026-06, explicit human sign-off): the OPTIONAL * `titleAdornment` and `loading` props. `titleAdornment` renders a node inline * after the title (e.g. a status `Tag`). `loading` swaps ONLY the title/subtitle * TEXT for line-box-accurate skeleton bars (the surrounding `h1`/`p` and their * typography are untouched, so a loading header is pixel-identical in height to * the loaded one — used by page skeletons that render through `PageLayout`). * Both are additive + default-preserving: omit them and every existing caller is * byte-identical. Do NOT change defaults or touch anything else here. * * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL * `subtitleRow` prop. Defaults to `'when-set'` — the subtitle row keeps * rendering only when `subtitle` is truthy, so every existing caller is * byte-identical. The other two values decide how a page trades a header that * never moves against a header that never shows an empty line; see the prop's * own doc for which to pick. Additive + default-preserving; do NOT change the * default. * * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): the OPTIONAL * `titleWrap` prop. Defaults to `false` — the frozen single-line `truncate` * baseline is unchanged for every existing caller. Content DETAIL pages (whose * h1 is CMS data of arbitrary length — releases, legal docs, FAQ docs, dev * sections) pass `titleWrap` to let the title wrap onto multiple lines instead * of being ellipsis-clipped. Additive + default-preserving; do NOT change the * default or touch anything else here. * * SANCTIONED EXCEPTION (2026-07, explicit human sign-off — ClickUp 86ahd6uy5): * responsive action layout on md+. This one intentionally CHANGES the baseline * for every caller, per the approved design (Figma open-design-system node * 2200-7452): instead of tablet always stacking actions below the title * (`md:flex-col`) and desktop always keeping one row (long titles clipped), * md+ is a single `flex-wrap` row — actions stay inline with a short title on * BOTH tablet and desktop, and wrap to a second row only when the title is * long enough to overflow. The title column is `md:flex-none md:max-w-full` * so its natural width drives the wrap while `truncate` still clamps a title * that alone exceeds the container. The mobile (base) layout is untouched. * This is the new frozen baseline; do NOT re-introduce breakpoint stacking. * * SANCTIONED EXCEPTION (2026-07, explicit human sign-off): overflow tooltips on * the title and subtitle. This one CHANGES the baseline for every caller: the * native `title=""` attribute on both is replaced by the ODS `FloatingTooltip`, * armed only when the text is genuinely clipped (`useIsTruncated`). Both stay * single-line — that is the design, and the tooltip is what makes a clipped * title readable instead of merely pretty. Nothing is lost for assistive tech: * `text-overflow: ellipsis` never hid the text from the accessibility tree, so * the full string was always exposed; the attribute only ever served a mouse. * The wrapper `div` the tooltip needs goes OUTSIDE the `h1`/`p` — never inside: * a `div` inside a `p` is auto-closed by the HTML parser, which would split the * subtitle paragraph and desync hydration. The `titleWrap` branch is untouched * (wrapped text never clips, so it never needs a tooltip). * ========================================================================== */ import React from 'react' import { useIsTruncated } from '../../hooks/ui/use-is-truncated' import { cn } from '../../utils/cn' import type { ActionsMenuGroup } from '../ui/actions-menu' import { EntityImage } from '../ui/entity-image' import { FloatingTooltip } from '../ui/floating-tooltip' import { PageActions, type PageActionButton } from '../ui/page-actions' import { BackButton } from './back-button' /** * Minimum height of the title block's content column, matched to the action * button height: the icon button on mobile (`h-11` → 44px) and the default * button on desktop (`md:h-12` → 48px). Applied to the inner title column (which * has no padding) rather than the root — the root's `pt`/`mb` are box-sizing * border-box and would otherwise absorb the floor. Keeps the header a consistent * height across pages whether or not they render action buttons, so the content * below starts at the same baseline. Exported so other page chrome can reuse it. */ export const TITLE_BLOCK_MIN_HEIGHT = 'min-h-11 md:min-h-12' export interface TitleBlockProps { title?: string subtitle?: string image?: { src: string; alt?: string } backButton?: { label?: string; onClick: () => void } actions?: PageActionButton[] actionsVariant?: 'icon-buttons' | 'primary-buttons' | 'menu-primary' menuActions?: ActionsMenuGroup[] /** Desktop-only slot (e.g. a `TabSelector`) rendered with the actions. Hidden on mobile. */ selector?: React.ReactNode /** * Visual variant. * - `plain` (default): transparent background, no border. * - `card`: card background, border, and padding on mobile only — collapses to plain on md+. */ variant?: 'plain' | 'card' className?: string /** Title typography size. Default `'h2'` (the frozen baseline). Pass `'h1'` to * opt the title up to `text-h1` (the unified Help Center pages). Subtitle stays * `text-h6` either way. */ titleSize?: 'h1' | 'h2' /** Optional node rendered inline, immediately after the title (e.g. a status `Tag`). * Additive + default-preserving: omit it and every existing caller is byte-identical. */ titleAdornment?: React.ReactNode /** When true, the title/subtitle TEXT is replaced by line-box-accurate skeleton bars * (typography + surrounding markup unchanged, so header height is identical to loaded). * Additive + default-preserving: omit it and every existing caller is byte-identical. */ loading?: boolean /** * Render the ACTIONS as placeholders too. Separate from `loading` on purpose: * a page whose action set is already final (it depends on the route, not on * the record) keeps showing its real buttons while the title loads. */ loadingActions?: boolean /** * When the subtitle row occupies the layout. The subtitle is often optional * RECORD data — unknown while loading, sometimes absent afterwards — and there * is no setting that both fills the loading header and never leaves a blank * line, so the caller picks which one matters. * * - `'when-set'` (default) — only when `subtitle` has text. * - `'while-loading'` — also during `loading`, as a skeleton bar. The header * looks complete while it loads, and the row collapses if the record turns * out to have no subtitle (so that case settles one line shorter). * - `'always'` — the row never collapses; an empty subtitle holds its line * with an invisible spacer. The header cannot change height. Use it when the * loaded page ALWAYS has a subtitle — typically a standalone skeleton * component matching a page whose subtitle is guaranteed. */ subtitleRow?: 'when-set' | 'while-loading' | 'always' /** When true, a long title WRAPS onto multiple lines instead of the frozen single-line * ellipsis clamp. For content detail pages whose h1 is CMS data of arbitrary length. * Additive + default-preserving: omit it and every existing caller is byte-identical. */ titleWrap?: boolean } /** * Inline text skeleton — used only in `loading` mode, placed directly inside the real * `h1`/`p`. It is a single `inline-block` bar whose height is intentionally SHORTER than the * typography line-height, and it uses the default baseline alignment. That way the element's * own line-box STRUT (text-h2 → 40px, text-h6 → 20px) sets the height exactly as it would for * real text — the bar fits within the ascent and never inflates the line. So a loading header * is pixel-identical in height to the loaded one. Phrasing-valid (`span` only). */ function TitleTextSkeleton({ widthClass, heightClass }: { widthClass: string; heightClass: string }) { return ( ) } /** * Bar geometry per title size, following the ODS type scale across breakpoints * (`md` = 800px, `lg` = 1280px here — the SAME widths the typography tokens * switch at, so bar and text step together). * * `h2` needs no `lg` step because `text-h2` has none: it is 24/32 on mobile and * 32/40 from tablet up. `text-h1` scales at both breakpoints (40 → 48 → 56), so * its bar does too — reusing the h2 bar there would leave a stamp-sized smudge * inside a 64px line. Heights stay well under the line box's ascent on purpose; * see `TitleTextSkeleton` for why that is what keeps the header height honest. */ const TITLE_SKELETON_SIZE = { h1: { width: 'w-56 md:w-80 lg:w-96', height: 'h-7 md:h-8 lg:h-9' }, h2: { width: 'w-48 md:w-72', height: 'h-4 md:h-6' }, } as const export function TitleBlock({ title, subtitle, image, backButton, actions, actionsVariant = 'icon-buttons', menuActions, selector, variant = 'plain', className, titleSize = 'h2', titleAdornment, loading, loadingActions, subtitleRow = 'when-set', titleWrap = false, }: TitleBlockProps) { const hasSubtitleRow = !!subtitle || subtitleRow === 'always' || (subtitleRow === 'while-loading' && !!loading) const hasActions = actions && actions.length > 0 const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0) const titleClass = titleSize === 'h1' ? 'text-h1' : 'text-h2' // Frozen baseline is the single-line `truncate`; `titleWrap` swaps it for // multi-line wrapping (break-words guards pathological unbroken tokens). const titleOverflowClass = titleWrap ? 'break-words' : 'truncate' const skeletonSize = TITLE_SKELETON_SIZE[titleSize] // Exactly one title element renders per pass, so one ref covers every branch. // The tooltip arms itself only on real clipping — repeating a fully visible // title back to the user is noise, and `titleWrap`/loading never clip at all. const { ref: titleRef, isTruncated: titleTruncated } = useIsTruncated(loading ? null : title) const { ref: subtitleRef, isTruncated: subtitleTruncated } = useIsTruncated( loading ? null : subtitle, ) const titleNode = loading ? : title return (
{/* md+: `flex-none` sizes the column to its content so a long title (capped at `max-w-full`, still truncating) pushes the actions onto the next wrap line; a short title leaves them inline. Base (mobile) keeps flex-1. */}
{backButton && ( )} {(image || hasSubtitleRow || loading) ? (
{image && ( )}
{(loading || title) && ( titleAdornment ? (
{/* `min-w-0` moves onto the tooltip's wrapper: the wrapper is now the flex item, and without it the row refuses to shrink and pushes the adornment out. */}

{titleNode}

{titleAdornment}
) : (

{titleNode}

) )} {hasSubtitleRow && ( /* The NBSP is a pure spacer, reached only in `'always'` mode: an empty `p` collapses to zero height (its only content would be collapsible whitespace), which is exactly the shift that mode exists to prevent. Hidden from AT — it carries no meaning. */

{loading ? : (subtitle || '\u00A0')}

)}
) : ( title && ( titleAdornment ? (

{title}

{titleAdornment}
) : ( /* This branch never had the single-line clamp, so text already wraps; `titleWrap` only adds break-words for pathological unbroken tokens. No class change when the prop is unset — the frozen baseline stays byte-identical. */

{title}

) ) )}
{/* `loadingActions` opens this gate on its own: the case the flag exists for is a page that does not YET know its action set, which is exactly the page that passes `actions={[]}` — gating on `hasActions` alone would render nothing and then pop the real buttons in. */} {(hasActions || hasMenuActions || selector || loadingActions) && (
)}
) } export default TitleBlock