@wix/himalaya-cli 0.1000.0 → 0.1002.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.
Files changed (42) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +39 -1
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/validator.d.ts +50 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +46 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +46 -0
  5. package/dist/cli.mjs +207 -20
  6. package/dist/preview-assets.generated.bin +0 -0
  7. package/guides/component-reference.json +2 -2
  8. package/guides/component-reference.md +32 -0
  9. package/package.json +2 -2
  10. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +11 -31
  11. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  12. package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  13. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +11 -31
  14. package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  15. package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  16. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +11 -31
  17. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  18. package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  19. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +11 -31
  20. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  21. package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  22. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +11 -31
  23. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  24. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  25. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +11 -31
  26. package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +31 -0
  27. package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +48 -13
  28. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +11 -31
  29. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  30. package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  31. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +11 -31
  32. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  33. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  34. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +11 -31
  35. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  36. package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  37. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +11 -31
  38. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  39. package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
  40. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +11 -31
  41. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +31 -0
  42. package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +48 -13
@@ -119,7 +119,7 @@
119
119
  "category": "Core",
120
120
  "propsType": "IconProps",
121
121
  "bindings": [],
122
- "propsSource": "export interface IconProps {\n name: IconRef;\n size?: number;\n tint?: TokenName;\n}"
122
+ "propsSource": "export interface IconProps {\n name: IconRef;\n size?: number;\n tint?: TokenName;\n /** The spoken name for a MEANINGFUL icon, mirroring\n * `ImageProps.contentDescription`. Absent (and with no enclosing labelled\n * control) the icon is decorative and stays OUT of the accessibility tree,\n * rather than being announced by its raw glyph name. */\n contentDescription?: string;\n}"
123
123
  },
