@fasl-work/caos-app-shell 0.23.1 → 0.24.0

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,34 @@
1
+ ## [0.24.000] - 2026-11-08
2
+
3
+ Four defects that an ADR-0082 review of CAOS_ChancaDEM on 0.23.2 found (CAOS_MANAGE `wip/chancadem/viz-review-2026-11-07.md`, findings M11, M1, M3, m4).
4
+
5
+ ### Fixed
6
+
7
+ - A scene callout no longer shows its state by colour alone (known defect 58, SHW-059, ADR-0082 rule 8). Every `SceneCallouts` card carries a state label, a word in the page language with a glyph of its own shape (en normal, warning, alarm, selected, inactive; es normal, aviso, alarma, seleccionado, inactivo), marked `data-callout-state-label`, on a row of its own; the border colour stays. A callout without a recognised state gets no label (G21 already fails it). The eyebrow wraps instead of ending in an ellipsis: on the shell's own fixture the Spanish eyebrows were cut by 19 to 58 px at 390 and 1280 px.
8
+ - G21 measures the callouts at every size, theme and language of the gate matrix (known defect 59, SHG-090). Its callout checks (value and unit, state and state label, provenance, the 8 px clearance of the own anchor, no overlap, inside the stage, one leader per callout, no horizontal overflow) ran at the primary size, first theme and first language only, so ChancaDEM's cards overlapped by 2 to 6 px at 390x844 and in Spanish while the gate passed. The interaction, motion and performance checks stay at the primary size. The report lists the measured contexts (`storyContexts`). A new G21 check fails a callout whose state label is not visible (SHG-091).
9
+ - Header callouts are laid out on their measured heights, in the order of their anchors (known defect 60, SHW-060, SHW-061). `layoutCalloutHeader` placed the cards in declaration order on nominal heights (68 px, 54 px compact), so a card that grew when its source wrapped covered the next row and a card anchored at the right could sit at the left with its leader across the subject. The layer now measures its cards (ResizeObserver) and lays them out again when a height changes; `layoutCalloutHeader(items, width, height, compact, heights?)` reads the cards in anchor x order (stable for equal x), sizes each row by its tallest card, takes the slot order with the fewest crossing leaders (the anchor order winning every tie) and narrows a compact card to the cell, never over its neighbour. `layoutSceneCallouts` takes the same optional `heights`.
10
+ - At a phone width every verb of the story stepper is whole (SHW-062, review m4). A step is at least as wide as its number and verb and never wider than the row (a longer verb wraps inside its step, so a platform with a wider system font shows it whole too: the first candidate's main CI on Linux overflowed a 320 px row), the row scrolls on x, and `StoryFrame` brings the active step inside the row whenever it changes (keyboard, autoplay, deep link).
11
+
12
+ ### Added
13
+
14
+ - `SceneCallouts` `onLayout?: (info: { bandHeight: number }) => void` and the CSS custom property `--caos-callout-band` (px) on the header layer: the distance from the top of the layer to the bottom of the lowest card after layout.
15
+
16
+ ### Adoption
17
+
18
+ - Known defects 58 and 59: no product change beyond the product's own gate run on this release; a product that drew its own state word or glyph in the eyebrow to work around defect 58 removes it. A product whose cards overlapped only on a phone or in Spanish now fails G21 there and fixes its layout.
19
+ - Known defect 60: a product that reserves a header band reads `--caos-callout-band` (CSS) or `onLayout` (JavaScript) instead of a band computed from the nominal 184x68 or 164x54 cards, and drops any local reordering of its callouts by anchor x.
20
+ - The phone stepper: no product change; a product that shortened its step verbs to fit the 90 px columns may restore them.
21
+
22
+ ## [0.23.002] - 2026-11-07
23
+
24
+ ### Fixed
25
+
26
+ - A story panel taller than the layout no longer shrinks the step row (known defect 57, SHW-058). `.caos-story` is a flex column and `.caos-story-layout` takes its content as its flex basis, so a tall evidence panel overflowed the column and every item shrank in proportion; the step row scrolls on x, so its automatic minimum height is 0, and it lost height (51.5 px to 10.7 px in the plant, 4 to 6 px on CAOS_ChancaDEM's story, where gate G5 failed it as cut tabs). The row is now `flex: none`. A browser test measures the row under a short and a 3,000 px panel at 1024, 1280 and 1440 px.
27
+
28
+ ### Adoption
29
+
30
+ - No product change. A product that bounded its own story panel to keep the row whole may keep that bound or drop it.
31
+
1
32
  ## [0.23.001] - 2026-11-07
2
33
 
3
34
  ### Fixed
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.23.001
1
+ 0.24.000
package/dist/index.d.ts CHANGED
@@ -1451,13 +1451,32 @@ interface CalloutBox {
1451
1451
  anchorX: number;
1452
1452
  anchorY: number;
1453
1453
  }
1454
+ /** Measured card heights in CSS pixels by callout id; a callout without one is laid out at the nominal height. */
1455
+ type CalloutHeights = Readonly<Record<string, number>>;
1454
1456
  /** Deterministic callout placement, exported so a product can test its scene geometry before rendering. */
1455
- declare function layoutSceneCallouts(items: SceneCalloutDef[], width: number, height: number, compact?: boolean): CalloutBox[];
1456
- /** Overlay anchored value labels on a stage; normalized anchors are reprojected by the product as the camera moves. */
1457
- declare function SceneCallouts({ items, className, placement }: {
1457
+ declare function layoutSceneCallouts(items: SceneCalloutDef[], width: number, height: number, compact?: boolean, heights?: CalloutHeights): CalloutBox[];
1458
+ /**
1459
+ * Callout cards in a reserved top band; leaders retain their individual scene anchors. The cards are read in the order of
1460
+ * their anchors' x (stable for equal x) and placed in rows from left to right, each row as tall as its tallest card, so a card
1461
+ * that grew when its text wrapped never covers the next row (known defect 60). `heights` are the measured card heights
1462
+ * (SceneCallouts measures its cards and lays them out again when one changes); without them the nominal heights are used.
1463
+ * When that order would make two leaders cross (anchors at different depths), the slot order with the fewest crossings is
1464
+ * taken instead, the anchor order winning every tie. The boxes are returned in anchor x order.
1465
+ */
1466
+ declare function layoutCalloutHeader(items: SceneCalloutDef[], width: number, height: number, compact?: boolean, heights?: CalloutHeights): CalloutBox[];
1467
+ /**
1468
+ * Overlay anchored value labels on a stage; normalized anchors are reprojected by the product as the camera moves.
1469
+ * The layer measures its cards and lays them out on their real heights. In the header placement it sets
1470
+ * `--caos-callout-band` (px) on the layer and calls `onLayout` with the band height: the distance from the top of the
1471
+ * layer to the bottom of the lowest card, which a product reserves above its drawing instead of a nominal band.
1472
+ */
1473
+ declare function SceneCallouts({ items, className, placement, onLayout }: {
1458
1474
  items: SceneCalloutDef[];
1459
1475
  className?: string;
1460
1476
  placement?: 'anchored' | 'header';
1477
+ onLayout?: (info: {
1478
+ bandHeight: number;
1479
+ }) => void;
1461
1480
  }): react.JSX.Element;
1462
1481
 
1463
1482
  interface Scene3DFrameProps {
@@ -1587,4 +1606,4 @@ declare function explodedOffsets(parts: ExplodedPart[], scale?: number): Record<
1587
1606
  declare function detailTier(distance: number): DetailTier;
1588
1607
  declare function sceneEnvironment(theme: 'light' | 'dark'): SceneEnvironment;
1589
1608
 
1590
- export { BREAKPOINTS, BarChart, type BarChartProps, type BarDatum, BiText, Callout, type CalloutBox, type CalloutState, type CameraFlyOptions, type CameraFrame, type CameraPose, CardDeck, type CardDeckProps, type CaseDef, type CaseFocusConfig, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, CaseWorkbench, type CaseWorkbenchProps, ChipGroup, type ChipGroupProps, type ChipOption, type Citation, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, type DeckCard, type DetailTier, DocPage, type DocPageProps, DocReferences, DocRefsScope, DocSection, type DocSectionProps, EXECUTIVE_SCHEMA_VERSION, Equation, type ExecutiveData, type ExecutiveProblem, ExecutiveView, type ExecutiveViewProps, type ExplodedPart, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, FlipCard, type FlipCardProps, type FocusMode, type FocusModeConfig, type FocusModeOptions, FocusShell, type FocusShellProps, FormatOptions, Gauge, type GaugeProps, type GaugeZone, Glossary, type GlossaryEntry, InlineMath, Knob, type KnobProps, Landing, type Lane, LaneBadge, Lang, LanguageToggle, LinkedViewProvider, type LinkedViewSide, type LinkedViewState, type LinkedViewStore, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, type MotionOptions, type MotionStage, PanelBoundary, type PausedVizController, PlotCard, type PlotCardProps, type Provenance, Question, type QuestionProps, type RailSection, Readout, type ReadoutItem, type ReadoutProps, ReferenceList, Refs, Scene3DFrame, type Scene3DFrameProps, type SceneBounds, type SceneCalloutDef, SceneCallouts, type SceneEnvironment, type SelectionOrigin, Stage, type StageSize, Stat, type StatProps, type StoryAccent, StoryFrame, type StoryFrameProps, type StoryStep, type SubTabDef, SubTabs, type SubTabsProps, type SurfaceFocusConfig, SurfacePage, type SurfacePageProps, type TabDef, type TabGroupDef, TabGroups, type TabGroupsProps, Tabs, type TabsProps, type Theme, ThemeToggle, type Tone, type UsePausedVizOptions, VariantBar, type VariantBarProps, type VariantDef, type Vec3, Verdict, type VerdictProps, type ViewFocusConfig, ViewsRow, type ViewsRowProps, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchGroup, WorkbenchLayout, type WorkbenchLayoutProps, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, type WorkbenchState, WorkbenchStateContext, applyTheme, cameraForBounds, caseKind, caseTooltip, casesInSource, createLinkedViewStore, createVizLoop, deckPosition, deckWindow, detailTier, explodedOffsets, firstAppearance, flowSpeed, flyCamera, groupByCategory, isStale, layoutSceneCallouts, makeStateKey, perPageFor, prefersReducedMotion, readCaseParam, readTheme, removeQueryParam, replaceQueryParam, runStagedTransition, sceneEnvironment, sourcesPresent, useFocusMode, useLinkedCursor, useLinkedItem, useLinkedView, usePausedViz, useStageSize, useThemeStore, useUrlView, useWorkbenchState, validateExecutive, viewParamOf, withCaseParam };
1609
+ export { BREAKPOINTS, BarChart, type BarChartProps, type BarDatum, BiText, Callout, type CalloutBox, type CalloutHeights, type CalloutState, type CameraFlyOptions, type CameraFrame, type CameraPose, CardDeck, type CardDeckProps, type CaseDef, type CaseFocusConfig, type CaseGroup, type CaseKind, CaseSelector, type CaseSelectorProps, type CaseSelectorText, CaseWorkbench, type CaseWorkbenchProps, ChipGroup, type ChipGroupProps, type ChipOption, type Citation, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, type DeckCard, type DetailTier, DocPage, type DocPageProps, DocReferences, DocRefsScope, DocSection, type DocSectionProps, EXECUTIVE_SCHEMA_VERSION, Equation, type ExecutiveData, type ExecutiveProblem, ExecutiveView, type ExecutiveViewProps, type ExplodedPart, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, FlipCard, type FlipCardProps, type FocusMode, type FocusModeConfig, type FocusModeOptions, FocusShell, type FocusShellProps, FormatOptions, Gauge, type GaugeProps, type GaugeZone, Glossary, type GlossaryEntry, InlineMath, Knob, type KnobProps, Landing, type Lane, LaneBadge, Lang, LanguageToggle, LinkedViewProvider, type LinkedViewSide, type LinkedViewState, type LinkedViewStore, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, type MotionOptions, type MotionStage, PanelBoundary, type PausedVizController, PlotCard, type PlotCardProps, type Provenance, Question, type QuestionProps, type RailSection, Readout, type ReadoutItem, type ReadoutProps, ReferenceList, Refs, Scene3DFrame, type Scene3DFrameProps, type SceneBounds, type SceneCalloutDef, SceneCallouts, type SceneEnvironment, type SelectionOrigin, Stage, type StageSize, Stat, type StatProps, type StoryAccent, StoryFrame, type StoryFrameProps, type StoryStep, type SubTabDef, SubTabs, type SubTabsProps, type SurfaceFocusConfig, SurfacePage, type SurfacePageProps, type TabDef, type TabGroupDef, TabGroups, type TabGroupsProps, Tabs, type TabsProps, type Theme, ThemeToggle, type Tone, type UsePausedVizOptions, VariantBar, type VariantBarProps, type VariantDef, type Vec3, Verdict, type VerdictProps, type ViewFocusConfig, ViewsRow, type ViewsRowProps, type VizFrame, type VizLoop, type VizLoopDeps, type VizLoopOptions, type WorkbenchGroup, WorkbenchLayout, type WorkbenchLayoutProps, type WorkbenchRoute, WorkbenchShell, type WorkbenchShellProps, type WorkbenchState, WorkbenchStateContext, applyTheme, cameraForBounds, caseKind, caseTooltip, casesInSource, createLinkedViewStore, createVizLoop, deckPosition, deckWindow, detailTier, explodedOffsets, firstAppearance, flowSpeed, flyCamera, groupByCategory, isStale, layoutCalloutHeader, layoutSceneCallouts, makeStateKey, perPageFor, prefersReducedMotion, readCaseParam, readTheme, removeQueryParam, replaceQueryParam, runStagedTransition, sceneEnvironment, sourcesPresent, useFocusMode, useLinkedCursor, useLinkedItem, useLinkedView, usePausedViz, useStageSize, useThemeStore, useUrlView, useWorkbenchState, validateExecutive, viewParamOf, withCaseParam };
package/dist/index.js CHANGED
@@ -4169,6 +4169,15 @@ function StoryFrame({ steps, activeId, onStepChange, onExit, labels = {}, autoAd
4169
4169
  const activeIndex = Math.max(0, steps.findIndex((step) => step.id === activeId));
4170
4170
  const active = steps[activeIndex];
4171
4171
  const copy = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
4172
+ const stepper = useRef(null);
4173
+ useEffect(() => {
4174
+ const row = stepper.current;
4175
+ const link = row?.querySelector('[aria-current="step"]');
4176
+ if (!row || !link) return;
4177
+ const r = row.getBoundingClientRect(), l = link.getBoundingClientRect();
4178
+ if (l.left < r.left) row.scrollLeft -= r.left - l.left;
4179
+ else if (l.right > r.right) row.scrollLeft += Math.min(l.right - r.right, l.left - r.left);
4180
+ }, [activeId]);
4172
4181
  useEffect(() => {
4173
4182
  if (steps.length < 3 || steps.length > 6) console.error("[caos-app-shell] A showcase story has 3 to 6 steps (ADR-0082 rule 3)");
4174
4183
  }, [steps.length]);
@@ -4232,7 +4241,7 @@ function StoryFrame({ steps, activeId, onStepChange, onExit, labels = {}, autoAd
4232
4241
  }, children: pick(copy.exit, lang) })
4233
4242
  ] })
4234
4243
  ] }),
