{"name":"message-scroller","title":"Message Scroller","description":"A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.","installCommand":"bunx marko-ui add message-scroller -y","usageTags":"<MessageScrollerProvider>","importSnippet":"import MessageScrollerProvider from \"@/components/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@/components/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@/components/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@/components/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@/components/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@/components/ui/message-scroller/button.marko\";","usageSnippet":"<let/messages=[\n  { id: \"1\", role: \"user\", text: \"Hi!\" },\n  { id: \"2\", role: \"assistant\", text: \"Hello — how can I help?\" },\n]/>\n\n<MessageScrollerProvider|controller|>\n  <MessageScroller controller=controller>\n    <MessageScrollerViewport controller=controller>\n      <MessageScrollerContent controller=controller>\n        <for|message| of=messages by=(message) => message.id>\n          <MessageScrollerItem\n            controller=controller\n            messageId=message.id\n            scrollAnchor=(message.role === \"user\")\n          >\n            <!-- your message row -->\n          </MessageScrollerItem>\n        </for>\n      </MessageScrollerContent>\n    </MessageScrollerViewport>\n    <MessageScrollerButton controller=controller/>\n  </MessageScroller>\n</MessageScrollerProvider>","tags":["MessageScroller","MessageScrollerButton","MessageScrollerContent","MessageScrollerItem","MessageScrollerMessageScrollerProvider","MessageScrollerViewport"],"parts":[],"props":[{"name":"controller","type":"MessageScrollerControllerGetter","required":true,"description":"Controller getter provided by the nearest `<message-scroller-provider>`."}],"events":[],"keyboard":[],"accessibilityNotes":["MessageScrollerViewport is a labelled, keyboard-focusable scroll region by default: it uses role=\"region\", aria-label=\"Messages\", and tabindex=\"0\", so keyboard users can focus the transcript and scroll it directly.","MessageScrollerContent marks the transcript as a live region with role=\"log\" and aria-relevant=\"additions\". New rows can be announced, but streamed text mutations do not have to be announced token by token — pass aria-busy while a turn is still arriving if announcements should wait for the completed row.","MessageScrollerButton renders a real button. When there is nothing to scroll toward, it sets inert, uses tabindex=\"-1\", and exposes data-active=\"false\" so inactive scroll controls do not create extra focus stops."],"examples":[{"id":"message-scroller-demo","title":"Demo","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Empty from \"@marko-ui/shadcn/ui/empty/empty.marko\";\nimport EmptyHeader from \"@marko-ui/shadcn/ui/empty/header.marko\";\nimport EmptyMedia from \"@marko-ui/shadcn/ui/empty/media.marko\";\nimport EmptyTitle from \"@marko-ui/shadcn/ui/empty/title.marko\";\nimport EmptyDescription from \"@marko-ui/shadcn/ui/empty/description.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Tooltip from \"@marko-ui/shadcn/ui/tooltip/tooltip.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const SCRIPT: ChatMessage[] = [\n  {\n    id: \"demo-1-user\",\n    role: \"user\",\n    text: \"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.\",\n  },\n  {\n    id: \"demo-1-assistant\",\n    role: \"assistant\",\n    text: \"That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\\n\\nIt only auto-scrolls while the reader is already at the bottom. The moment they scroll up, auto-scroll backs off and their position is preserved.\",\n  },\n  {\n    id: \"demo-2-user\",\n    role: \"user\",\n    text: \"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.\",\n  },\n  {\n    id: \"demo-2-assistant\",\n    role: \"assistant\",\n    text: \"MessageScrollerItem fixes that with turn anchoring. Set scrollAnchor on the turn that should settle near the top instead of blindly snapping to the document bottom.\\n\\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost.\",\n  },\n  {\n    id: \"demo-3-user\",\n    role: \"user\",\n    text: \"And if they've scrolled up to re-read an older answer? I don't want to yank them back down.\",\n  },\n  {\n    id: \"demo-3-assistant\",\n    role: \"assistant\",\n    text: \"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out. When there is content they haven't seen, MessageScrollerButton appears — one tap jumps them back and re-engages auto-scroll.\",\n  },\n];\n\n<let/messages=([] as ChatMessage[])/>\n<let/busy=false/>\n<const/nextMessage=SCRIPT[messages.length]/>\n\n<const/send=() => {\n  if (!nextMessage || busy) return;\n  const outgoing = nextMessage;\n  messages = [...messages, outgoing];\n  if (outgoing.role === \"user\") {\n    busy = true;\n    setTimeout(() => {\n      const reply = SCRIPT[messages.length];\n      if (reply) messages = [...messages, reply];\n      busy = false;\n    }, 700);\n  }\n}/>\n\n<const/reset=() => {\n  messages = [];\n  busy = false;\n}/>\n\n<MessageScrollerProvider|controller| autoScroll>\n  <div class=\"relative flex flex-col gap-4\">\n    <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n      <CardHeader class=\"gap-1 border-b\">\n        <CardTitle>New Chat</CardTitle>\n        <CardDescription>How can I help you today?</CardDescription>\n        <CardAction>\n          <Tooltip>\n            <@trigger|props|>\n              <Button type=\"button\" ...props variant=\"outline\" size=\"icon\" aria-label=\"Reset conversation\" disabled=busy onClick=reset>\n                <Icon name=\"RotateCwIcon\"/>\n              </Button>\n            </@trigger>\n            Reset\n          </Tooltip>\n        </CardAction>\n      </CardHeader>\n      <CardContent class=\"flex-1 overflow-hidden p-0\">\n        <if=(messages.length === 0)>\n          <Empty class=\"h-full\">\n            <EmptyHeader>\n              <EmptyMedia variant=\"icon\">\n                <Icon name=\"MessageCircleIcon\"/>\n              </EmptyMedia>\n              <EmptyTitle>Morning, shadcn!</EmptyTitle>\n              <EmptyDescription>\n                What are we working on today? Press send to start a new conversation\n              </EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        </if>\n        <else>\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller aria-busy=(busy ? \"true\" : \"false\") class=\"p-4\">\n                <for|message| of=messages by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <for|paragraph| of=message.text.split(/\\n\\s*\\n/)>\n                              <p class=\"whitespace-pre-wrap\">${paragraph}</p>\n                            </for>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </else>\n      </CardContent>\n      <CardFooter class=\"flex-col gap-2\">\n        <div class=\"flex h-14 w-full items-center rounded-md border border-input px-3 py-2.5 text-sm\">\n          <if=nextMessage>\n            <span class=(\"line-clamp-2 \" + (busy ? \"opacity-60\" : \"\"))>${nextMessage.text}</span>\n          </if>\n          <else>\n            <span class=\"text-muted-foreground\">No messages queued. Reset the conversation.</span>\n          </else>\n          <Button\n            type=\"button\"\n            size=\"icon-sm\"\n            class=\"ml-auto shrink-0\"\n            disabled=(!nextMessage || busy)\n            onClick=send\n          >\n            <Icon name=\"ArrowUpIcon\"/>\n            <span class=\"sr-only\">Send</span>\n          </Button>\n        </div>\n      </CardFooter>\n    </Card>\n    <div class=\"px-0.5 text-center text-xs text-muted-foreground\">\n      Demo is read only. Press send to send messages.\n    </div>\n  </div>\n</MessageScrollerProvider>","description":"A scripted chat with autoScroll enabled — send a message, the reply arrives, and the viewport follows the live edge."},{"id":"message-scroller-anchoring","title":"Anchoring Turns","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Empty from \"@marko-ui/shadcn/ui/empty/empty.marko\";\nimport EmptyHeader from \"@marko-ui/shadcn/ui/empty/header.marko\";\nimport EmptyMedia from \"@marko-ui/shadcn/ui/empty/media.marko\";\nimport EmptyTitle from \"@marko-ui/shadcn/ui/empty/title.marko\";\nimport EmptyDescription from \"@marko-ui/shadcn/ui/empty/description.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport ToggleGroup from \"@marko-ui/shadcn/ui/toggle-group/toggle-group.marko\";\n\nexport type AnchorRole = \"user\" | \"assistant\";\n\nexport interface ChatMessage {\n  id: string;\n  role: AnchorRole;\n  text: string;\n}\n\nstatic const SCRIPT: ChatMessage[] = [\n  { id: \"anchor-1-user\", role: \"user\", text: \"Can you show me how anchoring behaves when a new prompt starts the turn?\" },\n  { id: \"anchor-1-assistant\", role: \"assistant\", text: \"Append the user prompt first, then append the assistant response. With User selected, the prompt settles near the top and the assistant response fills in below it.\" },\n  { id: \"anchor-2-user\", role: \"user\", text: \"What changes when assistant messages are the anchor?\" },\n  { id: \"anchor-2-assistant\", role: \"assistant\", text: \"Now each assistant response is the item MessageScroller keeps in view. This is useful when the reply is the moment you want readers to land on after each turn.\" },\n  { id: \"anchor-3-user\", role: \"user\", text: \"Can I switch roles and keep adding turns?\" },\n  { id: \"anchor-3-assistant\", role: \"assistant\", text: \"Yes. The next appended message with the selected role becomes the anchor, so you can compare user and assistant anchoring without resetting the demo.\" },\n];\n\nstatic const ROLES: { value: AnchorRole; label: string }[] = [\n  { value: \"user\", label: \"User\" },\n  { value: \"assistant\", label: \"Assistant\" },\n];\n\n<let/anchorRole=(\"user\" as AnchorRole)/>\n<let/messages=([] as ChatMessage[])/>\n<let/messageIndex=0/>\n<const/nextMessage=SCRIPT[messageIndex]/>\n\n<const/send=() => {\n  if (!nextMessage) return;\n  messages = [...messages, nextMessage];\n  messageIndex += 1;\n}/>\n\n<const/reset=() => {\n  messages = [];\n  messageIndex = 0;\n}/>\n\n<const/onRoleChange=(next: string[]) => {\n  const value = next[0];\n  if (value === \"user\" || value === \"assistant\") {\n    anchorRole = value;\n    reset();\n  }\n}/>\n\n<div class=\"relative flex flex-col gap-4\">\n  <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n    <CardHeader class=\"border-b\">\n      <CardTitle>Anchoring Turns</CardTitle>\n      <CardDescription>Choose which role settles near the top edge.</CardDescription>\n      <CardAction>\n        <Button type=\"button\" variant=\"outline\" size=\"icon\" aria-label=\"Reset anchored turns\" disabled=(messages.length === 0) onClick=reset>\n          <Icon name=\"RotateCwIcon\"/>\n        </Button>\n      </CardAction>\n    </CardHeader>\n    <CardContent class=\"min-h-0 flex-1 overflow-hidden p-0\">\n      <if=(messages.length === 0)>\n        <Empty class=\"h-full\">\n          <EmptyHeader>\n            <EmptyMedia variant=\"icon\">\n              <Icon name=\"MessageCircleIcon\"/>\n            </EmptyMedia>\n            <EmptyTitle>No anchored messages yet</EmptyTitle>\n            <EmptyDescription>Send the first message to see the selected role anchor.</EmptyDescription>\n          </EmptyHeader>\n        </Empty>\n      </if>\n      <else>\n        <MessageScrollerProvider|controller|>\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller class=\"p-4\">\n                <for|message| of=messages by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === anchorRole)>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <p class=\"whitespace-pre-wrap\">${message.text}</p>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </MessageScrollerProvider>\n      </else>\n    </CardContent>\n    <CardFooter>\n      <ToggleGroup\n        aria-label=\"Select scroll anchor role\"\n        items=ROLES\n        value=[anchorRole]\n        valueChange=onRoleChange\n      />\n      <Button type=\"button\" size=\"icon\" class=\"ml-auto\" disabled=(!nextMessage) onClick=send>\n        <Icon name=\"ArrowUpIcon\"/>\n        <span class=\"sr-only\">Send Message</span>\n      </Button>\n    </CardFooter>\n  </Card>\n  <div class=\"mx-auto max-w-xs px-0.5 text-center text-xs text-muted-foreground\">\n    Toggle the anchor role, then send messages to compare where turns settle.\n  </div>\n</div>","description":"Toggle which role becomes the scroll anchor, then send scripted turns to see the selected role settle near the top edge."},{"id":"message-scroller-group-chat","title":"Group Chat","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Marker from \"@marko-ui/shadcn/ui/marker/marker.marko\";\nimport MarkerContent from \"@marko-ui/shadcn/ui/marker/content.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport MessageHeader from \"@marko-ui/shadcn/ui/message/header.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Tooltip from \"@marko-ui/shadcn/ui/tooltip/tooltip.marko\";\n\nstatic const CURRENT_USER = \"Grace\";\n\nexport type GroupChatItem =\n  | { id: string; type: \"event\"; text: string; scrollAnchor?: boolean }\n  | { id: string; type: \"message\"; sender: string; role: \"assistant\" | \"participant\"; text: string; scrollAnchor?: boolean };\n\nstatic const INITIAL_ITEMS: GroupChatItem[] = [\n  { id: \"group-1\", type: \"message\", sender: \"Grace\", role: \"participant\", text: \"@mary, the astrophage line keeps matching Venus energy output. Can you check my math?\" },\n  { id: \"group-2\", type: \"message\", sender: \"Mary (Agent)\", role: \"assistant\", text: \"Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.\" },\n  { id: \"group-3\", type: \"message\", sender: \"Grace\", role: \"participant\", text: \"ping @rocky\", scrollAnchor: true },\n];\n\nstatic const ROCKY_MARKER: GroupChatItem = { id: \"group-4\", type: \"event\", text: \"Rocky has joined the chat\", scrollAnchor: true };\nstatic const ROCKY_MESSAGE: GroupChatItem = { id: \"group-5\", type: \"message\", sender: \"Rocky\", role: \"participant\", text: \"Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.\" };\n\n<let/rockyTurn=(\"idle\" as \"idle\" | \"marker\" | \"message\")/>\n<const/items=(\n  rockyTurn === \"message\" ? [...INITIAL_ITEMS, ROCKY_MARKER, ROCKY_MESSAGE]\n  : rockyTurn === \"marker\" ? [...INITIAL_ITEMS, ROCKY_MARKER]\n  : INITIAL_ITEMS\n)/>\n<const/buttonLabel=(rockyTurn === \"idle\" ? \"Add Rocky\" : \"Send Message as Rocky\")/>\n<const/isComplete=(rockyTurn === \"message\")/>\n\n<const/reset=() => { rockyTurn = \"idle\"; }/>\n<const/advance=() => { rockyTurn = rockyTurn === \"idle\" ? \"marker\" : \"message\"; }/>\n\n<MessageScrollerProvider|controller|>\n  <div class=\"relative flex flex-col gap-4\">\n    <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n      <CardHeader class=\"gap-1 border-b\">\n        <CardTitle>Group Chat</CardTitle>\n        <CardDescription>\n          A group chat with several participants and an assistant. The Marker is marked as a turn.\n        </CardDescription>\n        <CardAction>\n          <Tooltip>\n            <@trigger|props|>\n              <Button ...props type=\"button\" variant=\"outline\" size=\"icon\" aria-label=\"Reset conversation\" disabled=(rockyTurn === \"idle\") onClick=reset>\n                <Icon name=\"RotateCwIcon\"/>\n              </Button>\n            </@trigger>\n            Reset\n          </Tooltip>\n        </CardAction>\n      </CardHeader>\n      <CardContent class=\"min-h-0 flex-1 p-0\">\n        <MessageScroller controller=controller>\n          <MessageScrollerViewport controller=controller>\n            <MessageScrollerContent controller=controller class=\"p-4\">\n              <for|item| of=items by=(item) => item.id>\n                <if=(item.type === \"message\")>\n                  <MessageScrollerItem controller=controller messageId=item.id scrollAnchor=item.scrollAnchor>\n                    <Message align=(item.sender === CURRENT_USER ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <if=(item.sender !== CURRENT_USER)>\n                          <MessageHeader>${item.sender}</MessageHeader>\n                        </if>\n                        <Bubble variant=(item.sender === CURRENT_USER ? \"muted\" : item.role === \"assistant\" ? \"ghost\" : \"tinted\")>\n                          <BubbleContent>${item.text}</BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </if>\n                <else>\n                  <MessageScrollerItem controller=controller scrollAnchor=(item.scrollAnchor ?? false)>\n                    <Marker variant=\"separator\">\n                      <MarkerContent>${item.text}</MarkerContent>\n                    </Marker>\n                  </MessageScrollerItem>\n                </else>\n              </for>\n            </MessageScrollerContent>\n          </MessageScrollerViewport>\n          <MessageScrollerButton controller=controller/>\n        </MessageScroller>\n      </CardContent>\n      <CardFooter class=\"flex flex-col items-center gap-2 border-t\">\n        <Button type=\"button\" disabled=isComplete onClick=advance class=\"w-full\" variant=\"secondary\">\n          ${buttonLabel}\n        </Button>\n        <p class=\"text-xs text-muted-foreground\">\n          <if=(rockyTurn === \"idle\")>This will create a marker and make it the anchor</if>\n          <else>Now send Rocky's reply into the conversation</else>\n        </p>\n      </CardFooter>\n    </Card>\n    <div class=\"mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground\">\n      When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn\n    </div>\n  </div>\n</MessageScrollerProvider>","description":"Anchoring is role-independent — a join-event Marker can be the scrollAnchor for the next turn just as easily as a message."},{"id":"message-scroller-previous-context","title":"Keeping Context Visible","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Tooltip from \"@marko-ui/shadcn/ui/tooltip/tooltip.marko\";\nimport Slider from \"@marko-ui/shadcn/ui/slider/slider.marko\";\n\nstatic const DEFAULT_PEEK = 64;\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const SCRIPT: ChatMessage[] = [\n  { id: \"prev-1-user\", role: \"user\", text: \"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.\" },\n  { id: \"prev-1-assistant\", role: \"assistant\", text: \"That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive.\" },\n  { id: \"prev-2-user\", role: \"user\", text: \"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.\" },\n  { id: \"prev-2-assistant\", role: \"assistant\", text: \"MessageScrollerItem fixes that with turn anchoring. It also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost.\" },\n];\n\n<let/messages=(SCRIPT.slice(0, 2) as ChatMessage[])/>\n<let/peek=DEFAULT_PEEK/>\n<let/busy=false/>\n<let/providerKey=0/>\n<const/nextMessage=SCRIPT[messages.length]/>\n\n<const/send=() => {\n  if (!nextMessage || busy) return;\n  const outgoing = nextMessage;\n  messages = [...messages, outgoing];\n  if (outgoing.role === \"user\") {\n    busy = true;\n    setTimeout(() => {\n      const reply = SCRIPT[messages.length];\n      if (reply) messages = [...messages, reply];\n      busy = false;\n    }, 700);\n  }\n}/>\n\n<const/reset=() => {\n  messages = SCRIPT.slice(0, 2);\n  peek = DEFAULT_PEEK;\n  providerKey += 1;\n  busy = false;\n}/>\n\n<for|generation| of=[providerKey] by=(value) => String(value)>\n  <MessageScrollerProvider|controller| scrollMargin=24 scrollPreviousItemPeek=peek>\n    <div class=\"relative flex flex-col gap-4\">\n      <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n        <CardHeader class=\"gap-1 border-b\">\n          <CardTitle>Keeping Context Visible</CardTitle>\n          <CardDescription>New turns keep part of the previous reply in view.</CardDescription>\n          <CardAction>\n            <Tooltip>\n              <@trigger|props|>\n                <Button type=\"button\" ...props variant=\"outline\" size=\"icon\" aria-label=\"Reset context example\" disabled=busy onClick=reset>\n                  <Icon name=\"RotateCwIcon\"/>\n                </Button>\n              </@trigger>\n              Reset\n            </Tooltip>\n          </CardAction>\n        </CardHeader>\n        <CardContent class=\"flex-1 overflow-hidden p-0\">\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller aria-busy=(busy ? \"true\" : \"false\") class=\"p-4\">\n                <for|message| of=messages by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <p class=\"whitespace-pre-wrap\">${message.text}</p>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </CardContent>\n        <CardFooter class=\"flex-col gap-2\">\n          <div class=\"flex w-full items-center gap-3\">\n            <span class=\"w-10 shrink-0 text-xs tabular-nums text-muted-foreground\">${peek}px</span>\n            <Slider\n              aria-label=[\"Previous context peek\"]\n              value=[peek]\n              min=64\n              max=128\n              step=1\n              disabled=busy\n              valueChange=(value: number[]) => { peek = value[0] ?? DEFAULT_PEEK; }\n              class=\"flex-1\"\n            />\n            <Button type=\"button\" size=\"icon-sm\" disabled=(!nextMessage || busy) onClick=send>\n              <Icon name=\"ArrowUpIcon\"/>\n              <span class=\"sr-only\">Send</span>\n            </Button>\n          </div>\n        </CardFooter>\n      </Card>\n      <div class=\"px-0.5 text-center text-xs text-muted-foreground\">\n        Adjust the slider and send. Observe the previous message peek\n      </div>\n    </div>\n  </MessageScrollerProvider>\n</for>","description":"Adjust scrollPreviousItemPeek to control how much of the previous turn stays visible above a newly anchored row."},{"id":"message-scroller-streaming","title":"Following the Live Edge","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Empty from \"@marko-ui/shadcn/ui/empty/empty.marko\";\nimport EmptyHeader from \"@marko-ui/shadcn/ui/empty/header.marko\";\nimport EmptyMedia from \"@marko-ui/shadcn/ui/empty/media.marko\";\nimport EmptyTitle from \"@marko-ui/shadcn/ui/empty/title.marko\";\nimport EmptyDescription from \"@marko-ui/shadcn/ui/empty/description.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Tooltip from \"@marko-ui/shadcn/ui/tooltip/tooltip.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const SCRIPT: ChatMessage[] = [\n  { id: \"stream-1-user\", role: \"user\", text: \"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.\" },\n  { id: \"stream-1-assistant\", role: \"assistant\", text: \"That's the classic streaming scroll problem. Wrap your message list in MessageScroller and turn on autoScroll — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\" },\n  { id: \"stream-2-user\", role: \"user\", text: \"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top.\" },\n  { id: \"stream-2-assistant\", role: \"assistant\", text: \"MessageScrollerItem fixes that with turn anchoring. Set scrollAnchor on the turn that should settle near the top instead of blindly snapping to the document bottom.\" },\n  { id: \"stream-3-user\", role: \"user\", text: \"And if they've scrolled up to re-read an older answer? I don't want to yank them back down.\" },\n  { id: \"stream-3-assistant\", role: \"assistant\", text: \"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out.\" },\n];\n\n<let/messages=([] as ChatMessage[])/>\n<let/busy=false/>\n<const/nextMessage=SCRIPT[messages.length]/>\n\n<const/send=() => {\n  if (!nextMessage || busy) return;\n  const outgoing = nextMessage;\n  messages = [...messages, outgoing];\n  if (outgoing.role === \"user\") {\n    busy = true;\n    setTimeout(() => {\n      const reply = SCRIPT[messages.length];\n      if (reply) messages = [...messages, reply];\n      busy = false;\n    }, 700);\n  }\n}/>\n\n<const/reset=() => {\n  messages = [];\n  busy = false;\n}/>\n\n<MessageScrollerProvider|controller| autoScroll>\n  <div class=\"relative flex flex-col gap-4\">\n    <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n      <CardHeader class=\"gap-1 border-b\">\n        <CardTitle>Streaming Messages</CardTitle>\n        <CardDescription>Auto-scroll follows the live edge of the conversation.</CardDescription>\n        <CardAction>\n          <Tooltip>\n            <@trigger|props|>\n              <Button type=\"button\" ...props variant=\"outline\" size=\"icon\" aria-label=\"Reset stream\" onClick=reset disabled=(messages.length === 0 || busy)>\n                <Icon name=\"RotateCwIcon\"/>\n              </Button>\n            </@trigger>\n            Reset\n          </Tooltip>\n        </CardAction>\n      </CardHeader>\n      <CardContent class=\"flex-1 overflow-hidden p-0\">\n        <if=(messages.length === 0)>\n          <Empty class=\"h-full\">\n            <EmptyHeader>\n              <EmptyMedia variant=\"icon\">\n                <Icon name=\"MessageCircleIcon\"/>\n              </EmptyMedia>\n              <EmptyTitle>Ready to Stream</EmptyTitle>\n              <EmptyDescription>Press send to stream a scripted launch summary.</EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        </if>\n        <else>\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller aria-busy=(busy ? \"true\" : \"false\") class=\"p-4\">\n                <for|message| of=messages by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <p class=\"whitespace-pre-wrap\">${message.text}</p>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </else>\n      </CardContent>\n      <CardFooter class=\"flex-col gap-2\">\n        <div class=\"flex h-14 w-full items-center rounded-md border border-input px-3 py-2.5 text-sm\">\n          <if=nextMessage>\n            <span class=(\"line-clamp-2 \" + (busy ? \"opacity-60\" : \"\"))>${nextMessage.text}</span>\n          </if>\n          <else>\n            <span class=\"text-muted-foreground\">No messages queued. Reset the stream.</span>\n          </else>\n          <Button type=\"button\" size=\"icon-sm\" class=\"ml-auto shrink-0\" disabled=(!nextMessage || busy) onClick=send>\n            <Icon name=\"ArrowUpIcon\"/>\n            <span class=\"sr-only\">Send</span>\n          </Button>\n        </div>\n      </CardFooter>\n    </Card>\n    <div class=\"px-0.5 text-center text-xs text-muted-foreground\">\n      Streaming is simulated. autoScroll is enabled.\n    </div>\n  </div>\n</MessageScrollerProvider>","description":"With autoScroll enabled, the viewport keeps pace with scripted replies while the reader is at the bottom, and backs off the moment they scroll away."},{"id":"message-scroller-opening-position","title":"Opening Saved Threads","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport type { MessageScrollerControllerGetter } from \"@marko-ui/shadcn/ui/message-scroller/lib/controller.ts\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Tabs from \"@marko-ui/shadcn/ui/tabs/tabs.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const MESSAGES: ChatMessage[] = [\n  { id: \"open-1\", role: \"user\", text: \"This is the first message the user sent in the conversation.\" },\n  { id: \"open-2\", role: \"assistant\", text: \"Workspace creation rose 8%, but first invite completion only rose 2%.\" },\n  { id: \"open-3\", role: \"user\", text: \"This is the last message the user sent in the conversation.\" },\n  { id: \"open-4\", role: \"assistant\", text: \"Start with the invite step. Teams are creating workspaces but waiting to add collaborators.\\n\\nRecommended follow-up:\\n\\n1. Compare invite drop-off by account size.\\n2. Check whether users who skip invites still return within 24 hours.\\n3. Review the empty-state copy on the first project screen.\\n4. Segment activation by template, since template users may not need invites right away.\\n\\nIf that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.\" },\n];\n\nexport type Position = \"start\" | \"end\" | \"last-anchor\";\n\nstatic const POSITION_TABS = [\n  { value: \"start\", label: \"start\" },\n  { value: \"end\", label: \"end\" },\n  { value: \"last-anchor\", label: \"last-anchor\" },\n];\n\n<let/position=(\"last-anchor\" as Position)/>\n<let/positionKey=0/>\n\n<const/onPositionChange=(value: string) => {\n  if (value === \"start\" || value === \"end\" || value === \"last-anchor\") {\n    position = value;\n    positionKey += 1;\n  }\n}/>\n\n<const/runOpeningScroll=(controller: MessageScrollerControllerGetter) => {\n  const frame = requestAnimationFrame(() => {\n    if (position === \"start\") {\n      controller().scrollToStart({ behavior: \"auto\" });\n      return;\n    }\n    if (position === \"end\") {\n      controller().scrollToEnd({ behavior: \"auto\" });\n      return;\n    }\n    controller().scrollToMessage(\"open-3\", { align: \"start\", behavior: \"auto\", scrollMargin: 64 });\n  });\n  return { cleanup: () => cancelAnimationFrame(frame) };\n}/>\n\n<div class=\"relative flex flex-col gap-4\">\n  <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n    <CardHeader class=\"gap-1 border-b\">\n      <CardTitle>Opening Position</CardTitle>\n      <CardDescription>Choose where a saved transcript opens.</CardDescription>\n    </CardHeader>\n    <CardContent class=\"flex-1 overflow-hidden p-0\">\n      <MessageScrollerProvider|controller|>\n        <for|generation| of=[positionKey] by=(value) => String(value)>\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller class=\"p-4\">\n                <for|message| of=MESSAGES by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <for|paragraph| of=message.text.split(/\\n\\s*\\n/)>\n                              <p class=\"whitespace-pre-wrap\">${paragraph}</p>\n                            </for>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n          <lifecycle onMount() { return runOpeningScroll(controller); }/>\n        </for>\n      </MessageScrollerProvider>\n    </CardContent>\n    <CardFooter class=\"flex items-center justify-center border-t\">\n\n      <Tabs items=POSITION_TABS class=\"w-full\" value=position valueChange=onPositionChange></Tabs>\n    </CardFooter>\n  </Card>\n  <div class=\"mx-auto max-w-sm px-0.5 text-center text-xs text-muted-foreground\">\n    Toggle the defaultScrollPosition to see where the transcript starts when you open the thread\n  </div>\n</div>","description":"Switch defaultScrollPosition between start, end, and last-anchor to see where a reopened transcript lands."},{"id":"message-scroller-load-history","title":"Loading Earlier Messages","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Marker from \"@marko-ui/shadcn/ui/marker/marker.marko\";\nimport MarkerContent from \"@marko-ui/shadcn/ui/marker/content.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Tooltip from \"@marko-ui/shadcn/ui/tooltip/tooltip.marko\";\nimport Toaster from \"@marko-ui/shadcn/ui/toast/toast.marko\";\nimport { toast } from \"@marko-ui/shadcn/ui/toast/store.ts\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const HISTORY: ChatMessage[] = [\n  { id: \"history-1-user\", role: \"user\", text: \"Can you summarize the incident channel?\" },\n  { id: \"history-1-assistant\", role: \"assistant\", text: \"The first alert was a delayed export job. It started backing up around 09:42 UTC and triggered the warning once the retry queue crossed the threshold.\\n\\nNo customer-facing checkout paths were affected, but exports for larger workspaces were running about 12 minutes behind.\" },\n  { id: \"history-2-user\", role: \"user\", text: \"Was checkout affected?\" },\n  { id: \"history-2-assistant\", role: \"assistant\", text: \"No checkout errors were reported. Payment authorization, order creation, and confirmation emails stayed inside their normal latency bands.\" },\n  { id: \"history-3-user\", role: \"user\", text: \"What changed in the last deploy?\" },\n  { id: \"history-3-assistant\", role: \"assistant\", text: \"Only the export queue worker changed. The deploy moved large CSV jobs onto the shared retry policy, which made each failed attempt hold a worker slot longer than before.\" },\n  { id: \"history-4-user\", role: \"user\", text: \"Do we need to roll back?\" },\n  { id: \"history-4-assistant\", role: \"assistant\", text: \"Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.\" },\n  { id: \"history-5-user\", role: \"user\", text: \"Keep watching for customer-visible issues.\" },\n  { id: \"history-5-assistant\", role: \"assistant\", text: \"I will watch the queue and support tags for another 15 minutes. I am tracking export failures, delayed download requests, and any support thread that mentions missing reports.\" },\n];\n\nstatic const INITIAL_VISIBLE_COUNT = 5;\n\n<let/demoKey=0/>\n<let/visibleCount=INITIAL_VISIBLE_COUNT/>\n<const/visibleMessages=HISTORY.slice(-visibleCount)/>\n<const/canLoadHistory=(visibleCount < HISTORY.length)/>\n\n<const/reset=() => {\n  visibleCount = INITIAL_VISIBLE_COUNT;\n  demoKey += 1;\n}/>\n\n<const/loadHistory=() => {\n  visibleCount = HISTORY.length;\n  toast.success({ title: \"History loaded\", description: \"Scroll up to see earlier messages.\" });\n}/>\n\n<div class=\"relative flex flex-col gap-4\">\n  <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n    <CardHeader class=\"gap-1 border-b\">\n      <CardTitle>Load History</CardTitle>\n      <CardDescription>Prepended messages keep your place.</CardDescription>\n      <CardAction>\n        <Tooltip>\n          <@trigger|props|>\n            <Button ...props type=\"button\" variant=\"outline\" size=\"icon\" aria-label=\"Reset loaded messages\" disabled=(visibleCount === INITIAL_VISIBLE_COUNT) onClick=reset>\n              <Icon name=\"RotateCwIcon\"/>\n            </Button>\n          </@trigger>\n          Reset\n        </Tooltip>\n      </CardAction>\n    </CardHeader>\n    <CardContent class=\"flex-1 overflow-hidden p-0\">\n      <MessageScrollerProvider|controller|>\n        <for|generation| of=[demoKey] by=(value) => String(value)>\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller class=\"p-4\">\n                <for|message| of=visibleMessages by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <for|paragraph| of=message.text.split(/\\n\\s*\\n/)>\n                              <p class=\"whitespace-pre-wrap\">${paragraph}</p>\n                            </for>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n                <MessageScrollerItem controller=controller scrollAnchor=false>\n                  <Marker variant=\"separator\">\n                    <MarkerContent>End of Conversation</MarkerContent>\n                  </Marker>\n                </MessageScrollerItem>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </for>\n      </MessageScrollerProvider>\n    </CardContent>\n    <CardFooter class=\"flex flex-col items-center gap-2 border-t\">\n      <Button type=\"button\" disabled=(!canLoadHistory) onClick=loadHistory class=\"w-full\" variant=\"secondary\">\n        ${canLoadHistory ? \"Load History\" : \"History Loaded\"}\n      </Button>\n      <p class=\"text-xs text-muted-foreground\">Restore earlier messages while keeping your place.</p>\n    </CardFooter>\n  </Card>\n  <div class=\"mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground\">\n    Click Load History to load the entire conversation\n  </div>\n</div>\n<Toaster/>","description":"Prepending older messages preserves the reader's current row via preserveScrollOnPrepend, enabled by default."},{"id":"message-scroller-animation","title":"Animating New Messages","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Icon from \"@marko-ui/shadcn/ui/icon/icon.marko\";\nimport Empty from \"@marko-ui/shadcn/ui/empty/empty.marko\";\nimport EmptyHeader from \"@marko-ui/shadcn/ui/empty/header.marko\";\nimport EmptyMedia from \"@marko-ui/shadcn/ui/empty/media.marko\";\nimport EmptyTitle from \"@marko-ui/shadcn/ui/empty/title.marko\";\nimport EmptyDescription from \"@marko-ui/shadcn/ui/empty/description.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport Select from \"@marko-ui/shadcn/ui/select/select.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const SCRIPT: ChatMessage[] = [\n  { id: \"anim-1-user\", role: \"user\", text: \"Can user messages pop in like iMessage without breaking anchoring?\" },\n  { id: \"anim-1-assistant\", role: \"assistant\", text: \"Yes. Animate the user row with transform and opacity, and let the assistant response stream normally below it.\" },\n  { id: \"anim-2-user\", role: \"user\", text: \"What makes the animation feel more like iMessage?\" },\n  { id: \"anim-2-assistant\", role: \"assistant\", text: \"Use a quick spring from the trailing edge: a little scale, a small upward move, and no layout animation.\" },\n  { id: \"anim-3-user\", role: \"user\", text: \"Can I switch between presets while testing the same thread?\" },\n  { id: \"anim-3-assistant\", role: \"assistant\", text: \"Yes. Keep the conversation in place while you change the preset, then send the next message to compare the new entrance against the same context.\" },\n];\n\nstatic const PRESETS = [\n  { value: \"fade\", label: \"Fade\", enterClass: \"opacity-0\", restClass: \"opacity-100\" },\n  { value: \"slide-up\", label: \"Slide Up\", enterClass: \"translate-y-2 opacity-0\", restClass: \"translate-y-0 opacity-100\" },\n  { value: \"pop\", label: \"Pop\", enterClass: \"scale-95 opacity-0\", restClass: \"scale-100 opacity-100\" },\n];\n\n<let/presetId=\"fade\"/>\n<let/messages=([] as ChatMessage[])/>\n<let/busy=false/>\n<let/enteringId=(null as string | null)/>\nstatic const firstPreset: (typeof PRESETS)[number] = (() => {\n  const entry = PRESETS[0];\n  if (!entry) throw new Error(\"PRESETS must not be empty\");\n  return entry;\n})();\n\n<const/nextMessage=SCRIPT[messages.length]/>\n<const/preset=(PRESETS.find((entry) => entry.value === presetId) ?? firstPreset)/>\n\n<const/send=() => {\n  if (!nextMessage || busy) return;\n  const outgoing = nextMessage;\n  messages = [...messages, outgoing];\n  enteringId = outgoing.id;\n  requestAnimationFrame(() => requestAnimationFrame(() => {\n    if (enteringId === outgoing.id) enteringId = null;\n  }));\n  if (outgoing.role === \"user\") {\n    busy = true;\n    setTimeout(() => {\n      const reply = SCRIPT[messages.length];\n      if (reply) messages = [...messages, reply];\n      busy = false;\n    }, 700);\n  }\n}/>\n\n<const/reset=() => {\n  messages = [];\n  busy = false;\n  enteringId = null;\n}/>\n\n<div class=\"relative flex flex-col gap-4\">\n  <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n    <CardHeader class=\"border-b\">\n      <CardTitle>Animation</CardTitle>\n      <CardDescription>Choose how user messages are animated when they are added to the conversation.</CardDescription>\n      <CardAction class=\"flex items-center gap-2\">\n        <Button type=\"button\" variant=\"outline\" size=\"icon\" aria-label=\"Reset animated messages\" disabled=(messages.length === 0 || busy) onClick=reset>\n          <Icon name=\"RotateCwIcon\"/>\n        </Button>\n      </CardAction>\n    </CardHeader>\n    <CardContent class=\"min-h-0 flex-1 overflow-hidden p-0\">\n      <if=(messages.length === 0)>\n        <Empty class=\"h-full\">\n          <EmptyHeader>\n            <EmptyMedia variant=\"icon\">\n              <Icon name=\"MessageCircleIcon\"/>\n            </EmptyMedia>\n            <EmptyTitle>No Messages Yet</EmptyTitle>\n            <EmptyDescription>Click the button below to send the first message.</EmptyDescription>\n          </EmptyHeader>\n        </Empty>\n      </if>\n      <else>\n        <MessageScrollerProvider|controller|>\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller aria-busy=(busy ? \"true\" : \"false\") class=\"p-4\">\n                <for|message| of=messages by=(message) => message.id>\n                  <MessageScrollerItem\n                    controller=controller\n                    messageId=message.id\n                    class=(\"transition-[transform,opacity] duration-300 ease-out \" + (message.id === enteringId ? preset.enterClass : preset.restClass))\n                  >\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <p class=\"whitespace-pre-wrap\">${message.text}</p>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </MessageScrollerProvider>\n      </else>\n    </CardContent>\n    <CardFooter class=\"border-t\">\n      <Select\n        items=PRESETS\n        value=[presetId]\n        placeholder=\"Animation preset\"\n        valueChange=(value: string[]) => { presetId = value[0] ?? \"fade\"; }\n      />\n      <Button type=\"button\" size=\"icon\" class=\"ml-auto\" disabled=(!nextMessage || busy) onClick=send>\n        <Icon name=\"ArrowUpIcon\"/>\n        <span class=\"sr-only\">Send Message</span>\n      </Button>\n    </CardFooter>\n  </Card>\n  <div class=\"mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground\">\n    Select an animation then click send to see it in action.\n  </div>\n</div>","description":"Pick an entrance preset, then send a message to see the new row animate in without disturbing the scroller's positioning work."},{"id":"message-scroller-commands","title":"Jumping to Messages","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport type { MessageScrollerControllerGetter } from \"@marko-ui/shadcn/ui/message-scroller/lib/controller.ts\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardAction from \"@marko-ui/shadcn/ui/card/action.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport Button from \"@marko-ui/shadcn/ui/button/button.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport DropdownMenu from \"@marko-ui/shadcn/ui/dropdown-menu/dropdown-menu.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const MESSAGES: ChatMessage[] = [\n  { id: \"command-activation\", role: \"user\", text: \"We're seeing activation dip after workspace creation. Can you help me find the likely step?\" },\n  { id: \"command-activation-reply\", role: \"assistant\", text: \"The sharpest drop is between creating the workspace and inviting the first teammate.\\n\\nWorkspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.\" },\n  { id: \"command-compare\", role: \"user\", text: \"What should I compare before we change the onboarding flow?\" },\n  { id: \"command-compare-reply\", role: \"assistant\", text: \"Compare three cohorts:\\n\\n1. Users who choose a template before inviting teammates.\\n2. Users who start from a blank workspace.\\n3. Users who skip invites and return within 24 hours.\\n\\nIf template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.\" },\n  { id: \"command-experiment\", role: \"user\", text: \"Can you turn that into an experiment?\" },\n  { id: \"command-experiment-reply\", role: \"assistant\", text: \"Yes. Create a variant that shows a short checklist after workspace creation:\\n\\n- Pick a template.\\n- Add one project detail.\\n- Invite a teammate when the workspace has context.\\n\\nMeasure first invite completion, 24-hour return rate, and whether teams create a second project.\" },\n  { id: \"command-risk\", role: \"user\", text: \"What's the risk if we delay the invite prompt?\" },\n  { id: \"command-risk-reply\", role: \"assistant\", text: \"The main risk is reducing team creation for accounts that already know who they want to invite.\\n\\nTo protect that path, keep the invite action visible in the header and only change the primary empty-state guidance.\" },\n];\n\nstatic const USER_MESSAGES = MESSAGES.filter((message) => message.role === \"user\");\n\nstatic function trimmed(text: string) {\n  return text.length > 42 ? `${text.slice(0, 39)}...` : text;\n}\n\nstatic const MENU_ITEMS = USER_MESSAGES.map((message) => ({\n  value: message.id,\n  label: trimmed(message.text),\n}));\n\n<const/jumpTo=(controller: MessageScrollerControllerGetter, messageId: string) => {\n  controller().scrollToMessage(messageId, { align: \"start\", behavior: \"smooth\" });\n}/>\n\n<MessageScrollerProvider|controller| defaultScrollPosition=\"end\">\n  <div class=\"relative flex flex-col gap-4\">\n    <Card class=\"mx-auto h-140 w-full max-w-sm gap-0\">\n      <CardHeader class=\"gap-1 border-b\">\n        <CardTitle>Commands</CardTitle>\n        <CardDescription>Drive the transcript from outside.</CardDescription>\n        <CardAction>\n          <DropdownMenu items=MENU_ITEMS select=(value) => jumpTo(controller, value)>\n            <@trigger|props|>\n              <Button ...props type=\"button\" variant=\"secondary\">Jump to...</Button>\n            </@trigger>\n          </DropdownMenu>\n        </CardAction>\n      </CardHeader>\n      <CardContent class=\"flex-1 overflow-hidden p-0\">\n        <MessageScroller controller=controller>\n          <MessageScrollerViewport controller=controller>\n            <MessageScrollerContent controller=controller class=\"p-4\">\n              <for|message| of=MESSAGES by=(message) => message.id>\n                <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                  <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                    <MessageContent>\n                      <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                        <BubbleContent class=\"space-y-2\">\n                          <for|paragraph| of=message.text.split(/\\n\\s*\\n/)>\n                            <p class=\"whitespace-pre-wrap\">${paragraph}</p>\n                          </for>\n                        </BubbleContent>\n                      </Bubble>\n                    </MessageContent>\n                  </Message>\n                </MessageScrollerItem>\n              </for>\n            </MessageScrollerContent>\n          </MessageScrollerViewport>\n          <MessageScrollerButton controller=controller/>\n        </MessageScroller>\n      </CardContent>\n    </Card>\n    <div class=\"mx-auto max-w-sm px-0.5 text-center text-xs text-balance text-muted-foreground\">\n      Use the controls to jump to any message in the conversation.\n    </div>\n  </div>\n</MessageScrollerProvider>","description":"A dropdown menu drives the transcript from outside MessageScroller's own markup, using the controller getter's scrollToMessage command.","essential":true},{"id":"message-scroller-visibility","title":"Tracking the Reader's Position","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport type { MessageScrollerControllerGetter } from \"@marko-ui/shadcn/ui/message-scroller/lib/controller.ts\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\nimport HoverCard from \"@marko-ui/shadcn/ui/hover-card/hover-card.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const MESSAGES: ChatMessage[] = [\n  { id: \"vis-brief\", role: \"user\", text: \"Review the incident handoff and tell me what to read first.\" },\n  { id: \"vis-brief-reply\", role: \"assistant\", text: \"Start with the summary and the impact section. The regression affected the upload queue, but the recovery path completed for every queued job.\" },\n  { id: \"vis-impact\", role: \"user\", text: \"What was the customer impact?\" },\n  { id: \"vis-impact-reply\", role: \"assistant\", text: \"Impact was limited to delayed processing.\\n\\nNo records were dropped, and the reconciliation worker confirmed each retry batch. Support saw confusion from two customers, but there were no checkout or billing errors.\" },\n  { id: \"vis-actions\", role: \"user\", text: \"What actions are open?\" },\n  { id: \"vis-actions-reply\", role: \"assistant\", text: \"Keep the retry window enabled until the next deploy, then add a queue-depth alert as the long-term fix.\\n\\nThe alert should fire on sustained queue growth, not a single short spike.\" },\n  { id: \"vis-checklist\", role: \"user\", text: \"Give me the follow-up checklist.\" },\n  { id: \"vis-checklist-reply\", role: \"assistant\", text: \"After that, compare the queue recovery graph with the deploy timeline so the handoff shows exactly when processing returned to baseline.\\n\\nI would also add a short owner note beside each follow-up item. The checklist is small, but ownership keeps the retry-window decision, alert tuning, and support macro from drifting into separate follow-up conversations.\" },\n];\n\nstatic const USER_MESSAGES = MESSAGES.filter((message) => message.role === \"user\");\n\nstatic function trimmed(text: string) {\n  return text.length > 42 ? `${text.slice(0, 39)}...` : text;\n}\n\n<let/currentAnchorId=(null as string | null)/>\n\n<const/subscribeVisibility=(controller: MessageScrollerControllerGetter) => {\n  const store = controller().visibilityStore;\n  const sync = () => { currentAnchorId = store.getSnapshot().currentAnchorId; };\n  sync();\n  return { unsubscribe: store.subscribe(sync) };\n}/>\n\n<const/jumpTo=(controller: MessageScrollerControllerGetter, messageId: string) => {\n  controller().scrollToMessage(messageId, { align: \"start\", behavior: \"smooth\" });\n}/>\n\n<MessageScrollerProvider|controller| scrollMargin=12>\n  <lifecycle onMount() { return subscribeVisibility(controller); } onDestroy() { this.unsubscribe?.(); }/>\n  <div class=\"relative flex flex-col gap-4\">\n    <div class=\"relative mx-auto w-full max-w-sm\">\n      <Card class=\"h-140 w-full gap-0\">\n        <CardHeader class=\"gap-1 border-b\">\n          <CardTitle>Transcript Outline</CardTitle>\n          <CardDescription>Track the current anchored turn.</CardDescription>\n        </CardHeader>\n        <CardContent class=\"flex-1 overflow-hidden p-0\">\n          <MessageScroller controller=controller>\n            <MessageScrollerViewport controller=controller>\n              <MessageScrollerContent controller=controller class=\"p-4\">\n                <for|message| of=MESSAGES by=(message) => message.id>\n                  <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                    <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                      <MessageContent>\n                        <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                          <BubbleContent class=\"space-y-2\">\n                            <for|paragraph| of=message.text.split(/\\n\\s*\\n/)>\n                              <p class=\"whitespace-pre-wrap\">${paragraph}</p>\n                            </for>\n                          </BubbleContent>\n                        </Bubble>\n                      </MessageContent>\n                    </Message>\n                  </MessageScrollerItem>\n                </for>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n            <MessageScrollerButton controller=controller/>\n          </MessageScroller>\n        </CardContent>\n      </Card>\n      <div class=\"absolute top-1/2 -right-12 -translate-y-1/2\">\n        <HoverCard>\n          <@trigger|props|>\n            <button ...props type=\"button\" aria-label=\"Open transcript outline\" class=\"flex h-9 w-9 flex-col items-center justify-center gap-1 rounded-md transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50\">\n              <for|message| of=USER_MESSAGES by=(message) => message.id>\n                <span data-current=String(message.id === currentAnchorId) class=\"h-0.5 w-4 rounded-full bg-muted-foreground/40 data-[current=true]:bg-foreground\"/>\n              </for>\n            </button>\n          </@trigger>\n          <div class=\"flex w-64 flex-col gap-1 rounded-2xl p-1\">\n            <for|message| of=USER_MESSAGES by=(message) => message.id>\n              <button\n                type=\"button\"\n                aria-current=(currentAnchorId === message.id ? \"location\" : undefined)\n                class=\"flex min-h-7 items-center rounded-xl px-2 py-1.5 text-left text-sm transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-current:bg-accent aria-current:text-accent-foreground\"\n                onClick() { jumpTo(controller, message.id); }\n              >\n                <span class=\"line-clamp-1 min-w-0\">${trimmed(message.text)}</span>\n              </button>\n            </for>\n          </div>\n        </HoverCard>\n      </div>\n    </div>\n    <div class=\"mx-auto max-w-sm px-0.5 text-center text-xs text-muted-foreground\">\n      Open the outline to jump between anchored turns as you read.\n    </div>\n  </div>\n</MessageScrollerProvider>","description":"A hover-card outline highlights the current anchored turn, driven by the controller's visibility tracking."},{"id":"message-scroller-scrollable","title":"Reading Scroll State","source":"import MessageScrollerProvider from \"@marko-ui/shadcn/ui/message-scroller/message-scroller-provider.marko\";\nimport MessageScroller from \"@marko-ui/shadcn/ui/message-scroller/message-scroller.marko\";\nimport MessageScrollerViewport from \"@marko-ui/shadcn/ui/message-scroller/viewport.marko\";\nimport MessageScrollerContent from \"@marko-ui/shadcn/ui/message-scroller/content.marko\";\nimport MessageScrollerItem from \"@marko-ui/shadcn/ui/message-scroller/item.marko\";\nimport MessageScrollerButton from \"@marko-ui/shadcn/ui/message-scroller/button.marko\";\nimport type { MessageScrollerControllerGetter } from \"@marko-ui/shadcn/ui/message-scroller/lib/controller.ts\";\nimport Card from \"@marko-ui/shadcn/ui/card/card.marko\";\nimport CardHeader from \"@marko-ui/shadcn/ui/card/header.marko\";\nimport CardTitle from \"@marko-ui/shadcn/ui/card/title.marko\";\nimport CardDescription from \"@marko-ui/shadcn/ui/card/description.marko\";\nimport CardContent from \"@marko-ui/shadcn/ui/card/content.marko\";\nimport CardFooter from \"@marko-ui/shadcn/ui/card/footer.marko\";\nimport Message from \"@marko-ui/shadcn/ui/message/message.marko\";\nimport MessageContent from \"@marko-ui/shadcn/ui/message/content.marko\";\nimport Bubble from \"@marko-ui/shadcn/ui/bubble/bubble.marko\";\nimport BubbleContent from \"@marko-ui/shadcn/ui/bubble/bubble-content.marko\";\n\nexport interface ChatMessage {\n  id: string;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nstatic const MESSAGES: ChatMessage[] = Array.from({ length: 12 }, (_, index) => ({\n  id: `scrollable-${index + 1}`,\n  role: index % 2 === 0 ? \"user\" : \"assistant\",\n  text:\n    index % 2 === 0\n      ? `Review scroll checkpoint ${index + 1}.`\n      : `Checkpoint ${index + 1} is synced. The scrollable hook updates as the viewport moves.\\n\\nWhen the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.\\n\\nAt the latest message, the footer should switch again and only point them back up.`,\n}));\n\n<let/scrollableStart=false/>\n<let/scrollableEnd=false/>\n\n<const/subscribeScrollable=(controller: MessageScrollerControllerGetter) => {\n  const store = controller().stateStore;\n  const sync = () => {\n    const snapshot = store.getSnapshot();\n    scrollableStart = snapshot.start;\n    scrollableEnd = snapshot.end;\n  };\n  sync();\n  return { unsubscribe: store.subscribe(sync) };\n}/>\n\n<const/status=(\n  scrollableStart && scrollableEnd ? \"You can scroll both ways.\"\n  : scrollableEnd ? \"You are at the top. You can only scroll down.\"\n  : scrollableStart ? \"You are at the bottom. You can only scroll up.\"\n  : \"All messages fit in the viewport.\"\n)/>\n\n<div class=\"mx-auto flex w-full max-w-sm flex-col gap-4\">\n  <Card class=\"h-140 w-full gap-0 overflow-hidden\">\n    <CardHeader class=\"gap-1 border-b\">\n      <CardTitle>Scroll Status</CardTitle>\n      <CardDescription>Where the reader can go scroll to based on current scroll position.</CardDescription>\n    </CardHeader>\n    <MessageScrollerProvider|controller| defaultScrollPosition=\"start\">\n      <lifecycle onMount() { return subscribeScrollable(controller); } onDestroy() { this.unsubscribe?.(); }/>\n      <CardContent class=\"flex-1 overflow-hidden p-0\">\n        <MessageScroller controller=controller>\n          <MessageScrollerViewport controller=controller>\n            <MessageScrollerContent controller=controller class=\"gap-4 p-4\">\n              <for|message| of=MESSAGES by=(message) => message.id>\n                <MessageScrollerItem controller=controller messageId=message.id scrollAnchor=(message.role === \"user\")>\n                  <Message align=(message.role === \"user\" ? \"end\" : \"start\")>\n                    <MessageContent>\n                      <Bubble variant=(message.role === \"user\" ? \"muted\" : \"ghost\")>\n                        <BubbleContent class=\"space-y-2\">\n                          <for|paragraph| of=message.text.split(/\\n\\s*\\n/)>\n                            <p class=\"whitespace-pre-wrap\">${paragraph}</p>\n                          </for>\n                        </BubbleContent>\n                      </Bubble>\n                    </MessageContent>\n                  </Message>\n                </MessageScrollerItem>\n              </for>\n            </MessageScrollerContent>\n          </MessageScrollerViewport>\n          <MessageScrollerButton controller=controller/>\n        </MessageScroller>\n      </CardContent>\n      <CardFooter class=\"justify-center border-t text-center text-sm text-muted-foreground\">\n        ${status}\n      </CardFooter>\n    </MessageScrollerProvider>\n  </Card>\n  <div class=\"px-0.5 text-center text-xs text-muted-foreground\">\n    Scroll the transcript to see the footer update.\n  </div>\n</div>","description":"A footer reports which edges are still scrollable, driven by the controller's scroll-state store."}],"importInstallCommand":"bun add @marko-ui/shadcn marko-zag","nativeAttributes":"div","subcomponents":[{"name":"button","props":[{"name":"controller","type":"MessageScrollerControllerGetter","required":true,"description":"Controller getter provided by the nearest `<message-scroller-provider>`."},{"name":"behavior","type":"ScrollBehavior","required":false,"description":"Native scroll behavior when clicked. Defaults to \"smooth\"."},{"name":"direction","type":"'end' | 'start'","required":false,"description":"Transcript edge to scroll toward. Defaults to \"end\"."}],"nativeAttributes":"button"},{"name":"content","props":[{"name":"controller","type":"MessageScrollerControllerGetter","required":true,"description":"Controller getter provided by the nearest `<message-scroller-provider>`."},{"name":"spacerClassName","type":"string","required":false,"description":"Class name for the internal tail spacer used when anchoring turns near the top."}],"nativeAttributes":"div"},{"name":"item","props":[{"name":"controller","type":"MessageScrollerControllerGetter","required":true,"description":"Controller getter provided by the nearest `<message-scroller-provider>`."},{"name":"messageId","type":"string","required":false,"description":"Stable row id for scrollToMessage, visibility, and prepend preservation."},{"name":"scrollAnchor","type":"boolean","required":false,"description":"Marks a turn boundary that newly appended anchors and last-anchor restore use."}],"nativeAttributes":"div"},{"name":"message-scroller-provider","props":[{"name":"autoScroll","type":"boolean","required":false,"description":"Follow new content at the bottom while the viewport is already at the end."},{"name":"defaultScrollPosition","type":"MessageScrollerDefaultScrollPosition","required":false,"description":"Opening position on the first non-empty render, applied once."},{"name":"scrollEdgeThreshold","type":"number","required":false,"description":"Distance from an edge that still counts as at-top/at-bottom. Defaults to 8."},{"name":"scrollMargin","type":"number","required":false,"description":"Default margin on the aligned edge for commands and visibility. Defaults to 0."},{"name":"scrollPreviousItemPeek","type":"number","required":false,"description":"Extra top margin for a newly anchored row, added to scrollMargin. Defaults to 64. Read once, when the controller is created: a later change has no effect until the provider remounts."}],"body":{"param":"MessageScrollerControllerGetter","description":"Subtree that receives the controller GETTER as its single argument."}},{"name":"viewport","props":[{"name":"controller","type":"MessageScrollerControllerGetter","required":true,"description":"Controller getter provided by the nearest `<message-scroller-provider>`."},{"name":"preserveScrollOnPrepend","type":"boolean","required":false,"description":"Keep the first visible messageId row stable on prepend. Defaults to true."}],"nativeAttributes":"div"}],"concepts":"MessageScroller is built around a few ideas: an anchor is the row (marked with scrollAnchor) that a new turn should settle near the top under, independent of message role. autoScroll follows the live edge only while the reader is already there — any scroll away is a deliberate opt-out, and MessageScrollerButton offers a way back. defaultScrollPosition controls where a saved thread opens (\"start\", \"end\", or \"last-anchor\" — the last anchored turn, the best default for reopening a conversation). preserveScrollOnPrepend keeps the reader's row in place when older history loads above them. All of this is tracked imperatively — scroll position, anchoring, and follow-output never trigger row rerenders — so MessageScroller stays fast in long transcripts.","composition":"MessageScrollerProvider is the headless root: it owns scroll state and the behavior props for opening position, auto-scroll, anchoring, scroll commands, and visibility tracking. It exposes a controller GETTER to its content body (this port's stand-in for upstream's React context / useMessageScroller hooks — see the Concepts section and the demos below for how it's used to drive the transcript from outside the scroller's own markup). MessageScroller is the styled frame that lays out the viewport, content, and controls inside the provider. MessageScrollerViewport is the scrollable element: it receives native scroll events and preserves the visible row when older messages are prepended. MessageScrollerContent is the transcript container: it holds the rows and provides the live-region defaults for new messages. MessageScrollerItem is the transcript row boundary — wrap every direct child of the content so the scroller can measure, anchor, preserve position, track visibility, and jump to it; an item can be a message, marker, typing indicator, separator, join/leave event, or \"load earlier\" row. MessageScrollerButton is the scroll control: it scrolls to the start or end of the transcript and is inert until there is content in its direction. Every part takes the `controller` getter as an explicit input — this port's substitute for the upstream React context boundary (Marko has no context primitive)."}