124
124
  {
125
125
  "name": "Button",
@@ -1252,7 +1252,7 @@
1252
1252
  "ScreenDescriptor": "export interface ScreenDescriptor {\n screenId: string;\n renderer: RendererKind;\n title?: string;\n /** Present when `renderer` is `sdui_intent` or `sdui_layout`. */\n payload?: Component;\n /** A component pinned ABOVE the scrolling payload (sticky tab-header / toolbar).\n * Rendered by the host outside the screen scroll, so it stays put as content\n * scrolls. Optional; absent = no sticky header. */\n stickyHeader?: Component;\n /** A component pinned BELOW the scrolling payload (sticky footer — price/confirm\n * bar, peeking bottom-sheet). Rendered outside the screen scroll by the host. */\n stickyFooter?: Component;\n /** When true, the host wraps the screen in a scroll that starts pinned to the\n * bottom and shows a floating \"jump to latest\" button whenever the user scrolls\n * up (a chat transcript). Tapping it animates back to the bottom. Absent/false =\n * a normal top-anchored scroll. Honored by the app host (Router). */\n scrollToBottom?: boolean;\n /** Present when `renderer` is `webview_native_bridge` or `webview_ds`. */\n url?: string;\n capabilitiesRequired?: string[];\n schemaVersion: number;\n togglesApplied?: string[];\n /** Per-session flag/experiment assignments baked in for this request. */\n assignments?: import(\"./flags.js\").Assignment[];\n /** Per-screen top bar (trailing actions +\n * leading/style override). Fully replaces `NavigationConfig.topBarDefault`. */\n topBar?: TopBarConfig;\n etag?: string;\n cacheControl?: CacheControl;\n telemetryScreenName?: string;\n bundleSri?: string;\n manifestVersion?: number;\n /**\n * Present on Tier-5 screens. The presence of this field is the tier-5\n * signal — there is no separate `tier: 5` flag.\n */\n logicBundle?: LogicBundleRef;\n /**\n * Static per-screen input injected as `globalThis.himalaya.input` before the\n * Tier-5 worker bundle evaluates — the worker reads it synchronously in `load()`\n * (e.g. `himalaya.input.id` for a detail screen, or a baked catalog like\n * `himalaya.input.cmsCollections`).\n *\n * A push merges its navigation parameters into this object on web, iOS, and Android.\n * For example, `himi.open({ screenId: \"event\", id: \"$item.id\" })` makes the id\n * available as `himalaya.input.id` in the pushed worker; static `input` remains the\n * fallback for values baked into a descriptor or a templated route. The host also\n * supplies `screenId`. See docs/tier-5-add-a-screen.md → \"Navigate to a detail screen\".\n *\n * Arbitrary JSON object; consumed by the web host (ScreenController) and the\n * native JSWorker.\n */\n input?: Record<string, unknown>;\n /**\n * Tier 3 / Tier 4 — behavior of the hosted WebView. Ignored by SDUI tiers.\n * Authored in the descriptor builder (git); served OTA verbatim.\n */\n webViewConfig?: WebViewConfig;\n}",
1253
1253
  "Component": "export interface Component {\n id: string;\n body: ComponentBody;\n modifiers?: Modifiers;\n minVersion?: number;\n}",
1254
1254
  "LogicBundleRef": "export interface LogicBundleRef {\n url: string;\n /** \"sha256-...\" SRI string. */\n integrity: string;\n contentType?: string;\n /** SDK capability ids the bundle is permitted to import / invoke. */\n declaredCapabilities: string[];\n /** Bundle refuses to load on a binary older than this (semver). */\n minBinaryVersion?: string;\n /** Bundle's own semver. */\n bundleVersion?: string;\n /** Base64 detached publisher signature over the bundle bytes\n * (authenticity beyond SRI). Verified by a host-injected verifier when present;\n * unsigned bundles load as today. */\n signature?: string;\n /** The publisher key id a host pins, paired with `signature`. */\n keyId?: string;\n /** Ambient refresh (gap G2): the host invokes `action` (a `tier5.<svc>.<action>`\n * path) every `intervalMs` while the screen is visible — workers have no timer\n * of their own, so the host owns the cadence. */\n refresh?: { intervalMs: number; action: string };\n}",
1255
- "Modifiers": "export interface Modifiers {\n padding?: EdgeInsets;\n background?: TokenRef;\n cornerRadius?: number;\n /** Fixed intrinsic width (pt/dp/px). Honored by iOS (`FrameIfPresent`), Android\n * (frame) and web (`modifiers.ts`). With no `ScrollView` in the vocabulary and\n * regions clipped at `maxHeight`, `width`/`height`/`fillMaxWidth` are the main\n * sizing tools for embed/host content. */\n width?: number;\n /** Fixed intrinsic height (pt/dp/px). See `width`. */\n height?: number;\n /** Fill the available cross-axis width (keeps any fixed `height`). Mirrors\n * Compose `fillMaxWidth` / SwiftUI `.frame(maxWidth: .infinity)`. See `width`. */\n fillMaxWidth?: boolean;\n /** CAP the node's laid-out width at this many points. It CONSTRAINS, it\n * does not SET: a node narrower than the cap keeps its intrinsic width, so a\n * short chat bubble stays short while a long one stops at the cap — the\n * asymmetry `width` cannot express and `weight` cannot either (weights divide\n * the *available* space, so every child lands on the same fraction).\n * The cap covers the node's TOTAL width, padding + border included (CSS\n * `max-width` under `box-sizing: border-box`). `fillMaxWidth` grabs the axis but\n * THE CAP WINS — fill + cap is exactly `maxWidth` wide; a literal `width` larger\n * than the cap is clamped to it too. POINTS, not a fraction: a modifier is\n * resolved with no portable notion of \"the row\" on any renderer — compose a\n * percentage from a weighted sibling gap instead. `0`/absent = no cap. */\n maxWidth?: number;\n /** Flex weight along the parent stack's MAIN axis (HStack = width, VStack =\n * height). The stack measures its intrinsic (unweighted) children first, then\n * divides what is LEFT among the weighted ones in proportion to their weights:\n * two children at `1` split the leftover equally, `2` and `1` split it 2:1.\n * Absent (or <= 0) keeps the node at its intrinsic size. Use this — not\n * `fillMaxWidth` — when a node should claim the remaining space *beside* its\n * siblings; a full-bleed fill grabs the whole axis and starves them. Ignored\n * outside an HStack/VStack.\n * CAVEAT — row-sharing controls have NO intrinsic width to measure first: in an\n * HStack a `Button` and a `Toggle` are implicitly weighted, so they DIVIDE the\n * row with your weighted child instead of taking a width ahead of it —\n * `[VStack(weight:1), Button, Button]` is a three-way split, not \"the VStack gets\n * the remainder\". To opt a Button out on EVERY renderer set `fullWidth: false`\n * (an explicit flag wins in both directions; absent falls back to the style\n * default, which is \"share\" for every style but `tertiary`/`link`), or use\n * `iconOnly`. Otherwise give the actions their own row.\n * See `himi docs designing-himi-screens` §4. */\n weight?: number;\n /** RTL — pin the subtree's writing direction: `\"ltr\"` | `\"rtl\"` | `\"locale\"`\n * (inherit). The common RTL pattern is the inverse of `Text.textDirection`: pin a\n * container `ltr` (a logotype, an attachment-tile row, a composer button row)\n * inside an otherwise-RTL screen. Absent = inherit the ambient direction. #1060. */\n layoutDirection?: \"ltr\" | \"rtl\" | \"locale\";\n /** Absolute visual translation (pt/dp/px). Applied as the OUTERMOST transform so\n * it shifts the fully-styled node WITHOUT changing its layout footprint — lets a\n * ZStack place polar/absolute children (e.g. AgentOrbit). Negative allowed. */\n offsetX?: number;\n offsetY?: number;\n onTap?: Action;\n /** Double-tap gesture (e.g. Instagram double-tap-to-like). Distinct from onTap. */\n onDoubleTap?: Action;\n accessibilityLabel?: string;\n hidden?: boolean;\n /** Data-bound conditional visibility: a `\"$service:<svc>.<field>\"`\n * or `\"$item.<field>\"` sentinel resolving to a bool at render; when true the\n * subtree fully COLLAPSES (zero layout, like `hidden`), overriding the\n * literal `hidden`. */\n hiddenPath?: string;\n /** Long-press gesture modifier. */\n onLongPress?: Action;\n /** Fires once when the component first appears on screen. */\n onAppear?: Action;\n /** Scoped token overrides for the subtree. */\n theme?: ThemeOverrides;\n /** Names a token from the app's `shadows` registry. */\n shadow?: TokenRef;\n /** Names a token from the app's `gradients` registry (background fill). */\n backgroundGradient?: TokenRef;\n /** Value-driven gradient: a `$service:`/`$item.` path resolving to a gradient\n * TOKEN NAME used as the live background (e.g. weather sky day→night). */\n backgroundGradientPath?: string;\n /** Value-driven background COLOR: a `$service:`/`$item.` path resolving to a\n * color TOKEN NAME used as the live background. Honored on iOS + Android\n * (`BoundBackgroundIfPresent`); web render support is tracked in #1057. */\n backgroundColorPath?: string;\n /** Value-driven background IMAGE: a `$service:`/`$item.` path resolving to an\n * image URL used as the live background. Honored on iOS + Android; web render\n * support is tracked in #1057. */\n backgroundImagePath?: string;\n /** #1051 — MEASURE (publish): report this node's laid-out size into the renderer's\n * self-contained (worker-free) measure store under the named keys, so a SIBLING can\n * feed the number back into its own layout via a `$measure:<key>` binding. The SDUI\n * equivalent of SwiftUI `.onGeometryChange` / Compose `onGloballyPositioned`; the\n * canonical use is a pinned bottom bar whose measured height becomes a scroll view's\n * bottom inset (sticky-footer + scroll-inset, unrepresentable before — #1051). */\n onMeasure?: Measure;\n /** #1051 — bound height: a `\"$measure:<key>\"` (measure store) or\n * `\"$service:<svc>.<field>\"` / `\"$item.<field>\"` (worker/row state) sentinel resolving\n * to a NUMBER at render. Wins over the literal `height`. String path only, so a\n * binding can never blank the node (never overload the numeric `height`). */\n heightPath?: string;\n /** #1051 — bound width. See `heightPath`. Wins over the literal `width`. */\n widthPath?: string;\n /** #1051 — bound TOP inset (the header-height → top-inset idiom). A `$measure:`/\n * `$service:`/`$item.` sentinel resolving to a number; added as top padding. */\n paddingTopPath?: string;\n /** #1051 — bound BOTTOM inset (the sticky-footer-height → bottom-inset idiom). A\n * `$measure:`/`$service:`/`$item.` sentinel resolving to a number; added as bottom\n * padding so the last scroll row clears a pinned bottom bar. */\n paddingBottomPath?: string;\n /** #1053 — INLINE gradient background paint. Distinct from `backgroundGradient`\n * (which names a `gradients` registry token an embed host can't register): this\n * carries its stops/colours in the descriptor, so it works anywhere. Painted as\n * a surface (behind children), below `material`/`backgroundGradient` if those are\n * also set. */\n gradient?: Gradient;\n /** #1053 — gradient ALPHA MASK: the node shows through where the gradient is\n * opaque and fades where it's transparent (a transparent-token stop = fully\n * clear). The material-under-gradient-scrim / scroll-fade-out idiom. Mirrors iOS\n * `.mask()`, Android `BlendMode.DstIn`, web `mask-image`. */\n mask?: Gradient;\n /** Inline border/stroke. */\n border?: Border;\n /** Subtree alpha 0.0–1.0; absent = opaque. */\n opacity?: number;\n /** #433 — constrain the subtree to a width:height ratio (e.g. `1` = square,\n * `1.777` = 16:9). Width-driven: the height derives from the resolved width.\n * Lets a data-bound Grid cell / photo tile hold its shape at any width. */\n aspectRatio?: number;\n /** Names a token from the app's `motions` registry; the subtree\n * fades + slides up on first appearance. */\n appear?: TokenRef;\n /** Surface material token (flat/glass/frosted). */\n material?: TokenRef;\n /** Per-node construction-language override. */\n variant?: string;\n /** Per-node density / motion override. */\n density?: string;\n motion?: string;\n /** Motion grammar (Batch 1) — press/tap feedback style\n * (auto|none|scale|highlight|scaleHighlight|lift); timing comes from `motion`.\n * Applied only when the node is interactive. Unknown degrades to `auto`. */\n pressFeedback?: string;\n /** Motion grammar (Batch 1) — press-coupled haptic\n * (none|selection|light|medium|heavy|success|warning|error); fires with onTap.\n * Default none (opt-in). Web is a no-op. */\n haptic?: string;\n /** Motion grammar (Batch 1) — animate this container's size changes instead of\n * snapping (iOS `.animation(value:)`, Android `animateContentSize`, web height\n * transition). Curve from the active `motion` personality. */\n animateContentSize?: boolean;\n /** Motion grammar (Batch 1) — enter/exit transition; supersedes `appear` when\n * both are present. */\n transition?: Transition;\n /** Motion grammar (Batch 2, scroll-driven) — defer the `transition` enter until\n * the node first crosses into the scroll viewport (reveal-on-scroll). Gated by\n * reduced motion (shown immediately when reduced). */\n revealOnScroll?: boolean;\n /** Motion grammar (Batch 2, scroll-driven) — parallax factor: the node drifts\n * vertically at this fraction of the scroll delta as it moves through the\n * viewport. Gated by reduced motion. Generalizes CollapsingHeader parallaxFactor. */\n parallax?: number;\n /** Motion grammar (Batch 4, rich visual) — when a backgroundGradient is present,\n * continuously drift/rotate it (animated gradient). Gated by reduced motion. */\n animatedGradient?: boolean;\n /** Motion grammar (Batch 3, hero/nav) — shared-element key: a matching\n * transitionId on the source + destination screen morphs that element across\n * navigation (web View Transitions / iOS matchedTransitionSource / Android\n * SharedTransitionLayout). Gated by reduced motion. */\n transitionId?: string;\n /** Motion grammar (Batch 4, rich visual) — render the backgroundGradient as a\n * mesh (overlapping multi-point blend) rather than a linear sweep; combine with\n * animatedGradient to drift it. */\n meshGradient?: boolean;\n /** Motion grammar (Batch 4, celebration) — burst a one-shot confetti overlay\n * within the node on appear. Gated by reduced motion. */\n confetti?: boolean;\n /** Motion grammar (Batch 2, scroll-driven) — pin this node to the top of its\n * scroll container (sticky section header). Functional positioning; not gated\n * by reduced motion. */\n sticky?: boolean;\n /** Motion grammar (Batch 2, scroll-driven) — turn this thin node into a\n * reading-progress bar that fills left→right with scroll (scaleX). web reads\n * window scroll; native reads the host scroll fraction. Functional. */\n scrollProgress?: boolean;\n /** Motion grammar (Batch 4, rich vector) — a named built-in vector animation\n * (\"loader\" | \"success\" | \"pulse\"), dependency-free. Gated by reduced motion.\n * Arbitrary Lottie/Rive JSON is the lib-backed superset follow-up. */\n vectorAnim?: string;\n /** Motion grammar (Batch 1, choreography) — delay this node's ENTER transition\n * by N ms so a screen can SEQUENCE its entrance beyond list stagger (\"title\n * fades, subtitle slides in 100ms later\"). Adds to any list `staggerMs`. Gated\n * by reduced motion. 0/absent = no extra delay. */\n enterDelayMs?: number;\n /** Motion grammar (Phase-3, per-node MotionSpec) — overrides the PRESS-feedback\n * curve with a `motions`-registry token, mirroring `Transition.motion` for the\n * enter curve. Absent → press uses the global `motion` personality. */\n pressMotion?: TokenRef;\n /** Motion grammar (Phase-3, gesture-driven) — finger-tracked drag-to-dismiss; past\n * threshold along `direction` it animates off and fires `action`, else springs back. */\n swipeDismiss?: SwipeDismiss;\n /** Motion grammar (Phase-4, reduced-motion) — per-node escape from the reduced-motion\n * gate. Default true (respect). false = \"must move anyway\" (transition/press animate\n * even when reduced motion is on). */\n respectReducedMotion?: boolean;\n /** #1052 — uniform scale (1 = no change). */\n scale?: number;\n /** #1052 — rotation in DEGREES. */\n rotation?: number;\n /** #1052 — stacking order within a ZStack/overlay. */\n zIndex?: number;\n /** #1052 — gaussian blur radius (pt/dp/px). */\n blur?: number;\n /** #1052 — `false` = decorative (ignores taps, unlike `hidden` which stays hit-testable);\n * `true` = force hit-testable (a clear tap target). */\n hitTestable?: boolean;\n /** #1052 — explicit drop shadow (colour + radius + offset), for upward / multi-layer\n * shadows the `shadow` token can't express. */\n boxShadow?: Shadow;\n}",
1255
+ "Modifiers": "export interface Modifiers {\n padding?: EdgeInsets;\n background?: TokenRef;\n cornerRadius?: number;\n /** Fixed intrinsic width (pt/dp/px). Honored by iOS (`FrameIfPresent`), Android\n * (frame) and web (`modifiers.ts`). With no `ScrollView` in the vocabulary and\n * regions clipped at `maxHeight`, `width`/`height`/`fillMaxWidth` are the main\n * sizing tools for embed/host content. */\n width?: number;\n /** Fixed intrinsic height (pt/dp/px). See `width`. */\n height?: number;\n /** Fill the available cross-axis width (keeps any fixed `height`). Mirrors\n * Compose `fillMaxWidth` / SwiftUI `.frame(maxWidth: .infinity)`. See `width`. */\n fillMaxWidth?: boolean;\n /** CAP the node's laid-out width at this many points. It CONSTRAINS, it\n * does not SET: a node narrower than the cap keeps its intrinsic width, so a\n * short chat bubble stays short while a long one stops at the cap — the\n * asymmetry `width` cannot express and `weight` cannot either (weights divide\n * the *available* space, so every child lands on the same fraction).\n * The cap covers the node's TOTAL width, padding + border included (CSS\n * `max-width` under `box-sizing: border-box`). `fillMaxWidth` grabs the axis but\n * THE CAP WINS — fill + cap is exactly `maxWidth` wide; a literal `width` larger\n * than the cap is clamped to it too. POINTS, not a fraction: a modifier is\n * resolved with no portable notion of \"the row\" on any renderer — compose a\n * percentage from a weighted sibling gap instead. `0`/absent = no cap. */\n maxWidth?: number;\n /** Flex weight along the parent stack's MAIN axis (HStack = width, VStack =\n * height). The stack measures its intrinsic (unweighted) children first, then\n * divides what is LEFT among the weighted ones in proportion to their weights:\n * two children at `1` split the leftover equally, `2` and `1` split it 2:1.\n * Absent (or <= 0) keeps the node at its intrinsic size. Use this — not\n * `fillMaxWidth` — when a node should claim the remaining space *beside* its\n * siblings; a full-bleed fill grabs the whole axis and starves them. Ignored\n * outside an HStack/VStack.\n * CAVEAT — row-sharing controls have NO intrinsic width to measure first: in an\n * HStack a `Button` and a `Toggle` are implicitly weighted, so they DIVIDE the\n * row with your weighted child instead of taking a width ahead of it —\n * `[VStack(weight:1), Button, Button]` is a three-way split, not \"the VStack gets\n * the remainder\". To opt a Button out on EVERY renderer set `fullWidth: false`\n * (an explicit flag wins in both directions; absent falls back to the style\n * default, which is \"share\" for every style but `tertiary`/`link`), or use\n * `iconOnly`. Otherwise give the actions their own row.\n * See `himi docs designing-himi-screens` §4. */\n weight?: number;\n /** RTL — pin the subtree's writing direction: `\"ltr\"` | `\"rtl\"` | `\"locale\"`\n * (inherit). The common RTL pattern is the inverse of `Text.textDirection`: pin a\n * container `ltr` (a logotype, an attachment-tile row, a composer button row)\n * inside an otherwise-RTL screen. Absent = inherit the ambient direction. #1060. */\n layoutDirection?: \"ltr\" | \"rtl\" | \"locale\";\n /** Absolute visual translation (pt/dp/px). Applied as the OUTERMOST transform so\n * it shifts the fully-styled node WITHOUT changing its layout footprint — lets a\n * ZStack place polar/absolute children (e.g. AgentOrbit). Negative allowed. */\n offsetX?: number;\n offsetY?: number;\n onTap?: Action;\n /** Double-tap gesture (e.g. Instagram double-tap-to-like). Distinct from onTap. */\n onDoubleTap?: Action;\n accessibilityLabel?: string;\n hidden?: boolean;\n /** Data-bound conditional visibility: a `\"$service:<svc>.<field>\"`\n * or `\"$item.<field>\"` sentinel resolving to a bool at render; when true the\n * subtree fully COLLAPSES (zero layout, like `hidden`), overriding the\n * literal `hidden`. */\n hiddenPath?: string;\n /** Long-press gesture modifier. */\n onLongPress?: Action;\n /** Fires once when the component first appears on screen. */\n onAppear?: Action;\n /** Scoped token overrides for the subtree. */\n theme?: ThemeOverrides;\n /** Names a token from the app's `shadows` registry. */\n shadow?: TokenRef;\n /** Names a token from the app's `gradients` registry (background fill). */\n backgroundGradient?: TokenRef;\n /** Value-driven gradient: a `$service:`/`$item.` path resolving to a gradient\n * TOKEN NAME used as the live background (e.g. weather sky day→night). */\n backgroundGradientPath?: string;\n /** Value-driven background COLOR: a `$service:`/`$item.` path resolving to a\n * color TOKEN NAME used as the live background. Honored on iOS + Android\n * (`BoundBackgroundIfPresent`); web render support is tracked in #1057. */\n backgroundColorPath?: string;\n /** Value-driven background IMAGE: a `$service:`/`$item.` path resolving to an\n * image URL used as the live background. Honored on iOS + Android; web render\n * support is tracked in #1057. */\n backgroundImagePath?: string;\n /** #1051 — MEASURE (publish): report this node's laid-out size into the renderer's\n * self-contained (worker-free) measure store under the named keys, so a SIBLING can\n * feed the number back into its own layout via a `$measure:<key>` binding. The SDUI\n * equivalent of SwiftUI `.onGeometryChange` / Compose `onGloballyPositioned`; the\n * canonical use is a pinned bottom bar whose measured height becomes a scroll view's\n * bottom inset (sticky-footer + scroll-inset, unrepresentable before — #1051). */\n onMeasure?: Measure;\n /** #1051 — bound height: a `\"$measure:<key>\"` (measure store) or\n * `\"$service:<svc>.<field>\"` / `\"$item.<field>\"` (worker/row state) sentinel resolving\n * to a NUMBER at render. Wins over the literal `height`. String path only, so a\n * binding can never blank the node (never overload the numeric `height`). */\n heightPath?: string;\n /** #1051 — bound width. See `heightPath`. Wins over the literal `width`. */\n widthPath?: string;\n /** #1051 — bound TOP inset (the header-height → top-inset idiom). A `$measure:`/\n * `$service:`/`$item.` sentinel resolving to a number; added as top padding. */\n paddingTopPath?: string;\n /** #1051 — bound BOTTOM inset (the sticky-footer-height → bottom-inset idiom). A\n * `$measure:`/`$service:`/`$item.` sentinel resolving to a number; added as bottom\n * padding so the last scroll row clears a pinned bottom bar. */\n paddingBottomPath?: string;\n /** #1053 — INLINE gradient background paint. Distinct from `backgroundGradient`\n * (which names a `gradients` registry token an embed host can't register): this\n * carries its stops/colours in the descriptor, so it works anywhere. Painted as\n * a surface (behind children), below `material`/`backgroundGradient` if those are\n * also set. */\n gradient?: Gradient;\n /** #1053 — gradient ALPHA MASK: the node shows through where the gradient is\n * opaque and fades where it's transparent (a transparent-token stop = fully\n * clear). The material-under-gradient-scrim / scroll-fade-out idiom. Mirrors iOS\n * `.mask()`, Android `BlendMode.DstIn`, web `mask-image`. */\n mask?: Gradient;\n /** Inline border/stroke. */\n border?: Border;\n /** Subtree alpha 0.0–1.0; absent = opaque. */\n opacity?: number;\n /** #433 — constrain the subtree to a width:height ratio (e.g. `1` = square,\n * `1.777` = 16:9). Width-driven: the height derives from the resolved width.\n * Lets a data-bound Grid cell / photo tile hold its shape at any width. */\n aspectRatio?: number;\n /** Names a token from the app's `motions` registry; the subtree\n * fades + slides up on first appearance. */\n appear?: TokenRef;\n /** Surface material token (flat/glass/frosted). */\n material?: TokenRef;\n /** Per-node construction-language override. */\n variant?: string;\n /** Per-node density / motion override. */\n density?: string;\n motion?: string;\n /** Motion grammar (Batch 1) — press/tap feedback style\n * (auto|none|scale|highlight|scaleHighlight|lift); timing comes from `motion`.\n * Applied only when the node is interactive. Unknown degrades to `auto`. */\n pressFeedback?: string;\n /** Motion grammar (Batch 1) — press-coupled haptic\n * (none|selection|light|medium|heavy|success|warning|error); fires with onTap.\n * Default none (opt-in). Web is a no-op. */\n haptic?: string;\n /** Motion grammar (Batch 1) — animate this container's size changes instead of\n * snapping (iOS `.animation(value:)`, Android `animateContentSize`, web height\n * transition). Curve from the active `motion` personality. */\n animateContentSize?: boolean;\n /** Motion grammar (Batch 1) — enter/exit transition; supersedes `appear` when\n * both are present. */\n transition?: Transition;\n /** Motion grammar (Batch 2, scroll-driven) — defer the `transition` enter until\n * the node first crosses into the scroll viewport (reveal-on-scroll). Gated by\n * reduced motion (shown immediately when reduced). */\n revealOnScroll?: boolean;\n /** Motion grammar (Batch 2, scroll-driven) — parallax factor: the node drifts\n * vertically at this fraction of the scroll delta as it moves through the\n * viewport. Gated by reduced motion. Generalizes CollapsingHeader parallaxFactor. */\n parallax?: number;\n /** Motion grammar (Batch 4, rich visual) — when a backgroundGradient is present,\n * continuously drift/rotate it (animated gradient). Gated by reduced motion. */\n animatedGradient?: boolean;\n /** Motion grammar (Batch 3, hero/nav) — shared-element key: a matching\n * transitionId on the source + destination screen morphs that element across\n * navigation (web View Transitions / iOS matchedTransitionSource / Android\n * SharedTransitionLayout). Gated by reduced motion. */\n transitionId?: string;\n /** Motion grammar (Batch 4, rich visual) — render the backgroundGradient as a\n * mesh (overlapping multi-point blend) rather than a linear sweep; combine with\n * animatedGradient to drift it. */\n meshGradient?: boolean;\n /** Motion grammar (Batch 4, celebration) — burst a one-shot confetti overlay\n * within the node on appear. Gated by reduced motion. */\n confetti?: boolean;\n /** Motion grammar (Batch 2, scroll-driven) — pin this node to the top of its\n * scroll container (sticky section header). Functional positioning; not gated\n * by reduced motion. */\n sticky?: boolean;\n /** Motion grammar (Batch 2, scroll-driven) — turn this thin node into a\n * reading-progress bar that fills left→right with scroll (scaleX). web reads\n * window scroll; native reads the host scroll fraction. Functional. */\n scrollProgress?: boolean;\n /** Motion grammar (Batch 4, rich vector) — a named built-in vector animation\n * (\"loader\" | \"success\" | \"pulse\"), dependency-free. Gated by reduced motion.\n * Arbitrary Lottie/Rive JSON is the lib-backed superset follow-up. */\n vectorAnim?: string;\n /** Motion grammar (Batch 1, choreography) — delay this node's ENTER transition\n * by N ms so a screen can SEQUENCE its entrance beyond list stagger (\"title\n * fades, subtitle slides in 100ms later\"). Adds to any list `staggerMs`. Gated\n * by reduced motion. 0/absent = no extra delay. */\n enterDelayMs?: number;\n /** Motion grammar (Phase-3, per-node MotionSpec) — overrides the PRESS-feedback\n * curve with a `motions`-registry token, mirroring `Transition.motion` for the\n * enter curve. Absent → press uses the global `motion` personality. */\n pressMotion?: TokenRef;\n /** Motion grammar (Phase-3, gesture-driven) — finger-tracked drag-to-dismiss; past\n * threshold along `direction` it animates off and fires `action`, else springs back. */\n swipeDismiss?: SwipeDismiss;\n /** Motion grammar (Phase-4, reduced-motion) — per-node escape from the reduced-motion\n * gate. Default true (respect). false = \"must move anyway\" (transition/press animate\n * even when reduced motion is on). */\n respectReducedMotion?: boolean;\n /** #1052 — uniform scale (1 = no change). */\n scale?: number;\n /** #1052 — rotation in DEGREES. */\n rotation?: number;\n /** #1052 — stacking order within a ZStack/overlay. */\n zIndex?: number;\n /** #1052 — gaussian blur radius (pt/dp/px). */\n blur?: number;\n /** #1052 — `false` = decorative (ignores taps, unlike `hidden` which stays hit-testable);\n * `true` = force hit-testable (a clear tap target). */\n hitTestable?: boolean;\n /** #1052 — explicit drop shadow (colour + radius + offset), for upward / multi-layer\n * shadows the `shadow` token can't express. */\n boxShadow?: Shadow;\n /** The role assistive technology should announce. Absent = the\n * renderer INFERS it from the component type, so an author never restates the\n * obvious. `\"decorative\"` is node-local: the node itself stops being an\n * accessibility element (no role, not a stop) while its children stay\n * traversable — the ARIA `role=\"presentation\"` semantic. To remove the subtree\n * too, use `accessibilityHidden`. */\n accessibilityRole?: AccessibilityRole;\n /** The supplementary \"what happens if I act\" phrase\n * (iOS `accessibilityHint`, Android `onClickLabel`, ARIA `aria-describedby`).\n * Never a substitute for `accessibilityLabel`: AT may not speak a hint at all. */\n accessibilityHint?: string;\n /** Remove this node AND its subtree from the accessibility tree.\n * Distinct from `hidden` (which removes it visually too) and from the\n * `\"decorative\"` role (node-local; children stay reachable). */\n accessibilityHidden?: boolean;\n /** Mark the node as a heading at level 1–6, for rotor / heading\n * navigation. Implies the `\"heading\"` role when `accessibilityRole` is absent.\n * The LEVEL reaches iOS and web only: Android (Compose `heading()`) and React Native\n * (`accessibilityRole: \"header\"`) have no level primitive and mark a plain heading. */\n headingLevel?: number;\n /** An explicit SPOKEN value where the visual and spoken values\n * differ (a rating \"3 of 5\", a gauge \"72 percent\"). Where the value is derivable\n * from a prop the renderer computes it and this override is unnecessary. */\n accessibilityValue?: string;\n /** Additive state that a single role cannot carry. A node may\n * hold several. */\n accessibilityTraits?: AccessibilityTrait[];\n}",
1256
1256
  "TokenRef": "export interface TokenRef {\n token: TokenName;\n}",
1257
1257
  "Action": "export interface Action {\n actionId: string;\n /** Backwards-compatible string-only params. Prefer `args` for typed payloads. */\n params?: Record<string, string>;\n /** Typed action args; nested objects/arrays preserved end-to-end. */\n args?: Record<string, Value>;\n /** Navigation hint for the router; ignored for non-nav actions. */\n presentation?: Presentation;\n}",
1258
1258
  "Presentation": "export type Presentation =\n | \"push\"\n | \"sheet\"\n | \"replace\"\n | \"dismiss\"\n | \"back\"\n | \"full_screen\"\n | \"alert\"\n | \"snackbar\";",
@@ -470,6 +470,11 @@ export interface IconProps {
470
470
  name: IconRef;
471
471
  size?: number;
472
472
  tint?: TokenName;
473
+ /** The spoken name for a MEANINGFUL icon, mirroring
474
+ * `ImageProps.contentDescription`. Absent (and with no enclosing labelled
475
+ * control) the icon is decorative and stays OUT of the accessibility tree,
476
+ * rather than being announced by its raw glyph name. */
477
+ contentDescription?: string;
473
478
  }
474
479
  ```
475
480
 
@@ -2412,6 +2417,33 @@ export interface Modifiers {
2412
2417
  /** #1052 — explicit drop shadow (colour + radius + offset), for upward / multi-layer
2413
2418
  * shadows the `shadow` token can't express. */
2414
2419
  boxShadow?: Shadow;
2420
+ /** The role assistive technology should announce. Absent = the
2421
+ * renderer INFERS it from the component type, so an author never restates the
2422
+ * obvious. `"decorative"` is node-local: the node itself stops being an
2423
+ * accessibility element (no role, not a stop) while its children stay
2424
+ * traversable — the ARIA `role="presentation"` semantic. To remove the subtree
2425
+ * too, use `accessibilityHidden`. */
2426
+ accessibilityRole?: AccessibilityRole;
2427
+ /** The supplementary "what happens if I act" phrase
2428
+ * (iOS `accessibilityHint`, Android `onClickLabel`, ARIA `aria-describedby`).
2429
+ * Never a substitute for `accessibilityLabel`: AT may not speak a hint at all. */
2430
+ accessibilityHint?: string;
2431
+ /** Remove this node AND its subtree from the accessibility tree.
2432
+ * Distinct from `hidden` (which removes it visually too) and from the
2433
+ * `"decorative"` role (node-local; children stay reachable). */
2434
+ accessibilityHidden?: boolean;
2435
+ /** Mark the node as a heading at level 1–6, for rotor / heading
2436
+ * navigation. Implies the `"heading"` role when `accessibilityRole` is absent.
2437
+ * The LEVEL reaches iOS and web only: Android (Compose `heading()`) and React Native
2438
+ * (`accessibilityRole: "header"`) have no level primitive and mark a plain heading. */
2439
+ headingLevel?: number;
2440
+ /** An explicit SPOKEN value where the visual and spoken values
2441
+ * differ (a rating "3 of 5", a gauge "72 percent"). Where the value is derivable
2442
+ * from a prop the renderer computes it and this override is unnecessary. */
2443
+ accessibilityValue?: string;
2444
+ /** Additive state that a single role cannot carry. A node may
2445
+ * hold several. */
2446
+ accessibilityTraits?: AccessibilityTrait[];
2415
2447
  }
2416
2448
 
2417
2449
  export interface TokenRef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1000.0",
3
+ "version": "0.1002.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -65,5 +65,5 @@
65
65
  "artifactId": "himalaya-cli"
66
66
  }
67
67
  },
68
- "falconPackageHash": "519641ffda704c66d21d2a3cde931411fcd450f37795d9941d1cb0a9"
68
+ "falconPackageHash": "5a9b766fa76e2de153929228ace6b0de2998997678b1c84616bfd779"
69
69
  }
@@ -42,7 +42,7 @@ import {
42
42
  type ConversationSummary,
43
43
  } from "@himalaya/wix-genie-chat";
44
44
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
45
- import { isWebUrl } from "./aria-widget-model.js";
45
+ import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
46
46
  import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
47
47
  from "./aria-widget-view.js";
48
48
  import { substituteSelection } from "./aria-widget-model.js";
@@ -528,36 +528,16 @@ export function disclosedLabel(link: { label: string; url: string }): string {
528
528
  return `${link.label} (${host || UNKNOWN_HOST_LABEL})`;
529
529
  }
530
530
 
531
- /** Aria replies come back as markdown. There is no native markdown renderer
532
- * (adding one is a CI-blocking parity change across both renderers), so
533
- * normalize to clean plain text on-device: drop code fences, unwrap
534
- * links/emphasis/headings, turn bullet markers into '•', collapse blank runs.
535
- * Safe and idempotent. */
536
- export function stripMarkdown(src: string): string {
537
- if (!src) return "";
538
- let t = src;
539
- // fenced code blocks -> keep inner text, drop the ``` fences
540
- t = t.replace(/```[a-zA-Z0-9]*\n?/g, "").replace(/```/g, "");
541
- // inline code `x` -> x
542
- t = t.replace(/`([^`]+)`/g, "$1");
543
- // images ![alt](url) -> alt ; links [text](url) -> text
544
- t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
545
- t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
546
- // bold/italic **x** *x* __x__ _x_ -> x
547
- t = t.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1");
548
- t = t.replace(/__([^_]+)__/g, "$1").replace(/_([^_]+)_/g, "$1");
549
- // ATX headings: '### Title' -> 'Title'
550
- t = t.replace(/^#{1,6}\s+/gm, "");
551
- // blockquotes '> ' -> ''
552
- t = t.replace(/^>\s?/gm, "");
553
- // unordered list markers '- ' / '* ' / '+ ' -> bullet
554
- t = t.replace(/^\s*[-*+]\s+/gm, "• ");
555
- // horizontal rules
556
- t = t.replace(/^\s*([-*_])\1{2,}\s*$/gm, "");
557
- // collapse 3+ newlines to a double
558
- t = t.replace(/\n{3,}/g, "\n\n");
559
- return t.trim();
560
- }
531
+ /** `stripMarkdown` used to live here. It moved to `aria-widget-model.ts` —
532
+ * which imports NOTHING, so the widget view can reach it without closing a
533
+ * cycle back through this file. Re-exported rather than relocated silently,
534
+ * so every consumer and test that names `aria-chat.ts` still resolves.
535
+ *
536
+ * Imported AND re-exported, and that is not redundant: `export { x } from "y"`
537
+ * does not bind `x` in this module's scope, and `toBubbles` below calls it —
538
+ * written that way the identifier is unbound at runtime and the whole turn
539
+ * machinery dies. (Measured: 20 tests, every one of them about polling.) */
540
+ export { stripMarkdown };
561
541
 
562
542
  /** A greeting or optimistic bubble: no links, all slots defaulted. */
563
543
  export function plainBubble(id: string, role: string, who: string, content: string): AriaBubble {
@@ -167,6 +167,37 @@ export function objectAt(v: unknown): Record<string, unknown> | null {
167
167
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
168
168
  }
169
169
 
170
+ /** Aria replies come back as markdown. There is no native markdown renderer
171
+ * (adding one is a CI-blocking parity change across both renderers), so
172
+ * normalize to clean plain text on-device: drop code fences, unwrap
173
+ * links/emphasis/headings, turn bullet markers into '•', collapse blank runs.
174
+ * Safe and idempotent. */
175
+ export function stripMarkdown(src: string): string {
176
+ if (!src) return "";
177
+ let t = src;
178
+ // fenced code blocks -> keep inner text, drop the ``` fences
179
+ t = t.replace(/```[a-zA-Z0-9]*\n?/g, "").replace(/```/g, "");
180
+ // inline code `x` -> x
181
+ t = t.replace(/`([^`]+)`/g, "$1");
182
+ // images ![alt](url) -> alt ; links [text](url) -> text
183
+ t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
184
+ t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
185
+ // bold/italic **x** *x* __x__ _x_ -> x
186
+ t = t.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1");
187
+ t = t.replace(/__([^_]+)__/g, "$1").replace(/_([^_]+)_/g, "$1");
188
+ // ATX headings: '### Title' -> 'Title'
189
+ t = t.replace(/^#{1,6}\s+/gm, "");
190
+ // blockquotes '> ' -> ''
191
+ t = t.replace(/^>\s?/gm, "");
192
+ // unordered list markers '- ' / '* ' / '+ ' -> bullet
193
+ t = t.replace(/^\s*[-*+]\s+/gm, "• ");
194
+ // horizontal rules
195
+ t = t.replace(/^\s*([-*_])\1{2,}\s*$/gm, "");
196
+ // collapse 3+ newlines to a double
197
+ t = t.replace(/\n{3,}/g, "\n\n");
198
+ return t.trim();
199
+ }
200
+
170
201
  /** A display string, whether the wire wrote it bare or wrapped.
171
202
  *
172
203
  * QUESTIONNAIRE writes every label as `{ text: "…" }` — question titles,
@@ -13,6 +13,7 @@
13
13
  // the transcript bubbles already use for their three link slots.
14
14
 
15
15
  import type { NormalizedWidget, WidgetKind } from "./aria-widget-model.js";
16
+ import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
16
17
  import { composeGenericCard } from "./aria-generic-widget.js";
17
18
  import { normalizeWidget } from "./aria-widget-kinds.js";
18
19
  import { scanEnvelopes, widgetsOfParts } from "./aria-widgets.js";
@@ -333,7 +334,22 @@ export const NO_WIDGET: WidgetView = {
333
334
  *
334
335
  * A button with no url is left alone: there is no destination to disclose. */
335
336
  function discloseButton(disclose: Disclose, label: string, url: string): string {
336
- return url ? disclose({ label, url }) : label;
337
+ const text = stripMarkdown(label);
338
+ return url ? disclose({ label: text, url }) : text;
339
+ }
340
+
341
+ /** A destination this region is willing to EXPOSE.
342
+ *
343
+ * `isWebUrl` guards the sink in `aria-chat.ts`, which stops a non-web scheme
344
+ * being opened. It does not stop it being PAINTED: a button whose url is
345
+ * `tel:` still has a label and a non-empty url, so it renders, and the owner
346
+ * taps a control that silently does nothing. Asking the same question here
347
+ * collapses the button instead — which is this screen's own rule, and the
348
+ * reason the two checks are not redundant. Same predicate, so they cannot
349
+ * drift into disagreeing about what a web url is. */
350
+ function webOnly(url: string | undefined): string {
351
+ const t = (url ?? "").trim();
352
+ return isWebUrl(t) ? t : "";
337
353
  }
338
354
 
339
355
  /** One option, flattened. `selected` is passed in because it is worker state
@@ -451,7 +467,12 @@ export function viewOf(
451
467
  allowPicture: (url: string) => boolean = (url) => isDisclosedSame(disclose, url),
452
468
  ): WidgetView {
453
469
  const extra = w.extra as Record<string, unknown>;
454
- const title = typeof extra.title === "string" ? extra.title : "";
470
+ // Markdown, flattened. Aria replies are markdown off the wire and there is no
471
+ // native markdown renderer, so a marker reaching a `Text` is painted
472
+ // literally — the catalogue sends `Upgrade to **Business Basic**`, and the
473
+ // card showed the asterisks while the bubble beside it did not. Same
474
+ // `stripMarkdown` the transcript uses, so the two cannot disagree.
475
+ const title = stripMarkdown(typeof extra.title === "string" ? extra.title : "");
455
476
  // Through the SAME media check as an option's picture. Counting `imageUrl`
456
477
  // towards renderability made an image-only VISUAL_DISPLAY reachable, and the
457
478
  // descriptor binds this path straight into an `Image` — so an off-Wix url
@@ -464,13 +485,13 @@ export function viewOf(
464
485
 
465
486
  const sections = Array.isArray(extra.sections) ? (extra.sections as Record<string, unknown>[]) : [];
466
487
  const rows: WidgetRow[] = sections.map((sec, i) => {
467
- const subtitle = typeof sec.subtitle === "string" ? sec.subtitle : "";
488
+ const subtitle = stripMarkdown(typeof sec.subtitle === "string" ? sec.subtitle : "");
468
489
  return {
469
490
  // A stable id per row. Index-based on purpose: a section has no id of its
470
491
  // own, and the list is rebuilt wholesale on every tick, so there is
471
492
  // nothing for a content-derived key to preserve.
472
493
  id: `s${i}`,
473
- title: typeof sec.title === "string" ? sec.title : "",
494
+ title: stripMarkdown(typeof sec.title === "string" ? sec.title : ""),
474
495
  subtitle,
475
496
  subtitleHidden: !subtitle,
476
497
  };
@@ -540,8 +561,11 @@ export function viewOf(
540
561
 
541
562
  const providerKey = typeof extra.providerKey === "string" ? extra.providerKey : "";
542
563
 
543
- const primaryUrl = w.primary?.externalUrl ?? "";
544
- const secondaryUrl = w.secondary?.externalUrl ?? "";
564
+ const primaryUrl = webOnly(w.primary?.externalUrl);
565
+ const secondaryUrl = webOnly(w.secondary?.externalUrl);
566
+ const prose = stripMarkdown(w.text);
567
+ const bottom = stripMarkdown(w.bottomText);
568
+ const badgeText = stripMarkdown(w.badge);
545
569
  // An UNKNOWN kind is the one case where a MISSING `respondText` must not
546
570
  // make a control inert.
547
571
  //
@@ -563,6 +587,15 @@ export function viewOf(
563
587
  || (generic ? (w.secondary?.label ?? "") : "");
564
588
  const primaryLabel = w.primary ? discloseButton(disclose, w.primary.label, primaryUrl) : "";
565
589
  const secondaryLabel = w.secondary ? discloseButton(disclose, w.secondary.label, secondaryUrl) : "";
590
+ // Whether each footer button is actually SHOWN — a label to read AND
591
+ // something to do (a web destination, or text to send). Defined once and
592
+ // used twice, by the `*Hidden` flags and by `renderable` below, because the
593
+ // two disagreeing is exactly the blank-card bug: `renderable` counted a bare
594
+ // label, so a widget whose only content was a button `webOnly` had just
595
+ // stripped (a `tel:` link with no `respondText`) stayed visible as an empty
596
+ // card with nothing inside it.
597
+ const primaryShown = !!(primaryLabel && (primaryUrl || primaryRespondText));
598
+ const secondaryShown = !!(secondaryLabel && (secondaryUrl || secondaryRespondText));
566
599
 
567
600
  // Hoisted out of the object literal below so `renderable` can see it. It
568
601
  // was computed inline, so naming it in the guard was a ReferenceError that
@@ -587,8 +620,10 @@ export function viewOf(
587
620
  // though they had something to show. Hiding a widget that has content is a
588
621
  // worse failure than the blank card this guard exists to prevent, so the
589
622
  // list has to match what the descriptor renders, not what I remembered.
590
- const renderable = !!(w.text || w.bottomText || w.badge || title || imageUrl
591
- || description || options.length || primaryLabel || secondaryLabel
623
+ // The STRIPPED prose, not the raw fields: those are what the card draws, and
624
+ // a reply of pure markup (`***`, an image-only `![](…)`) strips to nothing.
625
+ const renderable = !!(prose || bottom || badgeText || title || imageUrl
626
+ || description || options.length || primaryShown || secondaryShown
592
627
  || sections.length || slices.length || steps.length || questions.length
593
628
  || asyncMessage || treeJson);
594
629
 
@@ -651,10 +686,10 @@ export function viewOf(
651
686
  Object.entries(msgs).filter((kv): kv is [string, string] => typeof kv[1] === "string"),
652
687
  ),
653
688
  submitting: false,
654
- text: w.text, textHidden: !w.text,
689
+ text: prose, textHidden: !prose,
655
690
  title, titleHidden: !title,
656
- bottomText: w.bottomText, bottomTextHidden: !w.bottomText,
657
- badge: w.badge, badgeHidden: !w.badge,
691
+ bottomText: bottom, bottomTextHidden: !bottom,
692
+ badge: badgeText, badgeHidden: !badgeText,
658
693
  imageUrl, imageHidden: !imageUrl,
659
694
  // Shown only when there is a LABEL to read and a DESTINATION to go to.
660
695
  //
@@ -671,9 +706,9 @@ export function viewOf(
671
706
  // #2605 hid a button that had nothing to DO. Now that `respondText` can be
672
707
  // sent, having something to SAY counts too — which is the `&& url` that
673
708
  // issue said this one would remove.
674
- primaryLabel, primaryHidden: !(primaryLabel && (primaryUrl || primaryRespondText)), primaryUrl,
709
+ primaryLabel, primaryHidden: !primaryShown, primaryUrl,
675
710
  primaryRespondText,
676
- secondaryLabel, secondaryHidden: !(secondaryLabel && (secondaryUrl || secondaryRespondText)), secondaryUrl,
711
+ secondaryLabel, secondaryHidden: !secondaryShown, secondaryUrl,
677
712
  secondaryRespondText,
678
713
  rows,
679
714
  };
@@ -42,7 +42,7 @@ import {
42
42
  type ConversationSummary,
43
43
  } from "@himalaya/wix-genie-chat";
44
44
  import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
45
- import { isWebUrl } from "./aria-widget-model.js";
45
+ import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
46
46
  import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
47
47
  from "./aria-widget-view.js";
48
48
  import { substituteSelection } from "./aria-widget-model.js";
@@ -528,36 +528,16 @@ export function disclosedLabel(link: { label: string; url: string }): string {
528
528
  return `${link.label} (${host || UNKNOWN_HOST_LABEL})`;
529
529
  }
530
530
 
531
- /** Aria replies come back as markdown. There is no native markdown renderer
532
- * (adding one is a CI-blocking parity change across both renderers), so
533
- * normalize to clean plain text on-device: drop code fences, unwrap
534
- * links/emphasis/headings, turn bullet markers into '•', collapse blank runs.
535
- * Safe and idempotent. */
536
- export function stripMarkdown(src: string): string {
537
- if (!src) return "";
538
- let t = src;
539
- // fenced code blocks -> keep inner text, drop the ``` fences
540
- t = t.replace(/```[a-zA-Z0-9]*\n?/g, "").replace(/```/g, "");
541
- // inline code `x` -> x
542
- t = t.replace(/`([^`]+)`/g, "$1");
543
- // images ![alt](url) -> alt ; links [text](url) -> text
544
- t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
545
- t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
546
- // bold/italic **x** *x* __x__ _x_ -> x
547
- t = t.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1");
548
- t = t.replace(/__([^_]+)__/g, "$1").replace(/_([^_]+)_/g, "$1");
549
- // ATX headings: '### Title' -> 'Title'
550
- t = t.replace(/^#{1,6}\s+/gm, "");
551
- // blockquotes '> ' -> ''
552
- t = t.replace(/^>\s?/gm, "");
553
- // unordered list markers '- ' / '* ' / '+ ' -> bullet
554
- t = t.replace(/^\s*[-*+]\s+/gm, "• ");
555
- // horizontal rules
556
- t = t.replace(/^\s*([-*_])\1{2,}\s*$/gm, "");
557
- // collapse 3+ newlines to a double
558
- t = t.replace(/\n{3,}/g, "\n\n");
559
- return t.trim();
560
- }
531
+ /** `stripMarkdown` used to live here. It moved to `aria-widget-model.ts` —
532
+ * which imports NOTHING, so the widget view can reach it without closing a
533
+ * cycle back through this file. Re-exported rather than relocated silently,
534
+ * so every consumer and test that names `aria-chat.ts` still resolves.
535
+ *
536
+ * Imported AND re-exported, and that is not redundant: `export { x } from "y"`
537
+ * does not bind `x` in this module's scope, and `toBubbles` below calls it —
538
+ * written that way the identifier is unbound at runtime and the whole turn
539
+ * machinery dies. (Measured: 20 tests, every one of them about polling.) */
540
+ export { stripMarkdown };
561
541
 
562
542
  /** A greeting or optimistic bubble: no links, all slots defaulted. */
563
543
  export function plainBubble(id: string, role: string, who: string, content: string): AriaBubble {
@@ -167,6 +167,37 @@ export function objectAt(v: unknown): Record<string, unknown> | null {
167
167
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
168
168
  }
169
169
 
170
+ /** Aria replies come back as markdown. There is no native markdown renderer
171
+ * (adding one is a CI-blocking parity change across both renderers), so
172
+ * normalize to clean plain text on-device: drop code fences, unwrap
173
+ * links/emphasis/headings, turn bullet markers into '•', collapse blank runs.
174
+ * Safe and idempotent. */
175
+ export function stripMarkdown(src: string): string {
176
+ if (!src) return "";
177
+ let t = src;
178
+ // fenced code blocks -> keep inner text, drop the ``` fences
179
+ t = t.replace(/```[a-zA-Z0-9]*\n?/g, "").replace(/```/g, "");
180
+ // inline code `x` -> x
181
+ t = t.replace(/`([^`]+)`/g, "$1");
182
+ // images ![alt](url) -> alt ; links [text](url) -> text
183
+ t = t.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
184
+ t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
185
+ // bold/italic **x** *x* __x__ _x_ -> x
186
+ t = t.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1");
187
+ t = t.replace(/__([^_]+)__/g, "$1").replace(/_([^_]+)_/g, "$1");
188
+ // ATX headings: '### Title' -> 'Title'
189
+ t = t.replace(/^#{1,6}\s+/gm, "");
190
+ // blockquotes '> ' -> ''
191
+ t = t.replace(/^>\s?/gm, "");
192
+ // unordered list markers '- ' / '* ' / '+ ' -> bullet
193
+ t = t.replace(/^\s*[-*+]\s+/gm, "• ");
194
+ // horizontal rules
195
+ t = t.replace(/^\s*([-*_])\1{2,}\s*$/gm, "");
196
+ // collapse 3+ newlines to a double
197
+ t = t.replace(/\n{3,}/g, "\n\n");
198
+ return t.trim();
199
+ }
200
+
170
201
  /** A display string, whether the wire wrote it bare or wrapped.
171
202
  *
172
203
  * QUESTIONNAIRE writes every label as `{ text: "…" }` — question titles,
@@ -13,6 +13,7 @@
13
13
  // the transcript bubbles already use for their three link slots.
14
14
 
15
15
  import type { NormalizedWidget, WidgetKind } from "./aria-widget-model.js";
16
+ import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
16
17
  import { composeGenericCard } from "./aria-generic-widget.js";
17
18
  import { normalizeWidget } from "./aria-widget-kinds.js";
18
19
  import { scanEnvelopes, widgetsOfParts } from "./aria-widgets.js";
@@ -333,7 +334,22 @@ export const NO_WIDGET: WidgetView = {
333
334
  *
334
335
  * A button with no url is left alone: there is no destination to disclose. */
335
336
  function discloseButton(disclose: Disclose, label: string, url: string): string {
336
- return url ? disclose({ label, url }) : label;
337
+ const text = stripMarkdown(label);
338
+ return url ? disclose({ label: text, url }) : text;
339
+ }
340
+
341
+ /** A destination this region is willing to EXPOSE.
342
+ *
343
+ * `isWebUrl` guards the sink in `aria-chat.ts`, which stops a non-web scheme
344
+ * being opened. It does not stop it being PAINTED: a button whose url is
345
+ * `tel:` still has a label and a non-empty url, so it renders, and the owner
346
+ * taps a control that silently does nothing. Asking the same question here
347
+ * collapses the button instead — which is this screen's own rule, and the
348
+ * reason the two checks are not redundant. Same predicate, so they cannot
349
+ * drift into disagreeing about what a web url is. */
350
+ function webOnly(url: string | undefined): string {
351
+ const t = (url ?? "").trim();
352
+ return isWebUrl(t) ? t : "";
337
353
  }
338
354
 
339
355
  /** One option, flattened. `selected` is passed in because it is worker state
@@ -451,7 +467,12 @@ export function viewOf(
451
467
  allowPicture: (url: string) => boolean = (url) => isDisclosedSame(disclose, url),
452
468
  ): WidgetView {
453
469
  const extra = w.extra as Record<string, unknown>;
454
- const title = typeof extra.title === "string" ? extra.title : "";
470
+ // Markdown, flattened. Aria replies are markdown off the wire and there is no
471
+ // native markdown renderer, so a marker reaching a `Text` is painted
472
+ // literally — the catalogue sends `Upgrade to **Business Basic**`, and the
473
+ // card showed the asterisks while the bubble beside it did not. Same
474
+ // `stripMarkdown` the transcript uses, so the two cannot disagree.
475
+ const title = stripMarkdown(typeof extra.title === "string" ? extra.title : "");
455
476
  // Through the SAME media check as an option's picture. Counting `imageUrl`
456
477
  // towards renderability made an image-only VISUAL_DISPLAY reachable, and the
457
478
  // descriptor binds this path straight into an `Image` — so an off-Wix url
@@ -464,13 +485,13 @@ export function viewOf(
464
485
 
465
486
  const sections = Array.isArray(extra.sections) ? (extra.sections as Record<string, unknown>[]) : [];
466
487
  const rows: WidgetRow[] = sections.map((sec, i) => {
467
- const subtitle = typeof sec.subtitle === "string" ? sec.subtitle : "";
488
+ const subtitle = stripMarkdown(typeof sec.subtitle === "string" ? sec.subtitle : "");
468
489
  return {
469
490
  // A stable id per row. Index-based on purpose: a section has no id of its
470
491
  // own, and the list is rebuilt wholesale on every tick, so there is
471
492
  // nothing for a content-derived key to preserve.
472
493
  id: `s${i}`,
473
- title: typeof sec.title === "string" ? sec.title : "",
494
+ title: stripMarkdown(typeof sec.title === "string" ? sec.title : ""),
474
495
  subtitle,
475
496
  subtitleHidden: !subtitle,
476
497
  };
@@ -540,8 +561,11 @@ export function viewOf(
540
561
 
541
562
  const providerKey = typeof extra.providerKey === "string" ? extra.providerKey : "";
542
563
 
543
- const primaryUrl = w.primary?.externalUrl ?? "";
544
- const secondaryUrl = w.secondary?.externalUrl ?? "";
564
+ const primaryUrl = webOnly(w.primary?.externalUrl);
565
+ const secondaryUrl = webOnly(w.secondary?.externalUrl);
566
+ const prose = stripMarkdown(w.text);
567
+ const bottom = stripMarkdown(w.bottomText);
568
+ const badgeText = stripMarkdown(w.badge);
545
569
  // An UNKNOWN kind is the one case where a MISSING `respondText` must not
546
570
  // make a control inert.
547
571
  //
@@ -563,6 +587,15 @@ export function viewOf(
563
587
  || (generic ? (w.secondary?.label ?? "") : "");
564
588
  const primaryLabel = w.primary ? discloseButton(disclose, w.primary.label, primaryUrl) : "";
565
589
  const secondaryLabel = w.secondary ? discloseButton(disclose, w.secondary.label, secondaryUrl) : "";
590
+ // Whether each footer button is actually SHOWN — a label to read AND
591
+ // something to do (a web destination, or text to send). Defined once and
592
+ // used twice, by the `*Hidden` flags and by `renderable` below, because the
593
+ // two disagreeing is exactly the blank-card bug: `renderable` counted a bare
594
+ // label, so a widget whose only content was a button `webOnly` had just
595
+ // stripped (a `tel:` link with no `respondText`) stayed visible as an empty
596
+ // card with nothing inside it.
597
+ const primaryShown = !!(primaryLabel && (primaryUrl || primaryRespondText));
598
+ const secondaryShown = !!(secondaryLabel && (secondaryUrl || secondaryRespondText));
566
599
 
567
600
  // Hoisted out of the object literal below so `renderable` can see it. It
568
601
  // was computed inline, so naming it in the guard was a ReferenceError that
@@ -587,8 +620,10 @@ export function viewOf(
587
620
  // though they had something to show. Hiding a widget that has content is a
588
621
  // worse failure than the blank card this guard exists to prevent, so the
589
622
  // list has to match what the descriptor renders, not what I remembered.
590
- const renderable = !!(w.text || w.bottomText || w.badge || title || imageUrl
591
- || description || options.length || primaryLabel || secondaryLabel
623
+ // The STRIPPED prose, not the raw fields: those are what the card draws, and
624
+ // a reply of pure markup (`***`, an image-only `![](…)`) strips to nothing.
625
+ const renderable = !!(prose || bottom || badgeText || title || imageUrl
626
+ || description || options.length || primaryShown || secondaryShown
592
627
  || sections.length || slices.length || steps.length || questions.length
593
628
  || asyncMessage || treeJson);
594
629
 
@@ -651,10 +686,10 @@ export function viewOf(
651
686
  Object.entries(msgs).filter((kv): kv is [string, string] => typeof kv[1] === "string"),
652
687
  ),
653
688
  submitting: false,
654
- text: w.text, textHidden: !w.text,
689
+ text: prose, textHidden: !prose,
655
690
  title, titleHidden: !title,
656
- bottomText: w.bottomText, bottomTextHidden: !w.bottomText,
657
- badge: w.badge, badgeHidden: !w.badge,
691
+ bottomText: bottom, bottomTextHidden: !bottom,
692
+ badge: badgeText, badgeHidden: !badgeText,
658
693
  imageUrl, imageHidden: !imageUrl,
659
694
  // Shown only when there is a LABEL to read and a DESTINATION to go to.
660
695
  //
@@ -671,9 +706,9 @@ export function viewOf(
671
706
  // #2605 hid a button that had nothing to DO. Now that `respondText` can be
672
707
  // sent, having something to SAY counts too — which is the `&& url` that
673
708
  // issue said this one would remove.
674
- primaryLabel, primaryHidden: !(primaryLabel && (primaryUrl || primaryRespondText)), primaryUrl,
709
+ primaryLabel, primaryHidden: !primaryShown, primaryUrl,
675
710
  primaryRespondText,
676
- secondaryLabel, secondaryHidden: !(secondaryLabel && (secondaryUrl || secondaryRespondText)), secondaryUrl,
711
+ secondaryLabel, secondaryHidden: !secondaryShown, secondaryUrl,
677
712
  secondaryRespondText,
678
713
  rows,
679
714
  };