4235
- /* @__PURE__ */ jsx("nav", { className: "caos-story-stepper", role: "tablist", "aria-label": pick(copy.steps, lang), children: steps.map((step) => /* @__PURE__ */ jsxs(Link, { role: "tab", "aria-selected": step.id === active.id, "aria-current": step.id === active.id ? "step" : void 0, to: step.href, "data-story-link": step.id, className: step.id === active.id ? "caos-story-step is-active" : "caos-story-step", onClick: () => {
4244
+ /* @__PURE__ */ jsx("nav", { ref: stepper, className: "caos-story-stepper", role: "tablist", "aria-label": pick(copy.steps, lang), children: steps.map((step) => /* @__PURE__ */ jsxs(Link, { role: "tab", "aria-selected": step.id === active.id, "aria-current": step.id === active.id ? "step" : void 0, to: step.href, "data-story-link": step.id, className: step.id === active.id ? "caos-story-step is-active" : "caos-story-step", onClick: () => {
4236
4245
  setPlaying(false);
4237
4246
  onStepChange(step.id);
4238
4247
  }, children: [
@@ -4250,14 +4259,22 @@ function StoryFrame({ steps, activeId, onStepChange, onExit, labels = {}, autoAd
4250
4259
  ] }, active.id)
4251
4260
  ] });
4252
4261
  }
4262
+ var STATE_LABELS = {
4263
+ alarm: { glyph: "\u25B2", en: "alarm", es: "alarma" },
4264
+ warning: { glyph: "\u25C6", en: "warning", es: "aviso" },
4265
+ normal: { glyph: "\u25CF", en: "normal", es: "normal" },
4266
+ selected: { glyph: "\u25CE", en: "selected", es: "seleccionado" },
4267
+ inactive: { glyph: "\u25CB", en: "inactive", es: "inactivo" }
4268
+ };
4253
4269
  var overlaps = (a, b) => a.left < b.left + b.width && a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top;
4254
4270
  var coversAnchor = (box, x, y, clearance = 8) => x >= box.left - clearance && x <= box.left + box.width + clearance && y >= box.top - clearance && y <= box.top + box.height + clearance;
4255
- function layoutSceneCallouts(items, width, height, compact = false) {
4271
+ function layoutSceneCallouts(items, width, height, compact = false, heights) {
4256
4272
  const w = compact ? 142 : 184;
4257
- const h = compact ? 56 : 68;
4273
+ const nominal = compact ? 56 : 68;
4258
4274
  const margin = 8;
4259
4275
  const boxes = [];
4260
4276
  for (const item of items) {
4277
+ const h = heights?.[item.id] ?? nominal;
4261
4278
  const x = Math.max(0, Math.min(width, item.anchor.x * width));
4262
4279
  const y = Math.max(0, Math.min(height, item.anchor.y * height));
4263
4280
  const d = compact ? 82 : 112;
@@ -4275,19 +4292,67 @@ function layoutSceneCallouts(items, width, height, compact = false) {
4275
4292
  }
4276
4293
  return boxes;
4277
4294
  }
4278
- function layoutCalloutHeader(items, width, height, compact = false) {
4279
- const w = compact ? 164 : 184, h = compact ? 54 : 68, margin = 8, gap = compact ? 6 : 8;
4280
- const columns = width < 520 ? 2 : Math.max(1, Math.min(items.length, Math.floor((width - margin * 2 + gap) / (w + gap))));
4281
- return items.map((item, index) => {
4282
- const column = index % columns, row = Math.floor(index / columns), count = Math.min(columns, items.length - row * columns);
4283
- const cell = (width - margin * 2 - gap * (count - 1)) / count;
4284
- return { id: item.id, left: margin + column * (cell + gap) + (cell - w) / 2, top: margin + row * (h + gap), width: w, height: h, anchorX: item.anchor.x * width, anchorY: item.anchor.y * height };
4285
- });
4295
+ function leadersCross(a, b) {
4296
+ const [x1, y1, x2, y2] = [a.anchorX, a.anchorY, a.left + a.width / 2, a.top + a.height / 2];
4297
+ const [x3, y3, x4, y4] = [b.anchorX, b.anchorY, b.left + b.width / 2, b.top + b.height / 2];
4298
+ const d = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3);
4299
+ if (Math.abs(d) < 1e-9) return false;
4300
+ const t2 = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / d;
4301
+ const u = ((x3 - x1) * (y2 - y1) - (y3 - y1) * (x2 - x1)) / d;
4302
+ return t2 > 1e-6 && t2 < 1 - 1e-6 && u > 1e-6 && u < 1 - 1e-6;
4303
+ }
4304
+ function* orders(n, prefix = []) {
4305
+ if (prefix.length === n) {
4306
+ yield prefix;
4307
+ return;
4308
+ }
4309
+ for (let i = 0; i < n; i += 1) if (!prefix.includes(i)) yield* orders(n, [...prefix, i]);
4310
+ }
4311
+ function layoutCalloutHeader(items, width, height, compact = false, heights) {
4312
+ const nominalW = compact ? 164 : 184, nominalH = compact ? 54 : 68, margin = 8, gap = compact ? 6 : 8;
4313
+ const minW = compact ? 140 : nominalW;
4314
+ const sorted = items.map((item, index) => ({ item, index })).sort((a, b) => a.item.anchor.x - b.item.anchor.x || a.index - b.index).map(({ item }) => item);
4315
+ if (!sorted.length) return [];
4316
+ const columns = Math.max(1, Math.min(sorted.length, Math.floor((width - margin * 2 + gap) / (minW + gap))));
4317
+ const place = (slots) => {
4318
+ const boxes = [];
4319
+ let top = margin;
4320
+ for (let start = 0; start < slots.length; start += columns) {
4321
+ const row = slots.slice(start, start + columns);
4322
+ const cell = (width - margin * 2 - gap * (row.length - 1)) / row.length;
4323
+ const w = Math.max(0, Math.min(nominalW, cell));
4324
+ const rowHeight = Math.max(...row.map((item) => heights?.[item.id] ?? nominalH));
4325
+ row.forEach((item, column) => boxes.push({ id: item.id, left: margin + column * (cell + gap) + (cell - w) / 2, top, width: w, height: heights?.[item.id] ?? nominalH, anchorX: item.anchor.x * width, anchorY: item.anchor.y * height }));
4326
+ top += rowHeight + gap;
4327
+ }
4328
+ return boxes;
4329
+ };
4330
+ const crossings = (boxes) => {
4331
+ let count = 0;
4332
+ for (let i = 0; i < boxes.length; i += 1) for (let j = i + 1; j < boxes.length; j += 1) if (leadersCross(boxes[i], boxes[j])) count += 1;
4333
+ return count;
4334
+ };
4335
+ let best = place(sorted);
4336
+ let fewest = crossings(best);
4337
+ if (fewest > 0 && sorted.length <= 6) {
4338
+ for (const order of orders(sorted.length)) {
4339
+ const boxes = place(order.map((i) => sorted[i]));
4340
+ const count = crossings(boxes);
4341
+ if (count < fewest) {
4342
+ best = boxes;
4343
+ fewest = count;
4344
+ if (count === 0) break;
4345
+ }
4346
+ }
4347
+ }
4348
+ const at = new Map(best.map((box) => [box.id, box]));
4349
+ return sorted.map((item) => at.get(item.id));
4286
4350
  }
4287
- function SceneCallouts({ items, className, placement = "anchored" }) {
4351
+ function SceneCallouts({ items, className, placement = "anchored", onLayout }) {
4288
4352
  const lang = useShellLang();
4289
4353
  const host = useRef(null);
4290
4354
  const [size, setSize] = useState({ width: 0, height: 0 });
4355
+ const [heights, setHeights] = useState({});
4291
4356
  useEffect(() => {
4292
4357
  const node = host.current;
4293
4358
  if (!node) return;
@@ -4298,15 +4363,44 @@ function SceneCallouts({ items, className, placement = "anchored" }) {
4298
4363
  observer.observe(node);
4299
4364
  return () => observer.disconnect();
4300
4365
  }, []);
4366
+ const ids = items.map((item) => item.id).join("\0");
4367
+ useEffect(() => {
4368
+ const node = host.current;
4369
+ if (!node) return;
4370
+ const observer = new ResizeObserver((entries) => setHeights((previous) => {
4371
+ let next = previous;
4372
+ for (const entry of entries) {
4373
+ const id = entry.target.dataset.caosCallout;
4374
+ const measured2 = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.offsetHeight;
4375
+ if (id === void 0 || !(measured2 > 0) || Math.abs((previous[id] ?? -1) - measured2) <= 0.25) continue;
4376
+ if (next === previous) next = { ...previous };
4377
+ next[id] = measured2;
4378
+ }
4379
+ return next;
4380
+ }));
4381
+ node.querySelectorAll("[data-caos-callout]").forEach((card) => observer.observe(card));
4382
+ return () => observer.disconnect();
4383
+ }, [ids, placement]);
4384
+ const compact = size.width < 640;
4301
4385
  const boxes = useMemo(() => {
4302
- if (placement === "anchored") return layoutSceneCallouts(items, size.width, size.height, size.width < 640);
4303
- return layoutCalloutHeader(items, size.width, size.height, size.width < 640);
4304
- }, [items, placement, size]);
4305
- return /* @__PURE__ */ jsxs("div", { ref: host, className: ["caos-callout-layer", className].filter(Boolean).join(" "), "aria-label": lang === "es" ? "Valores fijados a los componentes" : "Values anchored to components", "data-caos-callout-layer": "", "data-placement": placement, children: [
4386
+ if (placement === "anchored") return layoutSceneCallouts(items, size.width, size.height, compact, heights);
4387
+ return layoutCalloutHeader(items, size.width, size.height, compact, heights);
4388
+ }, [items, placement, size, compact, heights]);
4389
+ const nominalH = placement === "anchored" ? compact ? 56 : 68 : compact ? 54 : 68;
4390
+ const bandHeight = placement === "header" && boxes.length ? Math.max(...boxes.map((box) => box.top + box.height)) : 0;
4391
+ const measured = items.every((item) => heights[item.id] !== void 0);
4392
+ const report = useRef(onLayout);
4393
+ report.current = onLayout;
4394
+ useEffect(() => {
4395
+ if (placement === "header" && size.width > 0 && measured) report.current?.({ bandHeight });
4396
+ }, [placement, size.width, measured, bandHeight]);
4397
+ const style = placement === "header" ? { "--caos-callout-band": `${bandHeight}px` } : void 0;
4398
+ return /* @__PURE__ */ jsxs("div", { ref: host, className: ["caos-callout-layer", className].filter(Boolean).join(" "), style, "aria-label": lang === "es" ? "Valores fijados a los componentes" : "Values anchored to components", "data-caos-callout-layer": "", "data-placement": placement, children: [
4306
4399
  /* @__PURE__ */ jsx("svg", { className: "caos-callout-leaders", "aria-hidden": "true", viewBox: `0 0 ${Math.max(1, size.width)} ${Math.max(1, size.height)}`, preserveAspectRatio: "none", children: boxes.map((box) => /* @__PURE__ */ jsx("line", { x1: box.anchorX, y1: box.anchorY, x2: box.left + box.width / 2, y2: box.top + box.height / 2, "data-caos-callout-leader": box.id }, box.id)) }),
4307
4400
  boxes.map((box) => {
4308
4401
  const item = items.find((candidate) => candidate.id === box.id);
4309
- return /* @__PURE__ */ jsxs("article", { className: "caos-scene-callout", "data-caos-callout": item.id, "data-callout-value": item.value, "data-callout-unit": pick(item.unit, lang), "data-callout-state": item.state, "data-callout-source": pick(item.source, lang), "data-callout-anchor-x": item.anchor.x, "data-callout-anchor-y": item.anchor.y, style: { left: box.left, top: box.top, width: box.width, minHeight: box.height }, children: [
4402
+ const state = Object.prototype.hasOwnProperty.call(STATE_LABELS, item.state) ? STATE_LABELS[item.state] : null;
4403
+ return /* @__PURE__ */ jsxs("article", { className: "caos-scene-callout", "data-caos-callout": item.id, "data-callout-value": item.value, "data-callout-unit": pick(item.unit, lang), "data-callout-state": item.state, "data-callout-source": pick(item.source, lang), "data-callout-anchor-x": item.anchor.x, "data-callout-anchor-y": item.anchor.y, style: { left: box.left, top: box.top, width: box.width, minHeight: nominalH }, children: [
4310
4404
  /* @__PURE__ */ jsx("span", { className: "caos-callout-eyebrow", title: pick(item.eyebrow, lang), children: pick(item.eyebrow, lang) }),
4311
4405
  /* @__PURE__ */ jsxs("strong", { title: `${item.value} ${pick(item.unit, lang)}`, children: [
4312
4406
  item.value,
@@ -4314,6 +4408,10 @@ function SceneCallouts({ items, className, placement = "anchored" }) {
4314
4408
  /* @__PURE__ */ jsx("small", { children: pick(item.unit, lang) })
4315
4409
  ] }),
4316
4410
  item.trend && /* @__PURE__ */ jsx("span", { className: `caos-callout-trend trend-${item.trend}`, "aria-label": item.trend, children: item.trend === "up" ? "\u2191" : item.trend === "down" ? "\u2193" : "\u2192" }),
4411
+ state && /* @__PURE__ */ jsxs("span", { className: "caos-callout-state", "data-callout-state-label": "", children: [
4412
+ /* @__PURE__ */ jsx("span", { className: "caos-callout-state-glyph", "aria-hidden": "true", "data-callout-state-glyph": "", children: state.glyph }),
4413
+ state[lang]
4414
+ ] }),
4317
4415
  /* @__PURE__ */ jsx("small", { className: "caos-callout-source", title: pick(item.source, lang), children: pick(item.source, lang) })
4318
4416
  ] }, box.id);
4319
4417
  })
@@ -4453,4 +4551,4 @@ function sceneEnvironment(theme) {
4453
4551
  return theme === "dark" ? { background: "#101a22", ground: "#202b33", key: { color: "#fff0d5", intensity: 2.1, castShadow: true }, fill: { color: "#98c5db", intensity: 0.8 }, ambient: { color: "#c9d7df", intensity: 1.1 } } : { background: "#e9eef0", ground: "#d6dee1", key: { color: "#fff8e9", intensity: 1.7, castShadow: true }, fill: { color: "#8eabb9", intensity: 0.65 }, ambient: { color: "#ffffff", intensity: 1.35 } };
4454
4552
  }
4455
4553
 
4456
- export { AppShell, ArchitectureModal, BREAKPOINTS, BarChart, Callout, CardDeck, CaseSelector, CaseWorkbench, ChipGroup, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, DocPage, DocReferences, DocRefsScope, DocSection, EXECUTIVE_SCHEMA_VERSION, Equation, ExecutiveView, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, FlipCard, FocusShell, Gauge, Glossary, InlineMath, Knob, Landing, LaneBadge, LanguageToggle, LinkedViewProvider, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, PlotCard, Question, Readout, ReferenceList, Refs, Scene3DFrame, SceneCallouts, Stat, StoryFrame, SubTabs, SurfacePage, TabGroups, Tabs, ThemeToggle, VariantBar, Verdict, ViewsRow, WorkbenchLayout, WorkbenchShell, WorkbenchStateContext, cameraForBounds, caseKind, caseTooltip, casesInSource, createLinkedViewStore, createVizLoop, deckPosition, deckWindow, detailTier, explodedOffsets, firstAppearance, flowSpeed, flyCamera, groupByCategory, isStale, layoutSceneCallouts, makeStateKey, perPageFor, prefersReducedMotion, readCaseParam, removeQueryParam, replaceQueryParam, runStagedTransition, sceneEnvironment, sourcesPresent, useFocusMode, useLinkedCursor, useLinkedItem, useLinkedView, usePausedViz, useUrlView, useWorkbenchState, validateArchitectureConfig, validateExecutive, viewParamOf, withCaseParam };
4554
+ export { AppShell, ArchitectureModal, BREAKPOINTS, BarChart, Callout, CardDeck, CaseSelector, CaseWorkbench, ChipGroup, CitationsProvider, Cite, DECK_TEXT, DOC_AREAS, DocPage, DocReferences, DocRefsScope, DocSection, EXECUTIVE_SCHEMA_VERSION, Equation, ExecutiveView, FLIP_LIMITS, FLIP_TEXT, FOCUS_PARAM, FOCUS_TEXT, FOUR_CS, Figure, FlipCard, FocusShell, Gauge, Glossary, InlineMath, Knob, Landing, LaneBadge, LanguageToggle, LinkedViewProvider, MAX_PEER_TABS, MAX_WORKBENCH_GROUPS, PanelBoundary, PlotCard, Question, Readout, ReferenceList, Refs, Scene3DFrame, SceneCallouts, Stat, StoryFrame, SubTabs, SurfacePage, TabGroups, Tabs, ThemeToggle, VariantBar, Verdict, ViewsRow, WorkbenchLayout, WorkbenchShell, WorkbenchStateContext, cameraForBounds, caseKind, caseTooltip, casesInSource, createLinkedViewStore, createVizLoop, deckPosition, deckWindow, detailTier, explodedOffsets, firstAppearance, flowSpeed, flyCamera, groupByCategory, isStale, layoutCalloutHeader, layoutSceneCallouts, makeStateKey, perPageFor, prefersReducedMotion, readCaseParam, removeQueryParam, replaceQueryParam, runStagedTransition, sceneEnvironment, sourcesPresent, useFocusMode, useLinkedCursor, useLinkedItem, useLinkedView, usePausedViz, useUrlView, useWorkbenchState, validateArchitectureConfig, validateExecutive, viewParamOf, withCaseParam };
package/gate/inpage.mjs CHANGED
@@ -1583,6 +1583,8 @@ export function installLib() {
1583
1583
  const callouts = [...(stage?.querySelectorAll('[data-caos-callout]') ?? [])].filter(visible).map((element) => ({
1584
1584
  id: element.getAttribute('data-caos-callout'), value: element.getAttribute('data-callout-value'), unit: element.getAttribute('data-callout-unit'),
1585
1585
  state: element.getAttribute('data-callout-state'), source: element.getAttribute('data-callout-source'),
1586
+ // the state as a reader sees it besides the colour (ADR-0082 rule 8): the visible text of the state label, empty when hidden or absent
1587
+ stateLabel: (() => { const label = element.querySelector('[data-callout-state-label]'); return label && visible(label) ? (label.innerText ?? label.textContent ?? '').trim() : ''; })(),
1586
1588
  anchor: { x: Number(element.getAttribute('data-callout-anchor-x')), y: Number(element.getAttribute('data-callout-anchor-y')) }, box: box(element),
1587
1589
  }));
1588
1590
  const links = [...(story?.querySelectorAll('[data-story-link]') ?? [])].filter(visible).map((element) => ({ id: element.getAttribute('data-story-link'), href: element.getAttribute('href'), active: element.getAttribute('aria-selected') === 'true' }));
package/gate/run.mjs CHANGED
@@ -35,6 +35,10 @@
35
35
  // G20 chart interactions every chart is one tab stop with an accessible name, described by a table of its values and a one-line summary,
36
36
  // and a zoomed chart resets (the + key zooms, the visible control shows, the 0 key resets); a chart that turns zoom
37
37
  // off says why (12 letters and digits at least) and is listed among the waivers (0.19.0)
38
+ // G21 showcase story with --require-showcase: a directly linked 3 to 6 step story; its callouts carry value, unit, provenance and
39
+ // a state shown as a word (not by colour alone), leave their own anchor clear, never overlap, stay on the stage
40
+ // and have a leader each, at every size, theme and language of the matrix (0.24.0); interactions, motion and
41
+ // performance at the primary size
38
42
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
39
43
  // references).
40
44
 
@@ -128,6 +132,7 @@ export async function runGate(o) {
128
132
  const focusReported = new Set(); // G18: an App without a Focus control is reported once per route and mode
129
133
  const waivers = []; // ADR-0017 2.1: every waiver a documentation page declares, with its verdict
130
134
  const docWalks = []; // the document walk's cost per state (samples and time against the budget it earned), for the report
135
+ const storyContexts = []; // G21: every context (size, theme, language) whose story routes had their callouts measured (0.24.0)
131
136
  // The smallest measured values against each floor, so a pass shows its margin (and a CI font change that eats it
132
137
  // is visible before it fails).
133
138
  const minima = { drawnShare: null, stageFill: null };
@@ -416,12 +421,41 @@ export async function runGate(o) {
416
421
  if (stories.length === 0) {
417
422
  fail('G21', { route: '/story', mode: 'routes' }, 'no /story/<step> route is declared; ADR-0082 requires a directly addressable guided story');
418
423
  } else {
424
+ const STATES = ['alarm', 'warning', 'normal', 'selected', 'inactive'];
425
+ // The callout checks of one story route in one context: content, state shown by more than colour, the own anchor
426
+ // left clear, no overlap, inside the stage, one leader each, no horizontal overflow. They run at every size, theme
427
+ // and language of the gate matrix (known defect 59, 0.24.0): a card grows with its text, so a layout that holds at
428
+ // 1280 in English can overlap on a phone or in Spanish.
429
+ const calloutChecks = async (page, at, f) => {
430
+ for (const call of f.calls) {
431
+ if (!call.value?.trim() || !call.unit?.trim()) fail('G21', at, `callout ${call.id} has no value or unit`);
432
+ if (!STATES.includes(call.state)) fail('G21', at, `callout ${call.id} has no recognised semantic state`);
433
+ // ADR-0082 rule 8: a state is never shown by colour alone (a word or an icon with it)
434
+ else if (!/[\p{L}]/u.test(call.stateLabel ?? '')) fail('G21', at, `callout ${call.id} shows its state by colour alone: no visible data-callout-state-label with its word (ADR-0082 rule 8)`);
435
+ if (!call.source?.trim()) fail('G21', at, `callout ${call.id} has no value provenance`);
436
+ if (![call.anchor?.x, call.anchor?.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1)) fail('G21', at, `callout ${call.id} has no normalized subject anchor`);
437
+ else if (call.box && f.stage) {
438
+ const x = f.stage.left + call.anchor.x * f.stage.width, y = f.stage.top + call.anchor.y * f.stage.height;
439
+ const clearance = 8;
440
+ if (x >= call.box.left - clearance && x <= call.box.right + clearance && y >= call.box.top - clearance && y <= call.box.bottom + clearance)
441
+ fail('G21', at, `callout ${call.id} covers its own subject anchor`);
442
+ }
443
+ }
444
+ for (let i = 0; i < f.calls.length; i += 1) for (let j = i + 1; j < f.calls.length; j += 1) {
445
+ const a = f.calls[i].box, b = f.calls[j].box;
446
+ if (a && b && a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top) fail('G21', at, `callouts ${f.calls[i].id} and ${f.calls[j].id} overlap`);
447
+ }
448
+ if (f.calls.some(({ box }) => !box || !f.stage || box.left < f.stage.left || box.right > f.stage.right || box.top < f.stage.top || box.bottom > f.stage.bottom)) fail('G21', at, 'one or more callouts escape the stage instead of following its subject');
449
+ if (f.calls.length && await page.locator('[data-story] [data-caos-callout-leader]').count() < f.calls.length) fail('G21', at, 'not every scene callout has a leader to its subject anchor');
450
+ if (f.scroll.width > f.viewport.width + 1) fail('G21', at, `story page overflows horizontally (${f.scroll.width}px in ${f.viewport.width}px)`);
451
+ };
419
452
  const size = primarySize;
420
453
  const [w, h] = size;
421
454
  const mode = `${w}x${h} ${themes[0]} ${langs[0]} story`;
422
455
  const ctx = await openContext(size, themes[0], langs[0]);
423
456
  const { page, sink } = await newPage(ctx);
424
457
  const visited = [];
458
+ storyContexts.push({ mode, routes: stories.length });
425
459
  for (const route of stories) {
426
460
  const at = { route, mode };
427
461
  const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
@@ -440,28 +474,33 @@ export async function runGate(o) {
440
474
  if (share < 0.8) fail('G21', at, `story stage and docked panel cover ${(100 * share).toFixed(1)}% of the viewport (floor 80%, ADR-0082)`);
441
475
  }
442
476
  if (f.calls.length < 3) fail('G21', at, `stage has ${f.calls.length} visible anchored callouts; ADR-0082 requires at least 3`);
443
- for (const call of f.calls) {
444
- if (!call.value?.trim() || !call.unit?.trim()) fail('G21', at, `callout ${call.id} has no value or unit`);
445
- if (!['alarm', 'warning', 'normal', 'selected', 'inactive'].includes(call.state)) fail('G21', at, `callout ${call.id} has no recognised semantic state`);
446
- if (!call.source?.trim()) fail('G21', at, `callout ${call.id} has no value provenance`);
447
- if (![call.anchor?.x, call.anchor?.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1)) fail('G21', at, `callout ${call.id} has no normalized subject anchor`);
448
- else if (call.box && f.stage) {
449
- const x = f.stage.left + call.anchor.x * f.stage.width, y = f.stage.top + call.anchor.y * f.stage.height;
450
- const clearance = 8;
451
- if (x >= call.box.left - clearance && x <= call.box.right + clearance && y >= call.box.top - clearance && y <= call.box.bottom + clearance)
452
- fail('G21', at, `callout ${call.id} covers its own subject anchor`);
453
- }
454
- }
455
- for (let i = 0; i < f.calls.length; i += 1) for (let j = i + 1; j < f.calls.length; j += 1) {
456
- const a = f.calls[i].box, b = f.calls[j].box;
457
- if (a && b && a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top) fail('G21', at, `callouts ${f.calls[i].id} and ${f.calls[j].id} overlap`);
458
- }
459
- if (f.calls.some(({ box }) => !box || box.left < f.stage.left || box.right > f.stage.right || box.top < f.stage.top || box.bottom > f.stage.bottom)) fail('G21', at, 'one or more callouts escape the stage instead of following its subject');
460
- if (f.calls.length && await page.locator('[data-story] [data-caos-callout-leader]').count() < f.calls.length) fail('G21', at, 'not every scene callout has a leader to its subject anchor');
461
- if (f.scroll.width > f.viewport.width + 1) fail('G21', at, `story page overflows horizontally (${f.scroll.width}px in ${f.viewport.width}px)`);
477
+ await calloutChecks(page, at, f);
462
478
  visited.push({ route, step: f.step, hrefs: f.links.map((x) => x.href) });
463
479
  drain(sink, at);
464
480
  }
481
+ // Every other context of the gate matrix: the callout checks only. The performance and interaction checks below stay
482
+ // at the primary size. Console errors of these loads are not drained here: the main walk loads the same routes in the
483
+ // same contexts and reports them there once.
484
+ for (const s of sizes) for (const theme of themes) for (const lang of langs) {
485
+ if (s === primarySize && theme === themes[0] && lang === langs[0]) continue;
486
+ const smode = `${s[0]}x${s[1]} ${theme} ${lang} story`;
487
+ const sctx = await openContext(s, theme, lang);
488
+ const { page: sp, sink: ssink } = await newPage(sctx);
489
+ for (const route of stories) {
490
+ const at = { route, mode: smode };
491
+ const res = await sp.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
492
+ const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
493
+ if (status !== 200) { fail('G21', at, `direct story link answered ${status}`); ssink.splice(0); continue; }
494
+ const settled = await settle(sp);
495
+ if (settled) fail('G21', at, `story did not settle: ${settled}`);
496
+ const f = await sp.evaluate(() => window.__caosGate.showcaseFacts());
497
+ if (!f.present) fail('G21', at, 'route does not render [data-story]');
498
+ else await calloutChecks(sp, at, f);
499
+ ssink.splice(0);
500
+ }
501
+ storyContexts.push({ mode: smode, routes: stories.length });
502
+ await sctx.close();
503
+ }
465
504
  const first = visited.find((x) => x.hrefs.length >= 3);
466
505
  if (first) {
467
506
  const hrefs = [...new Set(first.hrefs.map((href) => routeOf(new URL(href, base).href)).filter(Boolean))];
@@ -1290,6 +1329,7 @@ export async function runGate(o) {
1290
1329
  minima,
1291
1330
  waivers,
1292
1331
  docWalks,
1332
+ storyContexts,
1293
1333
  checks,
1294
1334
  captures: captures.length,
1295
1335
  failures,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
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
5
  "license": "MIT",
6
6
  "author": "Felipe Santibáñez-Leal",
@@ -34,6 +34,8 @@
34
34
  "caos-callout-layer",
35
35
  "caos-callout-leaders",
36
36
  "caos-callout-source",
37
+ "caos-callout-state",
38
+ "caos-callout-state-glyph",
37
39
  "caos-callout-trend",
38
40
  "caos-card",
39
41
  "caos-chart",
@@ -392,6 +394,8 @@
392
394
  "caos-callout-layer",
393
395
  "caos-callout-leaders",
394
396
  "caos-callout-source",
397
+ "caos-callout-state",
398
+ "caos-callout-state-glyph",
395
399
  "caos-callout-trend",
396
400
  "caos-card",
397
401
  "caos-chart",
package/styles.css CHANGED
@@ -993,7 +993,8 @@ svg.lucide { flex: none; }
993
993
  .caos-story button { min-height: 36px; padding: .35rem .65rem; color: var(--color-fg); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; }
994
994
  .caos-story button:disabled { opacity: .55; cursor: not-allowed; }
995
995
  .caos-story-actions button { min-height: clamp(28px, 2.5vw, 36px); padding: clamp(.15rem, .5vw, .35rem) clamp(.4rem, .8vw, .65rem); font-size: clamp(.75rem, .8vw, .9rem); line-height: 1.15; white-space: nowrap; }
996
- .caos-story-stepper { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(120px, 1fr); grid-template-columns: none; gap: var(--space-2); min-width: 0; overflow-x: auto; }
996
+ /* flex: none: the layout below takes its content as its basis, so a tall panel overflowed the column and shrank this row (it scrolls on x, so its automatic minimum height is 0; known defect 57, SHW-058). */
997
+ .caos-story-stepper { display: grid; flex: none; grid-auto-flow: column; grid-auto-columns: minmax(120px, 1fr); grid-template-columns: none; gap: var(--space-2); min-width: 0; overflow-x: auto; }
997
998
  .caos-story-step { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; align-items: center; column-gap: .55rem; min-width: 0; min-height: 40px; padding: .2rem .55rem; color: var(--color-fg-subtle); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); text-decoration: none; }
998
999
  .caos-story-step:hover { color: var(--color-fg); text-decoration: none; }
999
1000
  .caos-story-step.is-active { color: var(--color-fg); border-color: var(--color-accent); box-shadow: inset 3px 0 var(--color-accent); }
@@ -1024,13 +1025,18 @@ svg.lucide { flex: none; }
1024
1025
  .caos-scene-callout[data-callout-state="warning"] { border-left-color: var(--color-warn); }
1025
1026
  .caos-scene-callout[data-callout-state="normal"] { border-left-color: var(--color-good); }
1026
1027
  .caos-scene-callout[data-callout-state="inactive"] { border-left-color: var(--color-fg-faint); }
1027
- .caos-callout-eyebrow { overflow: hidden; color: var(--color-fg); font: 700 .68rem var(--font-mono); letter-spacing: .05em; text-overflow: ellipsis; white-space: nowrap; }
1028
+ /* The eyebrow wraps instead of ending in an ellipsis (0.24.0): the cards are laid out on their measured heights, so a longer
1029
+ Spanish eyebrow ("Alimentación del molino" in a 164 px card) is read whole and the card grows. */
1030
+ .caos-callout-eyebrow { min-width: 0; color: var(--color-fg); font: 700 .68rem/1.25 var(--font-mono); letter-spacing: .05em; overflow-wrap: anywhere; }
1028
1031
  .caos-scene-callout > strong { overflow: hidden; font: 700 .92rem var(--font-mono); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
1029
1032
  .caos-scene-callout > strong small,.caos-callout-source { color: var(--color-fg); font-size: .68rem; font-weight: 500; }
1030
1033
  .caos-callout-trend { grid-column: 2; grid-row: 1 / span 2; align-self: center; font-size: 1.1rem; font-weight: 800; }
1031
1034
  .caos-callout-trend.trend-up { color: var(--color-bad); }
1032
1035
  .caos-callout-trend.trend-down { color: var(--color-good); }
1033
1036
  .caos-callout-source { grid-column: 1 / -1; }
1037
+ /* The state as a word with a glyph of its own shape (ADR-0082 rule 8, known defect 58): the border colour is not the only sign. */
1038
+ .caos-callout-state { grid-column: 1 / -1; display: flex; align-items: center; gap: .3em; min-width: 0; color: var(--color-fg); font: 700 .64rem/1.2 var(--font-mono); letter-spacing: .04em; white-space: nowrap; }
1039
+ .caos-callout-state-glyph { flex: none; font-size: .95em; line-height: 1; }
1034
1040
  @media (max-width: 760px) {
1035
1041
  .app-shell.immersive { height: auto; min-height: 100dvh; overflow: visible; }
1036
1042
  .app-shell.immersive > .page { display: block; overflow: visible; }
@@ -1042,7 +1048,13 @@ svg.lucide { flex: none; }
1042
1048
  .caos-story-actions { width: 100%; flex-wrap: wrap; justify-content: space-between; gap: .35rem; }
1043
1049
  .caos-story-controls { flex: 1 1 auto; justify-content: flex-start; gap: .35rem; }
1044
1050
  .caos-story button { min-height: 34px; padding: .3rem .5rem; font-size: .78rem; white-space: nowrap; }
1045
- .caos-story-stepper { grid-auto-columns: minmax(90px, 1fr); grid-auto-flow: column; grid-template-columns: unset; overflow-x: auto; }
1051
+ /* every verb whole on a phone (review m4 of 0.23.2): a step is at least as wide as its number and verb, the row scrolls on x and
1052
+ StoryFrame keeps the active step inside it */
1053
+ .caos-story-stepper { container-type: inline-size; grid-auto-columns: minmax(max-content, 1fr); grid-auto-flow: column; grid-template-columns: unset; overflow-x: auto; }
1054
+ /* a step is never wider than its row (100cqi of the stepper): a verb longer than the row wraps inside it, so End always shows the
1055
+ last step whole, whatever width the platform's font gives the verb (Linux CI's system font overflowed a 320 px row in 0.24.0's first candidate) */
1056
+ .caos-story-step { max-width: 100cqi; }
1057
+ .caos-story-step-verb { overflow: visible; text-overflow: clip; white-space: normal; overflow-wrap: anywhere; }
1046
1058
  .caos-story-step-headline { display: none; }
1047
1059
  .caos-story-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(max(46dvh, var(--caos-story-stage-minimum, 300px)), auto) auto; }
1048
1060
  .caos-story-panel { max-height: 30dvh; }