@fasl-work/caos-app-shell 0.19.9 → 0.19.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.19.011] - 2026-11-01
2
+
3
+ ### Fixed
4
+
5
+ - Measure complete scrollable documents through actual readable viewport evidence rather than treating content below the current position as missing. Long executive briefs, source-link titles and technical areas retain the same content floors and hidden/clipped/font checks (issue #170).
6
+ - Retain reached native controls by identity, reject permanent clipping through the full document ancestor chain and reject opaque ancestor covers. Document inspection restores and verifies the original keyboard focus and scrolling state.
7
+
8
+ ### Adoption
9
+
10
+ - Products pin npm `0.19.11` and run the installed gate against the built site. No component API, style override or scientific-data change is required.
11
+
12
+ ## [0.19.010] - 2026-10-31
13
+
14
+ ### Fixed
15
+
16
+ - `SurfacePage document` gives a documentation surface a full-column heading and lede, with one root-owned inset and the existing tabs and Focus controls. OreFlow's Introduction needed document framing without a nested page wrapper or product overrides (issue #167, SHD-088). Products adopt the optional `document` prop and remove their document-width workaround; ordinary surfaces retain their current layout.
17
+ - Executive illustration checks measure transformed text bounds in the outer SVG viewBox and rendered font size through the complete text-to-screen matrix. Translated, scaled, rotated and nested labels are checked in their actual coordinate space; unavailable geometry fails explicitly. The existing bounds tolerance and 9 px font floor are unchanged (issue #167, SHG-076 to SHG-078).
18
+
1
19
  ## [0.19.009] - 2026-10-30
2
20
 
3
21
  ### Fixed
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.19.009
1
+ 0.19.011
package/dist/index.d.ts CHANGED
@@ -888,6 +888,8 @@ interface CardDeckProps {
888
888
  declare function CardDeck({ cards, flip, carouselAbove, ariaLabel, className, lang: forced }: CardDeckProps): react.JSX.Element;
889
889
 
890
890
  interface SurfacePageProps {
891
+ /** Full-column document framing with the same tabs and Focus behavior. The root owns page padding. */
892
+ document?: boolean;
891
893
  /** The route's heading; omit it when the header's active route already names the surface. */
892
894
  title?: BiText;
893
895
  /** One line under the title: what the surface is for. */
@@ -925,7 +927,7 @@ interface SurfacePageProps {
925
927
  * `CaseWorkbench`'s, one piece of code (`useFocusMode`). The control sits at the end of the tab row (a slot beside the tab list) with
926
928
  * its space reserved, so it adds no row and moves nothing; it is hidden while the open view has no stage.
927
929
  */
928
- declare function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView, ariaLabel, instrument, focus, children }: SurfacePageProps): react.JSX.Element;
930
+ declare function SurfacePage({ document: documentMode, title, lede, actions, tabs, value, onChange, deepLinkView, ariaLabel, instrument, focus, children }: SurfacePageProps): react.JSX.Element;
929
931
 
930
932
  /** Writes one query parameter with replaceState, keeping the router's history state (React Router keeps its entry
931
933
  * key and index there; replacing it with null broke the index the back button relies on). */
package/dist/index.js CHANGED
@@ -3052,7 +3052,7 @@ function textOf(node) {
3052
3052
  return "";
3053
3053
  }
3054
3054
  var viewFocus = (tab) => !tab || !tab.focus ? null : tab.focus === true ? {} : tab.focus;
3055
- function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView = true, ariaLabel, instrument, focus, children }) {
3055
+ function SurfacePage({ document: documentMode = false, title, lede, actions, tabs, value, onChange, deepLinkView = true, ariaLabel, instrument, focus, children }) {
3056
3056
  const lang = useShellLang();
3057
3057
  const placeAbove = useContext(PanelPathContext);
3058
3058
  const ids = (tabs ?? []).map((t2) => t2.id);
@@ -3110,11 +3110,11 @@ function SurfacePage({ title, lede, actions, tabs, value, onChange, deepLinkView
3110
3110
  ) });
3111
3111
  }
3112
3112
  const head = title !== void 0 || lede !== void 0 || actions !== void 0 && actions !== null;
3113
- const page = /* @__PURE__ */ jsxs("div", { className: "page-body wide caos-surface", "data-surface": "", "data-view-param": reserve ? viewParam ?? "" : void 0, children: [
3113
+ const page = /* @__PURE__ */ jsxs("div", { className: `page-body wide caos-surface${documentMode ? " caos-surface-doc" : ""}`, "data-doc-page": documentMode ? "" : void 0, "data-surface": "", "data-view-param": reserve ? viewParam ?? "" : void 0, children: [
3114
3114
  head && /* @__PURE__ */ jsxs("div", { className: "caos-surface-head", children: [
3115
3115
  /* @__PURE__ */ jsxs("div", { className: "caos-surface-heading", children: [
3116
3116
  title !== void 0 && /* @__PURE__ */ jsx("h1", { className: "caos-surface-title", children: pick(title, lang) }),
3117
- lede !== void 0 && /* @__PURE__ */ jsx("p", { className: "caos-surface-lede", children: lede })
3117
+ lede !== void 0 && /* @__PURE__ */ jsx("p", { className: `caos-surface-lede${documentMode ? " lede" : ""}`, children: lede })
3118
3118
  ] }),
3119
3119
  actions !== void 0 && actions !== null && /* @__PURE__ */ jsx("div", { className: "caos-surface-actions", children: actions })
3120
3120
  ] }),
package/gate/inpage.mjs CHANGED
@@ -9,6 +9,55 @@ export function installLib() {
9
9
  G.raf = 0;
10
10
  // The smallest rendered size of an illustration label (EXEC_LIMITS.minLabelPx of gate/areas.mjs; test/executive.test.ts keeps them equal).
11
11
  const EXEC_MIN_LABEL_PX = 9;
12
+ /** Local text bounds mapped into the outer illustration's SVG user space, not screen pixels.
13
+ * All four corners matter for rotation/skew. Null is an explicit unmeasurable bound, never a pass.
14
+ * Screen matrices include nested viewports and root CSS transforms; the root inverse cancels them.
15
+ */
16
+ G.svgTextBounds = (root, text) => {
17
+ try {
18
+ const rootMatrix = root.getScreenCTM();
19
+ const textMatrix = text.getScreenCTM();
20
+ const valid = (m) => m && ['a', 'b', 'c', 'd', 'e', 'f'].every((k) => Number.isFinite(m[k]))
21
+ && m.a * m.d - m.b * m.c !== 0;
22
+ if (!valid(rootMatrix) || !valid(textMatrix)) return null;
23
+ const matrix = rootMatrix.inverse().multiply(textMatrix);
24
+ if (!valid(matrix)) return null;
25
+ const box = text.getBBox();
26
+ if (!['x', 'y', 'width', 'height'].every((k) => Number.isFinite(box[k]))) return null;
27
+ const corners = [[box.x, box.y], [box.x + box.width, box.y],
28
+ [box.x, box.y + box.height], [box.x + box.width, box.y + box.height]]
29
+ .map(([x, y]) => ({ x: matrix.a * x + matrix.c * y + matrix.e,
30
+ y: matrix.b * x + matrix.d * y + matrix.f }));
31
+ if (corners.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) return null;
32
+ const x = Math.min(...corners.map((p) => p.x));
33
+ const y = Math.min(...corners.map((p) => p.y));
34
+ return { x, y, width: Math.max(...corners.map((p) => p.x)) - x,
35
+ height: Math.max(...corners.map((p) => p.y)) - y };
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+ /** Conservative rendered font size: the least stretch of the full text-to-screen mapping.
41
+ * Unlike a root bounding-rect ratio, singular values retain group/text/viewport scaling and
42
+ * ignore pure rotation. Normalize first, then use |det| / sigmaMax to avoid subtracting eigenvalues.
43
+ */
44
+ const svgTextFontPx = (text) => {
45
+ try {
46
+ const m = text.getScreenCTM();
47
+ const font = parseFloat(getComputedStyle(text).fontSize);
48
+ if (!m || m.is2D === false || !['a', 'b', 'c', 'd'].every((k) => Number.isFinite(m[k]))
49
+ || !Number.isFinite(font) || font <= 0) return null;
50
+ const norm = Math.max(Math.abs(m.a), Math.abs(m.b), Math.abs(m.c), Math.abs(m.d));
51
+ if (norm === 0) return null;
52
+ const a = m.a / norm, b = m.b / norm, c = m.c / norm, d = m.d / norm;
53
+ const largest = (Math.hypot(a + d, b - c) + Math.hypot(a - d, b + c)) / 2;
54
+ const scale = Math.abs(a * d - b * c) / largest * norm;
55
+ const px = font * scale;
56
+ return Number.isFinite(px) && px > 0 ? px : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
12
61
  G.counting = false;
13
62
  const nativeRaf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null;
14
63
  if (nativeRaf) {
@@ -51,7 +100,7 @@ export function installLib() {
51
100
  return { left: ar.left + o.left, top: ar.top + o.top, right: ar.right - o.right, bottom: ar.bottom - o.bottom };
52
101
  };
53
102
  /**
54
- * The part of `rect` a reader can see: inside `within`, inside every ancestor from `el` up (below `within`) that
103
+ * The part of `rect` a reader can see: inside `within`, inside every ancestor from `el` to the document root that
55
104
  * hides its overflow (`el` itself only when `self`: its overflow cuts its own text, not its own box) and inside every
56
105
  * `clip-path` of `el` and of those ancestors. Null when nothing is left.
57
106
  */
@@ -61,7 +110,7 @@ export function installLib() {
61
110
  let top = Math.max(rect.top, b.top);
62
111
  let right = Math.min(rect.right, b.right);
63
112
  let bottom = Math.min(rect.bottom, b.bottom);
64
- for (let a = el; a && a !== within && a !== document.body; a = a.parentElement) {
113
+ for (let a = el; a; a = a.parentElement) {
65
114
  const s = getComputedStyle(a);
66
115
  const ar = a.getBoundingClientRect();
67
116
  if (a !== el || self) {
@@ -514,6 +563,8 @@ export function installLib() {
514
563
  // ---- 0.11.0: the required areas of the documentation pages (ADR-0017 section 2.1) ------------------------------
515
564
 
516
565
  let docBound = null; // the page being measured: what lies outside it, or is clipped out of it, is not read
566
+ let docEvidence = null; // scoped to one completed real traversal, never shared across active tabs
567
+ const docSeen = (el) => docEvidence ? docEvidence.complete(el) : readable(el, docBound, 4, 4);
517
568
  const wordsOf = (el) => {
518
569
  // the words a reader reads: visible text outside equations; `linkWords` of them inside links
519
570
  let words = 0;
@@ -521,7 +572,7 @@ export function installLib() {
521
572
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
522
573
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
523
574
  const parent = n.parentElement;
524
- if (!parent || parent.closest('.katex, script, style') || !readable(parent, docBound, 4, 4, n)) continue;
575
+ if (!parent || parent.closest('.katex, script, style') || !(docEvidence ? docEvidence.nodeComplete(n) : readable(parent, docBound, 4, 4, n))) continue;
525
576
  const k = (n.nodeValue || '').split(/\s+/).filter(Boolean).length;
526
577
  words += k;
527
578
  if (parent.closest('a')) linkWords += k;
@@ -545,19 +596,19 @@ export function installLib() {
545
596
  }
546
597
  return { label: labels.join(' > '), key: ids.join('/'), head: ids[0] ?? null };
547
598
  };
548
- const textOf = (el) => (el.innerText || '').trim().replace(/\s+/g, ' '); // rendered text: a hidden descendant is not in it
599
+ const textOf = (el) => (docEvidence ? docEvidence.text(el) : el.innerText || '').trim().replace(/\s+/g, ' '); // only witnessed text during traversal
549
600
  const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
550
601
  /** A part of a statement a reader sees: the declared text is in the rendered text, in the readable part (`sel`) when the element has one. */
551
602
  const statPart = (el, want, sel) => {
552
603
  if (!want) return false;
553
604
  const kids = [...el.querySelectorAll(sel)];
554
- return kids.length ? kids.some((k) => readable(k, docBound, 4, 4) && norm(k.innerText).includes(want)) : norm(el.innerText).includes(want);
605
+ return kids.length ? kids.some((k) => docSeen(k) && norm(textOf(k)).includes(want)) : norm(textOf(el)).includes(want);
555
606
  };
556
607
  /** A quantified statement: visible, a digit in its value, and the unit and the source it declares readable beside it (the `Stat` component). */
557
608
  const validStat = (el) => {
558
- if (!readable(el, docBound, 4, 4)) return false;
609
+ if (!docSeen(el)) return false;
559
610
  const value = el.querySelector('.caos-stat-value');
560
- return /\d/.test((value ? value.innerText : el.innerText) || '') && statPart(el, norm(el.getAttribute('data-stat-unit')), '.caos-stat-unit') && statPart(el, norm(el.getAttribute('data-stat-source')), '.caos-stat-source');
611
+ return /\d/.test(textOf(value || el)) && statPart(el, norm(el.getAttribute('data-stat-unit')), '.caos-stat-unit') && statPart(el, norm(el.getAttribute('data-stat-source')), '.caos-stat-source');
561
612
  };
562
613
 
563
614
  /**
@@ -570,7 +621,7 @@ export function installLib() {
570
621
  const page = document.querySelector('[data-doc-page]');
571
622
  if (!page || !visible(page)) return null;
572
623
  docBound = page;
573
- const seen = (el) => readable(el, page, 4, 4);
624
+ const seen = (el) => docSeen(el);
574
625
  const lede = page.querySelector('.lede');
575
626
  // the document column: the page body without its padding; the lede and the executive view take all of it (Felipe, 2026-10-13)
576
627
  const pageStyle = getComputedStyle(page);
@@ -694,13 +745,14 @@ export function installLib() {
694
745
  // A card the carousel's window hides (`hidden` on its item) is out of sight by design: G16 walks the carousel and fails one it
695
746
  // cannot reach. Any other card that is not seen is not shown, as before 0.14.0.
696
747
  const hiddenByDeck = (el) => {
748
+ if (docEvidence) return false; // a real control must first expose every card
697
749
  const li = el.closest('[data-deck-card]');
698
750
  return Boolean(li) && li.hidden && Boolean(li.closest('[data-deck-mode="carousel"]'));
699
751
  };
700
752
  // The back of a flip card is content the reader gets by turning the card: read every field with the cards turned too (the
701
753
  // same `seen` and `innerText` as for any text), and call a field readable when it is seen in either face.
702
754
  const turned = new Map();
703
- withFlipsTurned(() => {
755
+ if (!docEvidence) withFlipsTurned(() => {
704
756
  for (const e of root.querySelectorAll('[data-exec-field], [data-exec-cdef]')) turned.set(e, { text: textOf(e), seen: seen(e) });
705
757
  });
706
758
  const fullText = (e) => textOf(e) || (turned.get(e) ? turned.get(e).text : '');
@@ -730,18 +782,18 @@ export function installLib() {
730
782
  if (!svg) return info;
731
783
  info.literal = literalsOf(svg);
732
784
  const vb = svg.viewBox && svg.viewBox.baseVal;
733
- const w = svg.getBoundingClientRect().width;
734
- const scale = vb && vb.width ? w / vb.width : 1;
735
785
  for (const t of svg.querySelectorAll('text')) {
736
786
  const label = (t.textContent || '').trim();
737
787
  if (!label || !visible(t)) continue;
738
788
  info.labels += 1;
739
789
  if (vb && vb.width) {
740
- const b = t.getBBox();
741
- if (b.x < vb.x - 1 || b.y < vb.y - 1 || b.x + b.width > vb.x + vb.width + 1 || b.y + b.height > vb.y + vb.height + 1) info.outside.push(label.slice(0, 40));
790
+ const b = G.svgTextBounds(svg, t);
791
+ if (!b) info.outside.push(`${label.slice(0, 40)} (bounds unavailable)`);
792
+ else if (b.x < vb.x - 1 || b.y < vb.y - 1 || b.x + b.width > vb.x + vb.width + 1 || b.y + b.height > vb.y + vb.height + 1) info.outside.push(label.slice(0, 40));
742
793
  }
743
- const px = parseFloat(getComputedStyle(t).fontSize) * scale;
744
- if (px < EXEC_MIN_LABEL_PX) info.small.push(`"${label.slice(0, 30)}" at ${px.toFixed(1)}px`);
794
+ const px = svgTextFontPx(t);
795
+ if (px === null) info.small.push(`"${label.slice(0, 30)}" (font scale unavailable)`);
796
+ else if (px < EXEC_MIN_LABEL_PX) info.small.push(`"${label.slice(0, 30)}" at ${px.toFixed(1)}px`);
745
797
  }
746
798
  return info;
747
799
  });
@@ -840,6 +892,300 @@ export function installLib() {
840
892
  };
841
893
  };
842
894
 
895
+ /** SHG-079..082: collect glyph evidence by real scrolling, then compute the existing metric once.
896
+ * No DOM-presence fallback: every word fragment must fit the viewport, original clip bounds,
897
+ * scrollports and hit tests. Text-node identities deduplicate overlapping observations.
898
+ */
899
+ G.readDocument = async ({ maxSamples = 600, timeoutMs = 30000 } = {}) => {
900
+ const page = document.querySelector('[data-doc-page]');
901
+ if (!page || !visible(page)) return null;
902
+ const started = performance.now();
903
+ const saved = new Map([...document.querySelectorAll('*')].map((e) => [e, [e.scrollLeft, e.scrollTop]]));
904
+ const windowPosition = [scrollX, scrollY], focused = document.activeElement;
905
+ const state = [...page.querySelectorAll('[role="tab"][aria-selected="true"]')];
906
+ const nodes = new Map(), witnessed = new Set(), flips = new Map(), decks = new Map();
907
+ const inkContext = document.createElement('canvas').getContext('2d');
908
+ if (!inkContext) throw new Error('document glyph measurement canvas is unavailable');
909
+ let samples = 0;
910
+ const check = () => {
911
+ if (++samples > maxSamples || performance.now() - started > timeoutMs) throw new Error(`document traversal exceeded ${maxSamples} samples or ${timeoutMs}ms; active document remains unverified`);
912
+ if (!page.isConnected || state.some((e) => !e.isConnected || e.getAttribute('aria-selected') !== 'true')) throw new Error('document traversal changed the active page/tab');
913
+ };
914
+ const frame = () => new Promise((resolve) => nativeRaf ? nativeRaf(() => nativeRaf(resolve)) : setTimeout(resolve, 32));
915
+ const textNodes = (el) => textNodesOf(el).filter((n) => !n.parentElement.closest('.katex, svg, .sr-only, [data-deck-position]'));
916
+ const available = (el) => visible(el) && !el.closest('[hidden], [inert], [aria-hidden="true"]');
917
+ const nodeComplete = (n) => {
918
+ const row = nodes.get(n);
919
+ return Boolean(row && row.value === n.nodeValue && row.parts.length && row.parts.every((p) => p.count > 0 && p.seen.size === p.count));
920
+ };
921
+ const evidence = {
922
+ nodeComplete,
923
+ text: (el) => el ? textNodes(el).filter(nodeComplete).map((n) => n.nodeValue).join(' ') : '',
924
+ complete: (el) => {
925
+ if (!el) return false;
926
+ if (el.matches('[data-control]') && !witnessed.has(el)) return false;
927
+ const required = textNodes(el).filter((n) => nodes.has(n) || available(n.parentElement));
928
+ return required.length ? required.every(nodeComplete) : witnessed.has(el);
929
+ },
930
+ };
931
+ const pseudoPaint = new Map();
932
+ // This box model supports only ordinary, unmapped coordinates. Apply the same identity
933
+ // contract to the pseudo and its ancestors: separate CSS transform channels do not appear
934
+ // in computed transform, and motion/filter/zoom can also move or expand actual paint.
935
+ const knownPaintCoordinates = (s) =>
936
+ ['transform', 'translate', 'rotate', 'scale', 'offsetPath', 'filter', 'backdropFilter', 'perspective']
937
+ .every((key) => !s[key] || s[key] === 'none')
938
+ && (!s.zoom || Number(s.zoom) === 1)
939
+ && !/layout|paint|strict|content/.test(s.contain)
940
+ && !/transform|translate|rotate|scale|offset|filter|perspective|contain|content-visibility|zoom/.test(s.willChange)
941
+ && s.contentVisibility !== 'auto'
942
+ && (!s.transformStyle || s.transformStyle === 'flat')
943
+ && (!s.positionAnchor || ['auto', 'normal'].includes(s.positionAnchor))
944
+ && (!s.positionArea || s.positionArea === 'none')
945
+ && (!s.positionTryFallbacks || s.positionTryFallbacks === 'none');
946
+ const pseudoCover = (parent, x, y) => {
947
+ for (let a = parent; a; a = a.parentElement) {
948
+ let boxes = pseudoPaint.get(a);
949
+ if (!boxes) {
950
+ boxes = [];
951
+ for (const pseudo of ['::before', '::after']) {
952
+ const s = getComputedStyle(a, pseudo);
953
+ if (s.content === 'none' || s.content === 'normal' || s.display === 'none' || s.visibility !== 'visible' || Number(s.opacity) === 0 || Number(s.zIndex) < 0) continue;
954
+ const background = parseColor(s.backgroundColor);
955
+ const outline = !['none', 'hidden'].includes(s.outlineStyle) && parseFloat(s.outlineWidth) > 0;
956
+ const painted = (background && background.a > 0) || s.backgroundImage !== 'none' || s.boxShadow !== 'none'
957
+ || outline || s.borderImageSource !== 'none'
958
+ || ['Top', 'Right', 'Bottom', 'Left'].some((side) => parseFloat(s[`border${side}Width`]) > 0 && (parseColor(s[`border${side}Color`])?.a ?? 0) > 0);
959
+ if (!painted) continue;
960
+ // Unmapped or expanding paint is unknown, even when its nominal layout box is remote.
961
+ if (!['absolute', 'fixed'].includes(s.position) || !knownPaintCoordinates(s)
962
+ || s.boxShadow !== 'none' || outline || s.borderImageSource !== 'none') { boxes.push(null); continue; }
963
+ // Local CSS dimensions cannot be combined with a viewport-space origin when any
964
+ // ancestor changes that mapping or establishes a non-viewport fixed containing block.
965
+ // We do not have a complete paint transform here: unknown positioned paint fails closed.
966
+ let ambiguous = false;
967
+ for (let ancestor = a; ancestor; ancestor = ancestor.parentElement) {
968
+ const cs = getComputedStyle(ancestor);
969
+ if (!knownPaintCoordinates(cs)) { ambiguous = true; break; }
970
+ }
971
+ if (ambiguous) { boxes.push(null); continue; }
972
+ let cb = s.position === 'fixed' ? null : a;
973
+ while (cb && getComputedStyle(cb).position === 'static' && getComputedStyle(cb).transform === 'none') cb = cb.parentElement;
974
+ if (cb && (cb.scrollLeft || cb.scrollTop)) { boxes.push(null); continue; }
975
+ const r = cb?.getBoundingClientRect();
976
+ const base = r ? { left:r.left + cb.clientLeft, top:r.top + cb.clientTop, width:cb.clientWidth, height:cb.clientHeight }
977
+ : { left:s.position === 'fixed' ? 0 : -scrollX, top:s.position === 'fixed' ? 0 : -scrollY, width:innerWidth, height:innerHeight };
978
+ let width = parseFloat(s.width), height = parseFloat(s.height);
979
+ if (s.boxSizing !== 'border-box') {
980
+ width += parseFloat(s.paddingLeft) + parseFloat(s.paddingRight) + parseFloat(s.borderLeftWidth) + parseFloat(s.borderRightWidth);
981
+ height += parseFloat(s.paddingTop) + parseFloat(s.paddingBottom) + parseFloat(s.borderTopWidth) + parseFloat(s.borderBottomWidth);
982
+ }
983
+ const left = parseFloat(s.left), right = parseFloat(s.right), top = parseFloat(s.top), bottom = parseFloat(s.bottom);
984
+ const px = Number.isFinite(left) ? base.left + left + parseFloat(s.marginLeft) : base.left + base.width - right - width - parseFloat(s.marginRight);
985
+ const py = Number.isFinite(top) ? base.top + top + parseFloat(s.marginTop) : base.top + base.height - bottom - height - parseFloat(s.marginBottom);
986
+ if (![px,py,width,height].every(Number.isFinite)) { boxes.push(null); continue; }
987
+ boxes.push({ left:px, right:px+width, top:py, bottom:py+height });
988
+ }
989
+ pseudoPaint.set(a, boxes);
990
+ }
991
+ if (boxes.some((b) => !b || x >= b.left && x <= b.right && y >= b.top && y <= b.bottom)) return true;
992
+ }
993
+ return false;
994
+ };
995
+ const clearFragment = (rect, parent, control = false) => {
996
+ const b = clipRect(rect, parent, page, true);
997
+ if (!b || rect.width < 0.1 || rect.height < 0.1) return false;
998
+ let left = Math.max(b.left, 0), top = Math.max(b.top, 0);
999
+ let right = Math.min(b.right, innerWidth), bottom = Math.min(b.bottom, innerHeight);
1000
+ for (let a = parent; a; a = a.parentElement) {
1001
+ const s = getComputedStyle(a), r = a.getBoundingClientRect();
1002
+ if (/auto|scroll/.test(s.overflowX)) { left = Math.max(left, r.left + a.clientLeft); right = Math.min(right, r.left + a.clientLeft + a.clientWidth); }
1003
+ if (/auto|scroll/.test(s.overflowY)) { top = Math.max(top, r.top + a.clientTop); bottom = Math.min(bottom, r.top + a.clientTop + a.clientHeight); }
1004
+ }
1005
+ if (left > rect.left + 0.5 || top > rect.top + 0.5 || right < rect.right - 0.5 || bottom < rect.bottom - 0.5) return false;
1006
+ // Hit testing quantizes fractional inline boxes to CSS pixels. Keep samples within fully
1007
+ // contained pixel cells: a narrow visible separator must not probe the adjacent inline box.
1008
+ // Full glyph extent was checked above; this does not admit clipped or covered fragments.
1009
+ const sampleAt = (start, end, fraction) => {
1010
+ const first = Math.ceil(start), last = Math.floor(end) - 1;
1011
+ return first <= last ? Math.max(first, Math.min(last, start + (end - start) * fraction)) : (start + end) / 2;
1012
+ };
1013
+ for (const q of [0.15, 0.5, 0.85]) for (const v of [0.15, 0.5, 0.85]) {
1014
+ const x = sampleAt(rect.left, rect.right, q), y = sampleAt(rect.top, rect.bottom, v);
1015
+ const hit = document.elementFromPoint(x, y);
1016
+ const transparentFallthrough = getComputedStyle(parent).pointerEvents === 'none' && hit?.contains(parent);
1017
+ if (!hit || !(hit === parent || (control ? parent.contains(hit) : transparentFallthrough)) || pseudoCover(parent,x,y)) return false;
1018
+ const link = parent.closest('a[href]');
1019
+ if (link && !(hit === link || link.contains(hit))) return false;
1020
+ }
1021
+ return true;
1022
+ };
1023
+ const sample = () => {
1024
+ check();
1025
+ pseudoPaint.clear();
1026
+ for (const n of textNodes(page)) {
1027
+ const parent = n.parentElement;
1028
+ if (!available(parent)) continue;
1029
+ let row = nodes.get(n);
1030
+ if (row && row.value !== n.nodeValue) throw new Error(`document text changed during traversal (${parent.tagName}: ${row.value.slice(0, 40)}); retry a settled state`);
1031
+ if (!row) {
1032
+ row = { value: n.nodeValue, parts: [...n.nodeValue.matchAll(/\S+/g)].map((m) => ({ start: m.index, end: m.index + m[0].length, count: 0, seen: new Set() })) };
1033
+ nodes.set(n, row);
1034
+ }
1035
+ if (fontPx(parent) < FONT_FLOOR) continue;
1036
+ const style = getComputedStyle(parent);
1037
+ inkContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
1038
+ for (const part of row.parts) {
1039
+ if (part.count > 0 && part.seen.size === part.count) continue;
1040
+ const r = document.createRange(); r.setStart(n, part.start); r.setEnd(n, part.end);
1041
+ const boxes = [...r.getClientRects()].filter((b) => b.width > 0 && b.height > 0);
1042
+ if (part.count && part.count !== boxes.length) throw new Error('document layout changed during traversal; retry a settled state');
1043
+ part.count = boxes.length;
1044
+ const ink = inkContext.measureText(n.nodeValue.slice(part.start, part.end));
1045
+ const fontHeight = ink.fontBoundingBoxAscent + ink.fontBoundingBoxDescent;
1046
+ if (!Number.isFinite(fontHeight) || fontHeight <= 0) throw new Error('document glyph font metrics are unavailable');
1047
+ boxes.forEach((b, i) => {
1048
+ // Range boxes include font leading, which may extend above a tight heading's line box.
1049
+ // Judge the actual ink ascent/descent, not invisible leading; retain all clip/hit checks.
1050
+ const scale = b.height / fontHeight;
1051
+ if (fontPx(parent) * scale < FONT_FLOOR) return;
1052
+ const baseline = b.bottom - ink.fontBoundingBoxDescent * scale;
1053
+ const top = baseline - ink.actualBoundingBoxAscent * scale;
1054
+ const bottom = baseline + ink.actualBoundingBoxDescent * scale;
1055
+ if (clearFragment({left:b.left,right:b.right,top,bottom,width:b.width,height:bottom-top}, parent)) part.seen.add(i);
1056
+ });
1057
+ }
1058
+ }
1059
+ for (const e of page.querySelectorAll('figure, svg, canvas, img, .equation')) {
1060
+ const b = e.getBoundingClientRect();
1061
+ if (available(e) && b.bottom > 0 && b.top < innerHeight && readable(e, page, 4, 4)) witnessed.add(e);
1062
+ }
1063
+ // Native inputs have no text nodes. Witness the actual control, never its metadata alone.
1064
+ for (const e of page.querySelectorAll('[data-control]')) {
1065
+ const target = e.matches('input, select, button, textarea, [role="slider"]') ? e
1066
+ : e.querySelector('input, select, button, textarea, [role="slider"]') ?? e;
1067
+ if (available(e) && available(target) && readable(target, page, 4, 4)
1068
+ && clearFragment(target.getBoundingClientRect(), target, true)) witnessed.add(e);
1069
+ }
1070
+ };
1071
+ const scrollable = (e) => {
1072
+ const s = getComputedStyle(e);
1073
+ return available(e) && ((/^(auto|scroll)$/.test(s.overflowY) && e.scrollHeight > e.clientHeight + 1) || (/^(auto|scroll)$/.test(s.overflowX) && e.scrollWidth > e.clientWidth + 1));
1074
+ };
1075
+ const bringSafely = (e) => {
1076
+ const x = scrollX, y = scrollY;
1077
+ bring(e); // only user-scrollable element ancestors; protect an unscrollable document too
1078
+ if (/hidden|clip/.test(getComputedStyle(document.documentElement).overflowY) || /hidden|clip/.test(getComputedStyle(document.body).overflowY)) winScroll(x, y);
1079
+ };
1080
+ const positions = (max, size) => {
1081
+ const out = [0], step = Math.max(16, Math.floor(size * 0.65));
1082
+ for (let p = step; p < max; p += step) {
1083
+ if (out.length > maxSamples) throw new Error('document scroll extent exceeds traversal budget');
1084
+ out.push(p);
1085
+ }
1086
+ if (max > 0) out.push(max);
1087
+ return out;
1088
+ };
1089
+ const sweep = async () => {
1090
+ sample();
1091
+ const containers = [...ancestorsUntil(page, document.body), page, ...page.querySelectorAll('*')].filter(scrollable);
1092
+ const de = document.scrollingElement;
1093
+ if (de && de.scrollHeight > innerHeight + 1 && !/hidden|clip/.test(getComputedStyle(de).overflowY) && !/hidden|clip/.test(getComputedStyle(document.body).overflowY)) containers.unshift(de);
1094
+ const unique = [...new Set(containers)];
1095
+ const owner = (e) => unique.find((p) => p !== e && p.contains(e) && !unique.some((q) => q !== p && q !== e && p.contains(q) && q.contains(e)));
1096
+ const walk = async (e) => {
1097
+ const s = getComputedStyle(e);
1098
+ const maxX = /auto|scroll/.test(s.overflowX) ? e.scrollWidth - e.clientWidth : 0;
1099
+ const maxY = e === de || /auto|scroll/.test(s.overflowY) ? e.scrollHeight - e.clientHeight : 0;
1100
+ for (const y of positions(maxY, Math.min(e.clientHeight, innerHeight))) for (const x of positions(maxX, Math.min(e.clientWidth, innerWidth))) {
1101
+ // A settled card/animated illustration may reduce incidental overflow between frames.
1102
+ // Clamp to the real extent; coverage still requires every fragment, never a partial word.
1103
+ const wantedX = Math.min(x, e.scrollWidth - e.clientWidth), wantedY = Math.min(y, e.scrollHeight - e.clientHeight);
1104
+ setScroll(e, wantedX, wantedY); await frame();
1105
+ if (Math.abs(e.scrollLeft - Math.min(wantedX, e.scrollWidth-e.clientWidth)) > 1 || Math.abs(e.scrollTop - Math.min(wantedY, e.scrollHeight-e.clientHeight)) > 1) throw new Error(`document scroller ${e.className} could not reach its requested position`);
1106
+ sample();
1107
+ for (const child of unique.filter((c) => owner(c) === e)) {
1108
+ const r = child.getBoundingClientRect();
1109
+ if (r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth) await walk(child);
1110
+ }
1111
+ }
1112
+ };
1113
+ for (const e of unique.filter((e) => !owner(e))) { bringSafely(e); await walk(e); }
1114
+ };
1115
+ const click = async (button) => {
1116
+ if (!button || !available(button) || button.disabled) throw new Error('document card control is unavailable');
1117
+ bringSafely(button); await frame();
1118
+ const r = button.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
1119
+ if (!hit || !(hit === button || button.contains(hit))) throw new Error('document card control is covered or clipped');
1120
+ button.click(); await frame();
1121
+ // Await the real transition, without changing attributes/styles or exposing a hidden face.
1122
+ const running = document.getAnimations().filter((a) => a.playState === 'running' && a.effect?.target?.closest?.('[data-flip-card], [data-card-deck]'));
1123
+ let timer;
1124
+ try {
1125
+ await Promise.race([Promise.all(running.map((a) => a.finished)), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('document card transition did not settle')), 1500); })]);
1126
+ } finally { clearTimeout(timer); }
1127
+ await frame();
1128
+ };
1129
+ const signature = (deck) => [...deck.querySelectorAll('[data-deck-card]')].map((c, i) => c.hidden ? '' : i).join(',');
1130
+ const visitFlips = async () => {
1131
+ for (const card of page.querySelectorAll('[data-flip-card]')) {
1132
+ if (!available(card)) continue;
1133
+ if (!flips.has(card)) flips.set(card, card.getAttribute('data-flip-open'));
1134
+ const open = card.getAttribute('data-flip-open') === 'true';
1135
+ await click(card.querySelector(`[data-flip-toggle="${open ? 'back' : 'front'}"]`));
1136
+ if ((card.getAttribute('data-flip-open') === 'true') === open) throw new Error('document flip control did not turn the card');
1137
+ await sweep();
1138
+ await click(card.querySelector(`[data-flip-toggle="${open ? 'front' : 'back'}"]`));
1139
+ }
1140
+ };
1141
+ try {
1142
+ await sweep(); await visitFlips();
1143
+ for (const deck of page.querySelectorAll('[data-deck-mode="carousel"]')) {
1144
+ decks.set(deck, signature(deck));
1145
+ const count = deck.querySelectorAll('[data-deck-card]').length;
1146
+ for (const direction of ['prev', 'next']) {
1147
+ for (let i = 0; ; i++) {
1148
+ const button = deck.querySelector(`[data-deck-${direction}]`);
1149
+ if (!button) throw new Error('document carousel has no traversal control');
1150
+ if (button.getAttribute('aria-disabled') === 'true') break;
1151
+ if (i > count) throw new Error('document carousel traversal did not terminate');
1152
+ const before = signature(deck); await click(button);
1153
+ if (signature(deck) === before) throw new Error('document carousel control did not expose another card');
1154
+ await sweep(); await visitFlips();
1155
+ }
1156
+ }
1157
+ }
1158
+ docEvidence = evidence;
1159
+ return G.docPage();
1160
+ } finally {
1161
+ docEvidence = null;
1162
+ try {
1163
+ for (const [card, open] of flips) if (card.isConnected && card.getAttribute('data-flip-open') !== open) await click(card.querySelector(`[data-flip-toggle="${open === 'true' ? 'front' : 'back'}"]`));
1164
+ for (const [deck, initial] of decks) {
1165
+ const count = deck.querySelectorAll('[data-deck-card]').length;
1166
+ for (let i = 0; signature(deck) !== initial; i++) {
1167
+ if (i > Math.min(count * 2, 32)) throw new Error('document carousel position could not be restored');
1168
+ const first = [...deck.querySelectorAll('[data-deck-card]')].findIndex((c) => !c.hidden);
1169
+ const target = initial.split(',').findIndex((v) => v !== '');
1170
+ await click(deck.querySelector(`[data-deck-${first > target ? 'prev' : 'next'}]`));
1171
+ }
1172
+ }
1173
+ } finally {
1174
+ for (const [e, [x, y]] of saved) if (e.isConnected) setScroll(e, x, y);
1175
+ winScroll(...windowPosition);
1176
+ if (focused === document.body) {
1177
+ if (document.activeElement !== focused) document.activeElement?.blur?.();
1178
+ } else if (focused?.isConnected) focused.focus({ preventScroll: true });
1179
+ await frame();
1180
+ if (focused && document.activeElement !== focused) throw new Error('document focus could not be restored');
1181
+ if (state.some((e) => !e.isConnected || e.getAttribute('aria-selected') !== 'true')) throw new Error('document tab state could not be restored');
1182
+ for (const [card, open] of flips) if (!card.isConnected || card.getAttribute('data-flip-open') !== open) throw new Error('document flip state could not be restored');
1183
+ for (const [deck, initial] of decks) if (!deck.isConnected || signature(deck) !== initial) throw new Error('document carousel state could not be restored');
1184
+ for (const [e, [x, y]] of saved) if (e.isConnected && (Math.abs(e.scrollLeft - x) > 1 || Math.abs(e.scrollTop - y) > 1)) throw new Error('document scroll position could not be restored');
1185
+ }
1186
+ }
1187
+ };
1188
+
843
1189
  /** A tall view must move when scrolled (S1, G5). Restores the positions it changed. */
844
1190
  G.scrolls = () => {
845
1191
  const out = [];
package/gate/run.mjs CHANGED
@@ -480,8 +480,12 @@ export async function runGate(o) {
480
480
  if (f.refsDeclaredNoBlock) fail('ADR-0017.4', where, `${f.refsDeclaredNoBlock} section(s) cite references the page never lists (no references block in their page or tab)`);
481
481
  if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
482
482
  if (s.walkState?.doc) {
483
- const d = await page.evaluate(() => window.__caosGate.docPage());
484
- if (d) s.walkState.doc.push(d);
483
+ try {
484
+ const d = await page.evaluate(() => window.__caosGate.readDocument());
485
+ if (d) s.walkState.doc.push(d);
486
+ } catch (e) {
487
+ fail('ADR-0017.2', where, `document readability traversal could not complete: ${e.message.split('\n')[0]}`);
488
+ }
485
489
  }
486
490
  for (const r of f.repeatedTicks ?? []) {
487
491
  // named x ticks (UPlotChart declares the repeated names): a name used twice, wherever the two ticks sit on the axis
package/package.json CHANGED
@@ -1,100 +1,100 @@
1
- {
2
- "name": "@fasl-work/caos-app-shell",
3
- "version": "0.19.9",
4
- "description": "Shared web-app shell, workbench primitives, content primitives, design system and measured gate for the CAOS / Faena public apps (ADR-0016, ADR-0017, ADR-0071).",
5
- "license": "MIT",
6
- "author": "Felipe Santibáñez-Leal",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/fsantibanezleal/CAOS_APP_SHELL.git"
10
- },
11
- "type": "module",
12
- "publishConfig": {
13
- "access": "public"
14
- },
15
- "sideEffects": [
16
- "*.css"
17
- ],
18
- "exports": {
19
- ".": {
20
- "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
22
- },
23
- "./chart": {
24
- "types": "./dist/chart.d.ts",
25
- "import": "./dist/chart.js"
26
- },
27
- "./keys": {
28
- "types": "./dist/keys.d.ts",
29
- "import": "./dist/keys.js"
30
- },
31
- "./styles.css": "./styles.css",
32
- "./chart.css": "./chart.css",
33
- "./reserved-classes.json": "./reserved-classes.json",
34
- "./executive.schema.json": "./src/content/executive.schema.json",
35
- "./serve": "./gate/serve.mjs",
36
- "./gate": "./gate/run.mjs",
37
- "./package.json": "./package.json"
38
- },
39
- "files": [
40
- "dist",
41
- "styles.css",
42
- "chart.css",
43
- "bin",
44
- "gate",
45
- "reserved-classes.json",
46
- "src/content/executive.schema.json",
47
- "VERSION",
48
- "CHANGELOG.md"
49
- ],
50
- "scripts": {
51
- "build": "tsup",
52
- "test": "node --import tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
53
- "test:browser": "node --import tsx --test \"e2e/**/*.test.ts\"",
54
- "prepare": "npm run build",
55
- "prepublishOnly": "npm run build",
56
- "prebuild": "node scripts/reserved-classes.mjs",
57
- "test:gate": "node --test test/gate-selftest.mjs",
58
- "check:sdd": "python scripts/check_sdd.py ."
59
- },
60
- "peerDependencies": {
61
- "katex": "^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0",
62
- "lucide-react": ">=0.400.0",
63
- "react": "^18 || ^19",
64
- "react-dom": "^18 || ^19",
65
- "react-router": "^6 || ^7 || ^8",
66
- "zustand": "^4 || ^5",
67
- "playwright": ">=1.40",
68
- "uplot": "^1.6.30"
69
- },
70
- "devDependencies": {
71
- "@playwright/test": "1.63.0",
72
- "@types/react": "^19.0.0",
73
- "@types/react-dom": "^19.0.0",
74
- "esbuild": "0.28.1",
75
- "katex": "0.19.0",
76
- "lucide-react": "1.43.0",
77
- "react": "^19.0.0",
78
- "react-dom": "^19.0.0",
79
- "react-router": "^8.3.0",
80
- "tsup": "^8.3.5",
81
- "tsx": "^4.23.0",
82
- "typescript": "^5.7.3",
83
- "zustand": "^5.0.2",
84
- "uplot": "1.6.32"
85
- },
86
- "overrides": {
87
- "esbuild": "0.28.1"
88
- },
89
- "bin": {
90
- "caos-shell-gate": "bin/caos-shell-gate.mjs"
91
- },
92
- "peerDependenciesMeta": {
93
- "playwright": {
94
- "optional": true
95
- },
96
- "uplot": {
97
- "optional": true
98
- }
99
- }
100
- }
1
+ {
2
+ "name": "@fasl-work/caos-app-shell",
3
+ "version": "0.19.11",
4
+ "description": "Shared web-app shell, workbench primitives, content primitives, design system and measured gate for the CAOS / Faena public apps (ADR-0016, ADR-0017, ADR-0071).",
5
+ "license": "MIT",
6
+ "author": "Felipe Santibáñez-Leal",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/fsantibanezleal/CAOS_APP_SHELL.git"
10
+ },
11
+ "type": "module",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "sideEffects": [
16
+ "*.css"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./chart": {
24
+ "types": "./dist/chart.d.ts",
25
+ "import": "./dist/chart.js"
26
+ },
27
+ "./keys": {
28
+ "types": "./dist/keys.d.ts",
29
+ "import": "./dist/keys.js"
30
+ },
31
+ "./styles.css": "./styles.css",
32
+ "./chart.css": "./chart.css",
33
+ "./reserved-classes.json": "./reserved-classes.json",
34
+ "./executive.schema.json": "./src/content/executive.schema.json",
35
+ "./serve": "./gate/serve.mjs",
36
+ "./gate": "./gate/run.mjs",
37
+ "./package.json": "./package.json"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "styles.css",
42
+ "chart.css",
43
+ "bin",
44
+ "gate",
45
+ "reserved-classes.json",
46
+ "src/content/executive.schema.json",
47
+ "VERSION",
48
+ "CHANGELOG.md"
49
+ ],
50
+ "scripts": {
51
+ "build": "tsup",
52
+ "test": "node --import tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
53
+ "test:browser": "node --import tsx --test \"e2e/**/*.test.ts\"",
54
+ "prepare": "npm run build",
55
+ "prepublishOnly": "npm run build",
56
+ "prebuild": "node scripts/reserved-classes.mjs",
57
+ "test:gate": "node --test test/gate-selftest.mjs",
58
+ "check:sdd": "python scripts/check_sdd.py ."
59
+ },
60
+ "peerDependencies": {
61
+ "katex": "^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0",
62
+ "lucide-react": ">=0.400.0",
63
+ "react": "^18 || ^19",
64
+ "react-dom": "^18 || ^19",
65
+ "react-router": "^6 || ^7 || ^8",
66
+ "zustand": "^4 || ^5",
67
+ "playwright": ">=1.40",
68
+ "uplot": "^1.6.30"
69
+ },
70
+ "devDependencies": {
71
+ "@playwright/test": "1.63.0",
72
+ "@types/react": "^19.0.0",
73
+ "@types/react-dom": "^19.0.0",
74
+ "esbuild": "0.28.1",
75
+ "katex": "0.19.0",
76
+ "lucide-react": "1.43.0",
77
+ "react": "^19.0.0",
78
+ "react-dom": "^19.0.0",
79
+ "react-router": "^8.3.0",
80
+ "tsup": "^8.3.5",
81
+ "tsx": "^4.23.0",
82
+ "typescript": "^5.7.3",
83
+ "zustand": "^5.0.2",
84
+ "uplot": "1.6.32"
85
+ },
86
+ "overrides": {
87
+ "esbuild": "0.28.1"
88
+ },
89
+ "bin": {
90
+ "caos-shell-gate": "bin/caos-shell-gate.mjs"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "playwright": {
94
+ "optional": true
95
+ },
96
+ "uplot": {
97
+ "optional": true
98
+ }
99
+ }
100
+ }
@@ -167,6 +167,7 @@
167
167
  "caos-surface",
168
168
  "caos-surface-actions",
169
169
  "caos-surface-body",
170
+ "caos-surface-doc",
170
171
  "caos-surface-focus",
171
172
  "caos-surface-head",
172
173
  "caos-surface-heading",
@@ -494,6 +495,7 @@
494
495
  "caos-surface",
495
496
  "caos-surface-actions",
496
497
  "caos-surface-body",
498
+ "caos-surface-doc",
497
499
  "caos-surface-focus",
498
500
  "caos-surface-head",
499
501
  "caos-surface-heading",
package/styles.css CHANGED
@@ -252,6 +252,9 @@ code, kbd { font-family: var(--font-mono); }
252
252
  .caos-surface-title { margin: 0; font-size: var(--text-xl); }
253
253
  .caos-surface-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
254
254
  .caos-surface-lede { margin: 0; color: var(--color-fg-subtle); font-size: var(--text-md); max-width: var(--measure-text); }
255
+ /* Document surfaces own their one page inset; actions cannot shrink the title/lede column. */
256
+ .caos-surface-doc > .caos-surface-head > .caos-surface-heading { flex: 1 1 100%; width: 100%; }
257
+ .caos-surface-doc .caos-surface-lede { max-width: none; font-size: var(--text-md); }
255
258
  .caos-surface-body { flex: 1 1 auto; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
256
259
  .caos-surface-body > .tabs { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
257
260
  .caos-surface-body > .tabs > .tabpanel:not([hidden]) { flex: 1 1 auto; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 0.6rem; }