@aisquare/pretty
v0.10.34
Published
Pretty UI primitives for AISquare. Centralized buttons, inputs, motion, and tokens shared across creator-studio and ai-studio.
Downloads
740
Readme
aisquare-pretty
Pretty UI primitives for AISquare. Centralized buttons, inputs, motion, and tokens shared across
creator-studioandai-studio.
Motto: Pretty, centralized, quick. Customer-non-impacting wins only.
If a change in here would visibly disrupt a paying customer's flow, it doesn't ship — find a smaller win or scope it as a deliberate version bump with a migration note. Everything else: ship fast.
Install
pnpm add aisquare-pretty
# or: npm install aisquare-prettyNo tokens, no .npmrc, no setup. Public npm package, private source.
Wire it up (one-time per consumer app)
Tailwind preset — extend in your
tailwind.config.ts:import aisquarePreset from "@aisquare/pretty/tailwind.preset"; export default { presets: [aisquarePreset], content: ["./src/**/*.{ts,tsx}"], };CSS variables — your app already defines
--primary,--radius,--accent, etc. The library reads from those at render time, so each app keeps its own theme. If starting a new app, copy the AISquare defaults:/* in your index.css */ @import "@aisquare/pretty/tokens.template.css";Mount the Toaster once (only if using toasts):
import { Toaster } from "@aisquare/pretty"; // near app root <Toaster />;Import + use:
import { Button, ChatInput, EmptyState } from "@aisquare/pretty";
For migrating existing apps, see MIGRATION.md.
For an agent-friendly index that fits inside a single Claude session window, see PRIMITIVES_INDEX.md. It carries every primitive with a one-line description and a copy-paste snippet, grouped both by category and by intent ("for stat reveals", "for chat anatomy", "for atmospheric backgrounds", etc.).
For an interactive card-grid browser of every primitive with a Copy JSX button per card, run pnpm dev inside examples/playground/ and open the default Browser tab. The other tabs (Foundations, Display, Aiko, ...) host the rich, interaction-heavy demos.
Components
Interactive
Button
<Button>Default</Button>
<Button variant="gradient" size="lg">Get started</Button>
<Button variant="outline" size="icon"><Plus /></Button>
<Button asChild><Link to="/x">Composed</Link></Button>
<Button motion={false}>No springback</Button>Variants: default / gradient / outline / secondary / ghost / link / destructive. Sizes: sm / default / lg / icon. Hover lift + click springback via Motion (the framework-independent successor to Framer Motion).
Input
<Input placeholder="Type here" />
<Input variant="ghost" placeholder="Inline edit" />Variants: default / ghost / chat / search. Sizes: sm / default / lg. Use chat and search directly or via the composed primitives below.
ActionInput — generalized "input with one trailing CTA"
<ActionInput
variant="default"
leadingIcon={<Search />}
trailingAction={
<Button size="icon">
<ArrowRight />
</Button>
}
showActionWhen="non-empty" // "always" | "non-empty" | "focus" | "never"
placeholder="Where to?"
/>The base for ChatInput and ExploreSearchBar. Use directly when none of those fit.
ChatInput
<ChatInput
onSubmit={(value) => sendMessage(value)}
isSending={isSending}
placeholder="Ask anything"
maxRows={8}
/>
// Voice dictation — Web Speech API, feature-detected + SSR-safe
<ChatInput
onSubmit={onSend}
voice
/>
// File attachments — picker built in, chip rendering consumer-owned
<ChatInput
onSubmit={onSend}
onPickFiles={(files) => upload(files)}
accept="application/pdf,image/*"
attachments={files.map(f => <Badge key={f.id}>{f.name}</Badge>)}
/>
// Voice + attachments together — full-featured composer
<ChatInput
onSubmit={onSend}
voice={{ onTranscript: t => analytics.track("dictation", { length: t.length }) }}
onPickFiles={onUpload}
attachments={chips}
/>Autoresizing textarea, animated send button, Enter-to-send / Shift+Enter newline, IME-aware. Optional voice prop (boolean or callbacks object) enables Web Speech dictation — mic button hides when the browser doesn't support SpeechRecognition. Optional onPickFiles adds a paperclip leading-action; chips render via the attachments slot above the input. Send is allowed when attachments is truthy even with empty text (file-only messages). Custom leadingActions slot stays available for app-specific buttons; visual order is [attach] [mic] [custom] | [send].
ExploreSearchBar
<ExploreSearchBar
value={query}
onChange={(e) => setQuery(e.target.value)}
onClear={() => setQuery("")}
suggestions={
<ul>
{results.map((r) => (
<li key={r.id}>{r.label}</li>
))}
</ul>
}
/>Pill input with search-icon prefix, animated clear button, suggestions panel.
SmartSearch
<SmartSearch
value={query}
onChange={setQuery}
data={items}
onItemSelect={(item) => navigate(item.id)}
placeholderPrompts={["Search your content...", "Find a quest...", "Search by tag or status..."]}
voice
assistant={{ label: "Ask Aiko", onAsk: openAskPanel }}
/>The AI-aware sibling of ExploreSearchBar. Voice input (Web Speech API, feature-detected + SSR-safe), animated placeholder cycling that pauses on focus, scored fuzzy match over data (capped at 5 by default), and an "Ask Aiko" footer that always renders when assistant is set so the assistant is the natural fallback. Pass <AikoMark size="xs" /> as leadingIcon for the brand-prefix variant. Composes ActionInput + Button + AikoMark — no new external deps.
Reach for ExploreSearchBar when you want the lean default; reach for SmartSearch when the surface earns the AI affordances.
Display + status
Badge
<Badge variant="success">Active</Badge>
<Badge variant="live"><PulseDot size="sm" /> Live</Badge>
<Badge variant="new">New</Badge>Variants: default / secondary / outline / success / warning / destructive / accent / live / new / beta.
Avatar
<Avatar name="Jatin Saini" />
<Avatar name="Jatin Saini" src="/me.jpg" size="lg" status="online" />
<Avatar name="Studio" shape="rounded" colorFromName /> // monogram tile, gradient backdrop
<Avatar name="Studio" shape="square" /> // hard cornersImage with initials fallback. Optional status dot (online / offline / busy / away). Shape variants: circle (default — profile-picture read), rounded (rounded-2xl monogram tile, drop-in replacement for InitialAvatar-style brand placeholders), square (hard corners).
AvatarGroup
<AvatarGroup
items={[
{ name: "Jatin Saini" },
{ name: "Aiko Helper", src: "/aiko.png" },
{ name: "Studio Bot" },
]}
maxVisible={3}
/>Stacked overlapping avatars with a +N overflow pill once items.length exceeds maxVisible. Same sizing scale as Avatar. Pass any Avatar props per item (src, status, etc).
PulseDot
<PulseDot tone="live" /> // green, pulsing
<PulseDot tone="destructive" /> // red, pulsing
<PulseDot static /> // no animationPairs with Badge live variant for "online" indicators.
Kbd
Press <Kbd>⌘ K</Kbd> to search.Inline keyboard-shortcut display.
Editorial / tracked-changes (document review surfaces)
Primitives that compose into a contract / document review canvas. Each reads
the editorial token set (--paper, --ink, --rule, --ins, --del,
--comment) which the preset's editorialTokensPlugin injects with light +
dark defaults — opt-in styling for any consumer that ships a long-form
document review surface.
RedlineSpan
<RedlineSpan variant="suggestion" suggestionId="sug-42">
net thirty
</RedlineSpan>Inline span that marks tracked changes in document text. Variants:
insertion (added text, green underline), deletion (red strikethrough),
suggestion (AI proposal, dotted green underline, click-to-open),
comment-anchor (text tied to a comment thread, amber underline). Use
asChild to compose with <em> / <strong> / other inline elements your
clause renderer already wraps text in. Click delegation via data-sug-id
or data-edit-id — a parent document handler reads the dataset to open the
right reasoning popover or comment thread.
AttributionChip
<AttributionChip
name="Anika Patel"
avatarUrl={user.avatarUrl}
timestamp="2026-05-06T14:22:00Z"
tone="ins"
actions={
<>
<button onClick={accept}>Accept</button>
<button onClick={reject}>Reject</button>
</>
}
/>Floating chip that attributes an inline edit / comment / suggestion to a
user. Composes Avatar + name + relative timestamp ("12m ago", auto-formatted
from ISO) with an actions slot for accept/reject mini-controls. Default
positioning is absolute (consumer wraps in a relatively-positioned anchor
and offsets via top/left); pass placement="inline" for flow layout.
Tones tint the chip to match the variant of the change it labels: ins,
del, ai, or default. Mounts via the attribution-fade-in keyframe
(220ms, smooth-out); motion={false} opts out.
CommentPopover
<CommentPopover
authorName="Anika Patel"
authorAvatarUrl={user.avatarUrl}
onUploadImage={blob => uploadAttachment(blob)}
onSubmit={({ text, imageUrl }) => postComment({ text, imageUrl })}
>
<button>Add comment</button>
</CommentPopover>Popover composer for adding a comment to an inline edit or clause. Wraps
Radix Popover with a header (avatar + author name), an auto-grow textarea,
an inline image-paste affordance (Cmd/Ctrl+V drops the clipboard image into
the composer; onUploadImage(blob) returns a URL), and Cancel / Post
controls. Image-paste is silently disabled when onUploadImage is omitted.
Storage is consumer-owned; the primitive only owns the UX.
RecheckIssueIndicator
<RecheckIssueIndicator
severity="warn"
message="Net-45 deviates from playbook §14.2 (default net-30)."
onClick={() => scrollToClause("3.4")}
/>Pill that surfaces a single consistency-scan issue underneath a clause /
form field / inline target. Composes PulseDot for a severity dot (warn
amber, error red), a one-line message, and an optional onClick that
makes the whole pill an activatable button (Enter / Space activate, focus
ring, etc.). Pass stale to dim the indicator when the document has
changed since the last scan; consumers usually pair with a Rescan CTA.
DiffRow
<DiffRow
removed="net thirty"
added="net forty-five"
meta="§3.4 — Payment"
verdict={<Badge variant="outline">Outside playbook</Badge>}
onClick={() => scrollToClause("3.4")}
/>Single-row diff view for inline change lists — counter-redline review,
version diff side-rail, audit-log entries that compare before/after. Left
cell shows removed text (red strikethrough); right cell shows added text
(green). Optional meta line under the diff carries clause anchors or
context notes; optional verdict slot floats on the right edge for status
badges or accept/reject controls.
ContentTypeIcon
<ContentTypeIcon type="video" /> // cyan Video glyph, decorative
<ContentTypeIcon type="expert" size="lg" /> // larger emerald Brain
<ContentTypeIcon type="quest" tone="plain" aria-label="Quiz" />Bare glyph for one of video | note | expert | podcast | quest | studio | collection | experience (or any string, with the neutral fallback). Pulls icon + colour from getExperienceTypeMeta so it always agrees with <ExperienceTypeBadge> on the same surface. Sizes xs | sm | md | lg | xl. tone="plain" falls back to currentColor for use inside coloured chips. Cover-placeholder rasters at assets/covers/<type>.{svg,png} (1200x675, regenerate via pnpm generate:covers).
Layout
Accordion
<Accordion type="single" collapsible defaultValue="features">
<AccordionItem value="features">
<AccordionTrigger>What's in the box</AccordionTrigger>
<AccordionContent>
Five experience types. One studio. Zero glue code.
</AccordionContent>
</AccordionItem>
<AccordionItem value="pricing">
<AccordionTrigger>Pricing</AccordionTrigger>
<AccordionContent>Free while we're cooking.</AccordionContent>
</AccordionItem>
</Accordion>Composes @radix-ui/react-accordion for full keyboard navigation (↑ / ↓ between triggers, Home / End, Enter / Space toggle), data-state open/close attributes, and aria-expanded / aria-controls wiring out of the box. Single (type="single") and multiple (type="multiple") selection both pass through. Two visual variants picked at the <AccordionItem> level: default (minimal, item-separated by border-b) and card (each item is a rounded card with border + soft bg-card). Open/close animations use the accordion-down / accordion-up keyframes registered in the preset; motion-reduce skips them automatically.
The trigger wraps <AccordionPrimitive.Header> so screen readers see each open/close control as a heading; default tag is <h3>, override per surface via <AccordionTrigger level="h2">. Override the chevron with icon= if a different glyph fits the surface. Pass asHeader={false} on the trigger to suppress the <h3> wrapper when the accordion lives inside an existing heading hierarchy.
GlassInputFrame
import { GlassInputFrame, ChatInput } from "@aisquare/pretty";
// Wrap any composer in the gradient ring
<GlassInputFrame>
<ChatInput onSubmit={send} placeholder="Ask AIKO..." />
</GlassInputFrame>
// Literal blue-purple-indigo from AIStudio-v3 (theme-exempt)
<GlassInputFrame colorScheme="studio">
<ChatInput onSubmit={send} placeholder="Ask AIKO..." />
</GlassInputFrame>
// Or use ChatInput's built-in shorthand
<ChatInput glassFrame glassColorScheme="brand" onSubmit={send} />
<ChatInput glassFrame glassColorScheme="studio" onSubmit={send} />Gradient-ring outer shell extracted from AISquare-AIStudio-v3 AikoChat.tsx. A 1px gradient border wraps an inner panel (bg-background rounded-2xl). colorScheme="brand" (default) uses primary/25 and accent/25 CSS-var tokens so the ring adapts to per-studio retinting; colorScheme="studio" uses the literal blue-400/30 → purple-400/30 → indigo-400/30 from the v3 original (hardcoded, theme-exempt). Use directly when the inner composer is not a ChatInput; for ChatInput, prefer the glassFrame + glassColorScheme shorthand which delegates to this wrapper internally.
Overlays
Tooltip
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button>Hover</Button>
</TooltipTrigger>
<TooltipContent>Useful hint</TooltipContent>
</Tooltip>
</TooltipProvider>Wrap your app once with <TooltipProvider>, reuse Tooltip everywhere.
SmoothScroll
// Sub-entry import — SmoothScroll lives behind its own path so the main
// barrel never references the optional `lenis` peer (#93).
import { SmoothScroll, useLenis } from "@aisquare/pretty/smooth-scroll";
// Wrap once near the app root
<SmoothScroll>
<App />
</SmoothScroll>
// Disable inside surfaces where smooth scroll fights inputs (e.g. editors)
<SmoothScroll enabled={!isEditorFocused}>...</SmoothScroll>
// Read the active Lenis instance for advanced cases
function ProgressBar() {
const [progress, setProgress] = useState(0);
useLenis((lenis) => setProgress(lenis.progress));
return <div style={{ width: `${progress * 100}%` }} />;
}Lenis-powered smooth scroll. Honors prefers-reduced-motion automatically (silently degrades to native scroll). Pair with useScrollProgress for scroll-linked motion. Opting in to this primitive is the only path that pulls lenis into your bundle — consumers who don't import from @aisquare/pretty/smooth-scroll don't need lenis installed at all.
Toaster + aiko
import { Toaster, aiko } from "@aisquare/pretty";
// near app root
<Toaster />;
// anywhere
aiko.success("Saved.");
aiko.error("Couldn't save. Try again.");
aiko.loading("Working on it.");
const id = aiko.loading("Uploading");
// later:
aiko.success("Done.", { id });
// Confetti + toast for milestone moments
aiko.celebrate("First studio shipped.");
aiko.celebrate("Plan upgraded.", { intensity: "shower" });
// Or just the confetti
import { celebrate } from "@aisquare/pretty";
celebrate(); // default burst
celebrate({ intensity: "sparkle" }); // restrained
celebrate({ intensity: "shower" }); // big moment, multi-originSonner under the hood, AISquare-styled, AIKO-voiced defaults. aiko.celebrate() and the celebrate helper honor prefers-reduced-motion (skip the confetti, still show the toast).
Abstract states
Spinner
<Spinner />
<Spinner size="lg" />
<Spinner decorative /> // when paired with text labelFor inline / sub-300ms loading (Button submit, validating an input). For "the platform is doing AI work" moments (>300ms — initial load, AI generation, image processing) reach for AikoLoader.
TypingIndicator
<TypingIndicator /> // dots, default size
<TypingIndicator size="lg" /> // dots, larger
<TypingIndicator variant="aiko" /> // breathing AikoMark
<TypingIndicator motion={false} /> // static frame
<TypingIndicator aria-label="Aiko's preparing your answer" />Chat-row "AIKO is typing" cue. Drop inside a MessageRow placeholder while the assistant is composing. Default variant="dots" is three bouncing dots (the universal chat convention — iMessage, WhatsApp, Slack, assistant-ui); variant="aiko" swaps in the breathing AikoMark for AIKO-led surfaces. role="status" + aria-live="polite" announce a default aria-label="AIKO is typing" to screen readers; reduce-motion + motion={false} render the resting frame.
For longer-running platform AI work (initial load, generation), reach for AikoLoader instead — TypingIndicator is sized to live inline next to or inside a message bubble.
ThinkingDots
import { ThinkingDots } from "@aisquare/pretty";
<ThinkingDots /> // md (default) — maps to TypingIndicator size="default"
<ThinkingDots size="sm" /> // smStudio-v3 alias for <TypingIndicator variant="dots">. Ported from AISquare-AIStudio-v3 AikoChat.tsx for drop-in parity. Shares the same animation engine, reduced-motion handling, and role="status" a11y contract. size prop uses Studio-v3's sm | md vocabulary and maps to TypingIndicator's sm | default. For surfaces that already use TypingIndicator, prefer that directly; use ThinkingDots when migrating v3 code that referenced the local component by this name.
AikoLoader
<AikoLoader />
<AikoLoader size="lg" label="Generating preview" />
<AikoLoader glow={false} label="Saving" /> // for nested contextsBranded loader. Composes AikoMark's breathing animation with a soft pulse-glow halo. Sizes: sm / default / lg (32 / 56 / 80 px). Optional label slot rendered below in muted ink. role="status" aria-live="polite" on the wrapper. Reduce-motion users get a static halo + resting mark.
EditingBeam
<EditingBeam loading={isAikoEditing}>
<input value={title} onChange={...} />
</EditingBeam>
<EditingBeam loading={isAikoEditing} speed={0.9}>
<p className="text-2xl font-semibold">{draft}</p>
</EditingBeam>
<EditingBeam loading={isAikoEditing} motion={false}>
<p>{draft}</p> // dim only, no glide
</EditingBeam>
<EditingBeam loading={isAikoEditing} className="rounded-none border-0 p-0">
<textarea ... /> // borderless surface override
</EditingBeam>Block-level "AIKO is editing this" cue. While loading is true, children dim to muted ink and a skewed gradient beam glides across the wrapper at speed seconds per cycle (default 1.1); when false, the wrapper is a plain pass-through. aria-busy={loading} is the screen-reader signal. motion={false} and prefers-reduced-motion both drop the beam — the dim + aria-busy stay so the cue persists. Pair with <TypingIndicator> for inline chat-row "composing" states; reach for EditingBeam when AIKO is rewriting a whole field, title, or paragraph.
Skeleton
<Skeleton variant="card" className="h-32" />
<Skeleton variant="avatar" className="h-12 w-12" />
<SkeletonParagraph lines={3} />Variants: block / text / avatar / card / button.
EmptyState
<EmptyState /> // default AIKO copy
<EmptyState
icon={<Sparkles />}
title="No studios yet"
description="Pick a topic and we'll get the first one going."
action={<Button>Create studio</Button>}
/>ErrorState
<ErrorState onRetry={refetch} /> // default AIKO copy + retry button
<ErrorState
title="Couldn't load profile"
description="The server hiccupped. One more shot?"
onRetry={refetch}
isRetrying={isRetrying}
/>Content (images, markdown, media chrome)
Image
import { Image } from "@aisquare/pretty";
<Image src={url} alt="Hero" aspectRatio="16/9" priority />
<Image
src={experience.cover}
alt={experience.title}
fallbackSrc="/static/cover-fallback.png"
onError={(e) => analytics.imageFailed(experience.id)}
aspectRatio="4/3"
rounded="lg"
className="w-full"
/>
<Image
src={undefined}
alt=""
errorContent={<EmptyState title="No cover yet" />}
aspectRatio="1/1"
/>Sealed wrapper around <img> that owns the loading skeleton, one-shot fallback retry, error chrome, and CLS-prevention slot. Native loading="lazy" by default; flips to eager + fetchpriority="high" when priority is set. aspectRatio ("16/9", "1/1", etc.) forwards to CSS aspect-ratio on the wrapper. rounded cva variant: none / sm / default / lg / full.
Composition: errorContent slot replaces the default error chrome; fallbackSrc provides one-shot retry. onError fires AFTER the internal retry, so consumer-side multi-step fallback chains keep working — the primitive resyncs when src changes.
A11y: aria-busy="true" during load, role="img" aria-label={errorLabel} on error (suppressed for decorative alt="" images). Honors prefers-reduced-motion automatically (load crossfade collapses to instant).
Markdown
import { Markdown } from "@aisquare/pretty";
<Markdown content={message.text} />
// Custom anchor handling — shallow-merged with the primitive's defaults
<Markdown
content={message.text}
components={{
a: ({ href, children, ...rest }) => {
if (href?.startsWith("#resource:")) return <ResourceLink href={href}>{children}</ResourceLink>;
return <a href={href} target="_blank" rel="noopener noreferrer" {...rest}>{children}</a>;
},
}}
/>Sealed renderer for chat-aligned markdown. GFM (tables, strikethrough, task lists, autolinks), soft line breaks, sanitized HTML with language-* className preserved on code, syntax-highlighted fenced blocks (react-syntax-highlighter + Prism + oneDark), copy buttons, inline code in bg-muted, scrollable tables, and new-tab anchors. Empty / whitespace-only content returns null.
Composition seam is the components prop — type Partial<Components> from react-markdown, shallow-merged with the primitive's defaults. Use it to intercept specific element renderers (most commonly a for app-specific routing); everything else stays default. There is no asChild — <Markdown> renders content from a string, not a child element.
Streaming chat: pass streaming to render an inline-block blinking caret as the last child of the prose container — pulses while live tokens arrive, disappears the moment you flip the prop:
<Markdown content={message.text} streaming={isResponseStreaming} />Empty / whitespace-only content still returns null even with streaming — surface a <TypingIndicator> for the pre-first-token gap. The cursor honors prefers-reduced-motion (steady block instead of pulse) and is aria-hidden so screen readers aren't bombarded with "blinking" chatter — the streaming cue comes from the consumer's aria-live region (typically <MessageRow role="assistant">). Re-renders during streaming are cheap thanks to React.memo + forwardRef for direct ref access to the prose container.
The rehype-sanitize schema extension that allows language-* className is exported as markdownSanitizeSchema for consumers who need to extend it further.
Truncate
import { Truncate } from "@aisquare/pretty";
<Truncate>{customUrl}</Truncate> // 1-line ellipsis (<span>)
<Truncate as="p" lines={2}>{description}</Truncate> // 2-line clamp
<Truncate tooltipBody={fullName}> // override body for icon children
<span className="inline-flex items-center gap-1">
<Icon /> {nick}
</span>
</Truncate>Truncates text at N lines AND auto-renders an <AikoTooltip> showing the full text only when overflow is actually present. Drops the need for consumers to plumb useIsTruncated state into a tooltip themselves. lines={1} (default) maps to Tailwind truncate; lines={2|3|4} maps to line-clamp-{n}. Polymorphic via as (default "span").
Tooltip semantics: when children is a string or number, that's the default tooltip body. When children is a non-string ReactNode, pass tooltipBody explicitly to opt in (we don't stringify arbitrary nodes). disableTooltip skips the wrapper entirely. The wrapper stays mounted across resize-driven truncation flips with open={false} pinned when not truncated, so transient resizes don't remount the trigger child.
SSR-safe: ResizeObserver gated behind useIsomorphicLayoutEffect, so server-rendered markup is just the bare element with truncation classes; the tooltip wrap evaluates after first client measurement.
For 1-line truncation to take effect, the parent should be a flex container with min-w-0 (or any block parent with constrained width) — same constraint as raw truncate. Multi-line clamp needs no parent constraint. Requires <TooltipProvider> at the app root (per <AikoTooltip>).
Chat anatomy
ChatLog
import { ChatLog, MessageRow, ScrollToBottomButton, useScrollToBottom } from "@aisquare/pretty";
function ChatScroller({ messages, loadOlder, isLoadingOlder, unreadCount }) {
const { scrollRef, sentinelRef, isAtBottom, scrollToBottom } = useScrollToBottom<
HTMLDivElement,
HTMLDivElement
>();
return (
<div className="relative h-full">
<ChatLog
ref={scrollRef}
onLoadOlder={loadOlder}
isLoadingOlder={isLoadingOlder}
empty={<EmptyState title="Say hi to AIKO" />}
>
{messages.map((m) => (
<MessageRow key={m.id} role={m.role}>
<Markdown content={m.text} />
</MessageRow>
))}
<div ref={sentinelRef} aria-hidden />
</ChatLog>
<ScrollToBottomButton
visible={!isAtBottom}
unreadCount={unreadCount}
onClick={() => scrollToBottom()}
/>
</div>
);
}Scroll container for a conversation of <MessageRow> children. Owns the right CSS for an append-only chat surface — vertical overflow with scrollbar-gutter: stable so chip rows don't jiggle, and overflow-anchor: auto so newly-rendered messages don't yank the user's reading position. Two density variants — comfortable default and compact for tighter rails.
onLoadOlder adds a 1px top sentinel that fires the callback once when scrolled into view (rate-limited to 250ms so layout shifts don't double-fire). isLoadingOlder surfaces an AIKO-voiced "Loading earlier messages" status row above the messages and latches the sentinel to prevent re-firing during the load. loadingOlderLabel overrides the copy.
empty slot renders when children is null / undefined / false / an empty array — drop in an <EmptyState> for the pre-first-message moment.
Forwards ref to the scroll container so you can wire useScrollToBottom's scrollRef directly. Virtualization-friendly: a single overflow scroller with a flat children stack means react-virtuoso / @tanstack/react-virtual can wrap without structural change.
ScrollToBottomButton + useScrollToBottom()
import { ScrollToBottomButton, useScrollToBottom } from "@aisquare/pretty";
function ChatScroller({ messages, unreadCount }) {
const { scrollRef, sentinelRef, isAtBottom, scrollToBottom } = useScrollToBottom<
HTMLDivElement,
HTMLDivElement
>();
return (
<div ref={scrollRef} className="relative h-full overflow-y-auto">
{messages.map((m) => (
<MessageRow key={m.id} {...m} />
))}
<div ref={sentinelRef} aria-hidden />
<ScrollToBottomButton
visible={!isAtBottom}
unreadCount={unreadCount}
onClick={() => scrollToBottom()}
/>
</div>
);
}The hook owns IntersectionObserver-based at-bottom detection plus a scrollToBottom({ smooth }) action. The button is the visible "Jump to latest" affordance — absolutely positioned by default (absolute bottom-3 left-1/2 -translate-x-1/2 z-10), so wrap it inside a position: relative scroller. Override placement via className for sidebar / right-aligned use cases.
unreadCount renders a small badge in the top-right corner ("3", "99+"). The label folds into aria-label so SR users hear "Jump to latest, 3 unread". Default 180ms fade+slide entrance; motion={false} and prefers-reduced-motion skip it.
For non-default content (e.g. a <TypingIndicator /> while AIKO is composing), pass children: <ScrollToBottomButton ...><TypingIndicator size="sm" /></ScrollToBottomButton>.
ContextBar
import { ContextBar, AikoMark, Button } from "@aisquare/pretty";
import { LogOut } from "lucide-react";
// Bordered banner above the chat scroller, sticky-wrapped by the consumer
<div className="sticky top-0 z-10">
<ContextBar
lead={<AikoMark size="xs" />}
context="AIKO is in Founders & Friends"
actions={
<Button variant="ghost" size="sm" onClick={exitExperience}>
<LogOut className="size-3.5" />
Exit
</Button>
}
/>
</div>
// Flatter inline variant — tucks under a chat header
<ContextBar
variant="muted"
context="AIKO knows you are looking at the Vault"
actions={
<Button variant="ghost" size="sm" onClick={clearScope}>Clear</Button>
}
/>"AIKO knows you're on X" banner that signals which scope the agent has loaded. Three slots — lead (conventionally <AikoMark>), context (the label, single-line truncated), actions (Clear / Edit / Exit-experience). Two variants — default bordered card surface, muted flatter pill.
The primitive renders as a regular block-level element. Sticky behavior is the consumer's call — wrap with sticky top-0 z-10 to ride the scroller. role="status" + aria-live="polite" so screen readers announce scope changes; aria-label infers from context when it's a string, otherwise falls back to "Chat context" (override per surface). Default 240ms fade+slide-down entrance; motion={false} and prefers-reduced-motion skip it.
WizardStageIndicator
import { WizardStageIndicator, ContextBar, AikoMark } from "@aisquare/pretty";
<WizardStageIndicator stages={["Upload", "Review", "Publish"]} current={1} />
<WizardStageIndicator
stages={[
{ id: "upload", label: "Upload" },
{ id: "review", label: "Review" },
{ id: "publish", label: "Publish" },
]}
current="review"
variant="labels"
/>
// Composing inside <ContextBar>:
<ContextBar
lead={<AikoMark size="xs" />}
context="AIKO is in Founders & Friends"
actions={
<WizardStageIndicator
stages={["Upload", "Review", "Publish"]}
current={1}
aria-label="AIKO is on Step 2 of 3, Review"
/>
}
/>;Read-only "you are here" breadcrumb for multi-stage flows. Conventionally renders inside <ContextBar> (in the actions slot) so AIKO's loaded scope can show stage progression without bespoke per-page UI. Two variants — compact (default — dots + "Step N of T · Label" line; fits inside ContextBar) and labels (full breadcrumb with completed / current / pending icons + connectors; suitable for page headers).
Stages accept string[] for ergonomics or {id, label}[] when you need stable ids for current lookup. current accepts a 0-based index OR a stage id (string); out-of-range numbers clamp, unknown ids fall back to 0. Returns null on empty stages — no need to gate at the call site.
A11y: role="progressbar" with aria-valuenow / aria-valuemax / aria-valuemin / aria-label. Default aria-label is voice-neutral ("Step N of T: Label") so non-AIKO consumers get a sensible SR announcement; AIKO surfaces pass an explicit override (aria-label="AIKO is on Step 2 of 3, Review"). The labels variant also adds visually-hidden (complete) / (current) / (pending) text per stage so SR users get the full state without relying on icons.
Distinct from <Stepper> (above): Stepper drives interactive multi-step flows with content panels + keyboard navigation; WizardStageIndicator is the read-only display of where the consumer state machine has the user. Pick Stepper when the user controls the step (walkthrough, recovery flow); pick WizardStageIndicator when consumer state controls the step and you want to surface "where am I" inline.
MessageRow
import { MessageRow, AikoMark, Avatar } from "@aisquare/pretty";
// User message — right-aligned bubble, soft primary tint
<MessageRow
role="user"
avatar={<Avatar src={user.avatar} fallback={user.initials} />}
actions={<CopyButton onClick={...} />}
timestamp={<time dateTime={msg.createdAt}>2 min ago</time>}
>
Hi AIKO, what can you do?
</MessageRow>
// Assistant — left-aligned bare text, AIKO mark on the leading edge
<MessageRow role="assistant" avatar={<AikoMark />} actions={<CopyButton />}>
<Markdown content={msg.text} />
</MessageRow>
// Tool — left-aligned host for a ToolCard
<MessageRow role="tool">
<ToolCard label="search_experiences" status="success" args={...} result={...} />
</MessageRow>
// System — full-width centered divider
<MessageRow role="system">Returned to AIKO chat</MessageRow>
// Error — destructive surface with retry slot
<MessageRow role="error" actions={<RetryButton onClick={resend} />}>
Connection interrupted, retry your message
</MessageRow>Five role variants (user / assistant / tool / system / error) drive the layout — slot props (avatar, actions, timestamp) compose the same vocabulary across roles. Default 240ms cubic-bezier(0.16, 1, 0.3, 1) fade+slide entrance; opt out per call with animate={false}. prefers-reduced-motion forces static.
system and error rows carry role="status" so SR users hear thread-state cues. Hover-revealed action groups respond to :focus-within too, so keyboard users see the same affordance.
For the placeholder while AIKO composes, render <TypingIndicator> inside <MessageRow role="assistant"> rather than reaching for a custom surface.
senderName renders a small label above the bubble — useful for multi-agent threads where distinguishing tool names or assistant personas matters. Supported on assistant and tool roles.
CollapsedMessageBody
import { CollapsedMessageBody } from "@aisquare/pretty";
// Short bodies pass through unchanged — no collapse chrome
<CollapsedMessageBody>
AIKO is ready. Ask me anything.
</CollapsedMessageBody>
// Long bodies collapse to a one-line headline + toggle
<CollapsedMessageBody threshold={280}>
{longAssistantMarkdown}
</CollapsedMessageBody>
// Override the auto-extracted headline
<CollapsedMessageBody headline="Summary of your weekly analytics">
{veryLongText}
</CollapsedMessageBody>
// Open by default
<CollapsedMessageBody defaultOpen>
{assistantReply}
</CollapsedMessageBody>Inline collapse/expand for long chat message bodies. Bodies at or below threshold (default 280 chars) render children directly with no chrome — safe to drop in every <MessageRow> without visible change for short replies. Above threshold, shows a one-line headline + "N+ more chars" toggle; body DOM is unmounted when collapsed so large off-screen trees stay light. Headline auto-extracted from children text content (strips markdown headers, takes first line, trims to 140 chars); override via headline. aria-expanded + aria-controls wire the toggle to the body region for screen readers.
ToolCard
import { ToolCard, Markdown } from "@aisquare/pretty";
import { Search } from "lucide-react";
// Live tool-call card — pending while running, auto-collapses on success
<ToolCard
icon={<Search className="size-3.5" />}
label="search_experiences"
status="pending"
args={<pre className="font-mono text-[11px]">{JSON.stringify({ q: "ml" }, null, 2)}</pre>}
/>
// Completed call — body collapsed by default, click to expand
<ToolCard
label="upload_to_vault"
status="success"
args={<>file.pdf · 1.4 MB</>}
result={<Markdown content={`Uploaded. **document_id: 42**`} />}
/>
// Errored call — body open by default so the user sees the failure
<ToolCard
label="delete_studio"
status="error"
result={<>permission denied</>}
/>
// Compact label-only row (no body sections — disclosure trigger disabled)
<ToolCard label="get_studio_stats" status="success" />Collapsible tool-call surface for AIKO chat. Three statuses (pending / success / error) drive a colored status pill (Running / Done / Failed) and the chrome tint. Body holds optional args and result slots — consumers pass pre-formatted ReactNodes, typically <Markdown> for the result and a <pre>{JSON}</pre> block for the args.
Disclosure follows WAI-ARIA: trigger button has aria-expanded + aria-controls, body region has aria-labelledby pointing to the label cell. Body opens by default while pending or errored, collapsed once success lands so the assistant's reply stays the focus. Auto-collapses on the pending → success transition (uncontrolled mode); user clicks after that stick. Pass defaultOpen to override the heuristic, or open + onOpenChange for full control. motion={false} and prefers-reduced-motion skip the height animation and the pending pulse.
Composition: drop inside <MessageRow role="tool"> for proper avatar-rail alignment within the chat scroller.
SuggestedReplies
import { SuggestedReplies } from "@aisquare/pretty";
<SuggestedReplies
replies={["Show me my top studios", "What can I do here?", "Switch workspace"]}
onSelect={(text) => composer.fill(text)}
/>
// Compact variant for tighter rails
<SuggestedReplies
replies={msg.actionChips ?? []}
variant="muted"
align="end"
onSelect={sendNow}
/>
// Disable while AIKO is composing the next reply
<SuggestedReplies
replies={msg.actionChips ?? []}
onSelect={sendNow}
disabled={isThinking}
/>Pill-chip row that lives under the last assistant message. Two visual variants (default bordered pill on bg-card, muted flatter pill on bg-muted). align="start" (default) or "end" for right-aligned rails. disabled dims every chip and blocks onSelect. Returns null for an empty replies array — drop in without an outer length guard. role="group" with a default aria-label="Suggested replies". Default 24ms-staggered fade-in entrance; motion={false} and prefers-reduced-motion skip the stagger.
CitationRow
import { CitationRow } from "@aisquare/pretty";
// Inline anchor — drops between sentences with hover preview
<p>
Climate science is broad
<CitationRow
index={1}
title="Earth science: a 5-step path"
summary="A quick overview of feedback loops and forcings."
badge="quest"
thumbnail="https://example.com/quest-thumb.png"
href="/quest/earth-science"
/>.
</p>
// Row variant — full card in a "Sources" list at the bottom of a message
<div className="space-y-2">
{message.referencedResources.map((r, i) => (
<CitationRow
key={r.uid}
variant="row"
index={i + 1}
title={r.title}
summary={r.focus_area}
thumbnail={r.image_url}
badge={r.experience_type}
onActivate={() => openResource(r)}
/>
))}
</div>
// asChild — slot through to a router Link
<CitationRow asChild index={2} title="Linked elsewhere">
<Link to={`/q/${id}`} />
</CitationRow>Footnote-style citation for resources cited in an assistant turn. Two variants for two render contexts: anchor (default) is an inline [N] chip that drops between sentences and reveals a Radix-Tooltip-anchored preview on hover/focus; row is the full card on its own line, used in a "Sources" list at the bottom of an assistant message. Both share the same content vocabulary — index, title, summary?, thumbnail? (URL or ReactNode), badge? (string or ReactNode).
Activation: pass onActivate for click-to-open behavior, href for a real <a> (so middle-click + cmd-click follow the URL natively), or both — onActivate wins on plain left-clicks; modifier-clicks fall through to the browser. asChild slots through to a custom anchor (a router <Link>, an analytics-instrumented wrapper). The anchor's accessible name defaults to "Citation N: <title>"; override with aria-label.
The inline anchor is sized to drop into prose without breaking line height (h-[18px], text-[11px], tabular-nums). Tooltip Provider is self-contained — drop a CitationRow into any tree without setup.
Motion / delight
AnimatedCounter
<AnimatedCounter value={subscriberCount} />
<AnimatedCounter value={revenue} format={(n) => `$${n.toFixed(2)}`} duration={1200} />Respects prefers-reduced-motion.
Typewriter
<Typewriter
phrases={["AI Studios.", "experiences.", "communities."]}
typeSpeed={60}
holdDuration={1800}
/>Cycles through phrases with a typewriter effect. Reduced-motion shows the longest phrase statically.
TypewriterText
import { TypewriterText } from "@aisquare/pretty";
// Types one string to completion, then fires onDone
<TypewriterText
text="Your studio analytics are ready."
speed={15} // ms per character (default 15)
onDone={() => setStreaming(false)}
/>
// Key-remount to replay (common pattern for demos and retries)
<TypewriterText key={replayKey} text={assistantFirstLine} />One-shot character-by-character type-out. Unlike <Typewriter> (phrase loop), this types a single string to completion and fires onDone. Designed for first-token streaming reveals in chat UIs. Blinking caret appears while typing, disappears when done. prefers-reduced-motion renders the full string immediately and fires onDone synchronously on mount. Remount via a key change to replay.
StaggeredText
<StaggeredText
text="The platform is thinking"
by="word" // "char" | "word"
delay={60} // ms between staggered tokens
shimmer // optional gradient sweep over each token
/>;
// Imperative: replay on a state change
const ref = useRef<StaggeredTextHandle>(null);
ref.current?.play();
ref.current?.reset();Animated text reveal — fade-in-stagger per char/word. Optional shimmer pass for AI-thinking surfaces. aria-live announces the full string at once for screen readers (no per-token noise). Reduced-motion renders text statically.
AnimatedList
<AnimatedList
items={[
{ id: 1, content: "Aiko's Studio is now live." },
{ id: 2, content: "Daniel subscribed." },
{ id: 3, content: "New comment on Quest #42." },
]}
animationType="slide"
enterFrom="top"
hoverEffect="scale"
fadeEdges
pauseOnHover
height="320px"
/>
// Bounded notification stack — older items fall off when maxItems is hit
<AnimatedList
items={notifications}
autoAddDelay={4000}
maxItems={5}
swipeToDismiss
onDismiss={(item) => analytics.dismiss(item.id)}
renderItem={(item) => (
<NotificationRow icon={item.content.icon} title={item.content.title} />
)}
/>Vertically-stacked list with motion-driven entrance / exit, optional auto-add cycling, swipe-to-dismiss, ripple/press click effects, and fade-edge masks. Adopted from React Bits Pro and adapted to pretty's anatomy — all hardcoded palette colors swapped for theme tokens (bg-card, border-border, text-foreground, text-destructive); prefers-reduced-motion collapses entrance / exit to instant transitions and falls back to fade for the bouncy spring animation; forwardRef on the wrapper for layout measurement. Use for the Studio Quick-Filter rail, Aiko notification stacks, recent-activity feeds, anywhere you want a list that animates through state.
AnimatedContent
<AnimatedContent direction="vertical" distance={80} delay={0.2}>
<h2>Build experiences your audience trusts.</h2>
</AnimatedContent>
<AnimatedContent direction="horizontal" reverse distance={120}>
<FeatureBlock />
</AnimatedContent>Scroll-triggered entrance — directional slide + optional scale + fade once the element crosses a viewport threshold. GSAP-powered. Adopted from React Bits OSS and adapted to pretty's anatomy: forwardRef, cn for className merge, window as the default scroll target (the upstream's hardcoded snap-main-container lookup is gone). prefers-reduced-motion paints the final state immediately and still fires onComplete so consumer state machines resolve. Optional disappearAfter hides the element on a timer once revealed. Requires the gsap peer-optional dep.
FadeContent
<FadeContent duration={0.8} delay={0.1} blur>
<p>Numbers tell the story.</p>
</FadeContent>The quieter sibling — pure opacity ramp (with optional blur ramp) on scroll-into-view. Same anatomy + reduced-motion handling as AnimatedContent. Use when "this section just slid into view" is the right read but you don't want directional movement or scale. All time props (duration, delay, disappearAfter) are seconds — pretty unified them since the upstream's mixed ms/s heuristic was ambiguous. Requires the gsap peer-optional dep.
FeatureTabs
import { FeatureTabs, type FeatureTab } from "@aisquare/pretty";
import { Layers, Sparkles, Bot } from "lucide-react";
const tabs: FeatureTab[] = [
{ value: "designers", label: "Designers", icon: <Layers />, content: <DesignersPane /> },
{ value: "builders", label: "Builders", icon: <Sparkles />, content: <BuildersPane /> },
{ value: "audience", label: "Audience", icon: <Bot />, content: <AudiencePane /> },
];
<FeatureTabs tabs={tabs} defaultValue="designers" />;Persona-card tab selector with an animated radial-gradient indicator that slides between active triggers, plus motion-driven content fade-up on swap. Adopted from @reactbits-pro/features-9. Composes @radix-ui/react-tabs underneath for full keyboard navigation (← / → between triggers, Home / End, Enter / Space activate), role="tablist" / role="tab" / role="tabpanel" semantics, and ARIA wiring out of the box. Active-card halo uses --accent so the indicator follows per-studio brand retinting via <StudioBrand>. Reduce-motion users get an instant snap. Use for marketing landing sections (hero · personas · features grid), studio dashboard "switch the active workspace lens" patterns, or any consumer-facing surface that needs a top-row category selector with animated content swap. The examples/playground itself is built on this primitive.
MorphingRail / MorphingRailStage
import { MorphingRail, MorphingRailStage } from "@aisquare/pretty";
// The dock — a stack of morphing circles (parked items / quick switcher).
<MorphingRail
items={items} // [{ id, icon, label, accentColor? }]
activeId={activeKey} // optional — which one is currently in the stage
onSelect={(id) => restore(byId[id])}
onDismiss={(id) => dismiss(byId[id])}
morphGroup="experience"
/>
// The stage wrapper — same morphGroup, the active item's id.
<MorphingRailStage morphGroup="experience" morphId={activeKey}>
<YourFullStageContent />
</MorphingRailStage>Paired primitive that morphs an item between a tile in a side rail and a full stage panel via Framer layoutId, with macOS-dock-style cursor magnification on the rail side. Designed to replace hand-rolled morphs that animate flexGrow / flexBasis on the stage container — a layout-trigger that fights Framer's FLIP and re-paints heavy children (chat / video / markdown stages) every frame. MorphingRailStage is transform-only on enter / exit (opacity and scale) and applies contain: paint so the inner content does not get re-rasterised mid-morph; MorphingRail mirrors the FLIP from the dock side using the same layoutId. Both halves share the namespace via morphGroup, and the rail item id must match the stage's morphId. Item silhouette is configurable (shape: "circle" | "rounded" | "square", default rounded so it reads consistently with rounded-square dock buttons). Items pass an accentColor (6-digit hex) and the dock auto-tints the tile background (~15% alpha) and border (~40%); fall back to accentClassName when the consumer's accent is a CSS variable rather than a hex. Cursor magnification (adapted from React Bits Pro navigation-4) tracks mouseY / mouseX against each tile's center via useTransform + useSpring (mass 0.1, stiffness 150, damping 12) and lifts the tile under the cursor to ~1.2× — disable per-instance with magnify={false} when the dock should stay static. Toolbar semantics (role="toolbar", aria-pressed on the active tile), per-tile <AikoTooltip> on the off-axis side, optional sibling dismiss button shown on hover / focus with a 90° rotate-on-hover X. Each restore button carries data-morph-id={item.id} so consumers can re-focus across the morph (the rail and the stage live in different parts of the React tree, so DOM focus does not survive a swap on its own — wire a small requestAnimationFrame re-focus when the consumer needs it). Honors prefers-reduced-motion — collapses both the FLIP and the magnify to instant. Self-wraps <TooltipProvider>. Drop-in for AIStudio v3's ExperienceBubble + the motion.div wrapper around CenterStage.
SpotlightCard
import { SpotlightCard } from "@aisquare/pretty";
<SpotlightCard intensity="default" color="primary">
<h3>Aiko's Studio</h3>
<p className="text-muted-foreground">Live now</p>
</SpotlightCard>;Surface wrapper with a cursor-following radial-glow overlay, adopted from React Bits Pro @reactbits-starter/spotlight-card-tw. Glow color drives from theme tokens (--primary / --accent / --secondary) so per-studio brand retinting via <StudioBrand> flows through automatically — never a hardcoded hex. intensity (subtle / default / strong) scales the gradient opacity; radius and padding variants drop the chrome into common card silhouettes; spread (default 400px) tunes the gradient radius. Reduce-motion users get a centered static glow on hover instead of live cursor tracking — the affordance stays, the motion stops. SSR-safe (no window at module scope), forwardRef<HTMLDivElement> for layout measurement. Use for Quest cards, studio tile grids, featured-experience cards, and Aiko nudge surfaces where the spotlight reads as "the assistant is paying attention to where you are." Wrap interactive elements (Link, button) inside, not around — asChild is intentionally a v2 concern (the two-layer overlay structure doesn't collapse cleanly into a single Slot child).
BorderGlow
import { BorderGlow } from "@aisquare/pretty";
<BorderGlow color="primary" intensity="default">
<StudioCard />
</BorderGlow>;
// Asymmetric color — semantic statuses
<BorderGlow color="success" intensity="subtle" pulse={false}>
<PlanCard tier="active" />
</BorderGlow>;
// Slot onto a Link — outer element is the anchor
<BorderGlow color="warning" asChild>
<a href="/upgrade">Upgrade</a>
</BorderGlow>;Steady or breathing halo around a surface's perimeter — sibling to the internal <BorderBeam> (sweeping arc on <Badge variant="premium">). intensity (subtle / default / strong) scales spread / blur / alpha; color (primary / accent / success / warning) drives the halo from hsl(var(--token) / X) so per-studio retinting via <StudioBrand> cascades through automatically. pulse defaults to true and reuses the preset's animate-pulse-glow keyframe by overriding --shadow-glow + --shadow-glow-aurora per instance — one keyframe drives every color × intensity combination. Reduce-motion users get a static halo (the affordance stays visible; the breathing stops). asChild slots the glow onto a <Link> / <button> / etc. via Radix Slot. Composes with <BorderBeam> on the same host element for a "premium + active" combination — beam sweeps the perimeter, glow is the steady backdrop. Use for active studio rail cards, subscribed-state community cards, Aiko nudge surfaces ("I'm here, paying attention"), and premium / upgrade prompts.
Stepper
import { Stepper } from "@aisquare/pretty";
// Self-rendering — pass steps + (optional) per-step content
<Stepper
steps={[
{ label: "Vault", description: "Where uploads land" },
{ label: "Publish", description: "One click to ship" },
{ label: "Analytics", description: "See who's tuned in" },
]}
defaultValue={0}
onComplete={() => closeWalkthrough()}
/>;
// Headless — render-prop owns the layout (content panel + footer buttons)
<Stepper steps={steps} value={step} onValueChange={setStep}>
{({ step, total, next, prev, complete }) => (
<footer className="flex justify-between gap-3 mt-4">
<Button variant="ghost" onClick={prev} disabled={step === 0}>
Back
</Button>
<Button onClick={step === total - 1 ? complete : next}>
{step === total - 1 ? "Done" : "Next"}
</Button>
</footer>
)}
</Stepper>;Multi-step indicator with optional content panel + WAI-ARIA tablist keyboard navigation. Drives multi-step flows (first-run walkthroughs, quest authoring tutorials, paywall onboarding, Aiko-led recovery) that previously lived as ad-hoc useState({ step: 0..3 }) per surface. Variants: orientation (horizontal / vertical), size (sm / default / lg), indicator (numbers / dots / progress). Controlled (value + onValueChange) and uncontrolled (defaultValue) modes; interactive: false swaps role="tablist" for role="progressbar" so display-only flows pick up the right ARIA shape automatically. Keyboard nav follows the WAI-ARIA tablist pattern: ← / → for horizontal, ↑ / ↓ for vertical, Home / End. Reduce-motion users get an instant step transition. The render-prop slot is the composition seam for <AikoTip> integration: <AikoTip body={<Stepper steps={...}>{({ next }) => ...}</Stepper>}> plumbs Aiko-led multi-step recovery without prop-drilling.
Aiko (brand mascot + helper surfaces)
The AISquare AI assistant lives across these primitives. They share a single visual anatomy (paper-shell silhouette, breathing AikoMark, anti-intrusion contract) and one mental model: the platform has a personality, and it shows up where it can help.
AikoMark
<AikoMark size="md" />
<AikoMark size="2xl" animated={false} /> // resting frame
<AikoMark aria-label="AISquare assistant" /> // announce; otherwise decorativeThe brand mascot. One file, single source of truth — every Aiko surface (Tip, Sheet, Whisper, Loader, marketing) composes this. Sizes: xs / sm / md / lg / xl / 2xl (24 / 28 / 32 / 40 / 56 / 80 px). Honors prefers-reduced-motion (resting frame). To change Aiko's look across the whole platform, edit only AikoMark.tsx.
AikoTip
<AikoTip
trigger="click" // "click" | "hover"
variant="rail" // label | info | single | rail | pages | pages-passive
title="Recall lives across chats"
body="Aiko remembers the last 50 turns to keep context tight."
actions={[
{ label: "Got it", primary: true },
{ label: "Snooze 30 min", onClick: snooze },
]}
>
<Button variant="outline">What's recall?</Button>
</AikoTip>The Aiko helper bubble. Six content silhouettes share one paper shell + breathing AikoMark. Built on Radix Popover so positioning, focus, ESC, and click-outside are handled. Drop-in replacement for traditional tooltips when the helper has personality + content + (optionally) actions. trigger="hover" makes it a label-style tooltip instead of a click-to-open popover.
AikoTooltip
<AikoTooltip body="Save"> // → minimal: no mark, light shell
<IconButton onClick={save} />
</AikoTooltip>
<AikoTooltip body="This will permanently delete the experience."> // → full: branded card with mark
<IconButton onClick={remove} />
</AikoTooltip>
<AikoTooltip title="Save" body="Saves and exits the editor" side="right"> // → full (header)
<IconButton />
</AikoTooltip>
<AikoTooltip body="Save" tone="full"> // pin full when the heuristic guesses wrong
<IconButton />
</AikoTooltip>The drop-in replacement for shadcn-style <Tooltip><TooltipTrigger><TooltipContent> compositions. The visual treatment auto-adapts via the tone heuristic: short string body, no title → light label-style tooltip; longer body, JSX node, or title → branded paper card with the AikoMark. Brand the act, not the chrome — short button labels stay quiet so substantive AIKO comms stand out. Override with tone="full" | "minimal" when you need to pin a style. Hover-only — for click-to-open helpers reach for <AikoTip> directly. Built on Radix Tooltip so the consumer's <TooltipProvider> configures cross-tooltip delay-skipping and free aria-describedby wiring. Default delayDuration={400} aligns with industry norms (Linear ≈500, GitHub ≈400) so cursor-traversal doesn't trigger noise.
AikoHint
<AikoHint title="Why this matters" body="One sentence. Aiko keeps it brief." />
<AikoHint
title="Heads up"
body="This will overwrite the current draft."
actions={[{ label: "Got it" }]}
channelId="form.experience-name"
/>Small Aiko mark that teases on hover and reveals on click. Hover (120ms delay) shows a tiny label tip with the teaser text (default "Aiko has a tip"). Click opens a fuller info card with title + body + optional actions. Use to replace always-visible "subtext" / FormDescription patterns where the explanation should be one click away — quiet by default, informative on demand. Built on Radix Popover. Honors the anti-intrusion contract when channelId is set + wrapped in <AikoProvider>.
AikoWhisper / AikoNudge / AikoIntervene
const ref = useRef<HTMLButtonElement>(null);
const aiko = useAikoSignal("checkout.confused");
useAikoDwellSignal(ref, { channelId: "checkout.confused", ms: 2000 });
useAikoRageClickSignal(ref, { channelId: "checkout.confused", threshold: 3 });
<Button ref={ref}>Submit order</Button>;
<AikoWhisper anchorRef={ref} open={aiko.canShow && aiko.level === "whisper"}
channelId="checkout.confused" onExpand={() => aiko.signal("rage-click")}
previewText="Aiko has a hint. Click to see it." />
<AikoNudge anchorRef={ref} open={aiko.canShow && aiko.level === "nudge"}
channelId="checkout.confused">
Looks confusing — try <em>refreshing your library</em>.
</AikoNudge>
<AikoIntervene anchorRef={ref} open={aiko.canShow && aiko.level === "intervene"}
channelId="checkout.confused" title="Need a hand?" body="..."
primaryAction={{ label: "Try this", onClick: fix }} />The three escalation surfaces (Phase 3). Whisper is a tiny breathing pip; Nudge is a one-line balloon; Intervene is a full helper card. Drive open from useAikoSignal(channelId).level. All three honor the anti-intrusion contract — closing fires dismiss() which snoozes 30 min, three dismisses → silent for the session, mute persists.
AikoWhisper's optional previewText opens a paper-shelled AikoTip (label variant) on hover/focus — progressive disclosure without committing to escalation.
AikoProvider + signal hooks
import {
AikoProvider,
useAiko,
useAikoChannel,
useAikoSignal,
useAikoDwellSignal,
useAikoRageClickSignal,
useAikoHoverLoopSignal,
} from "@aisquare/pretty";
// Wrap once near app root
<AikoProvider snoozeMinutes={30} sessionDismissThreshold={3}>
<App />
</AikoProvider>;
// Read or control any channel
const channel = useAikoChannel("library.refresh-tip");
channel.canShow; // false if muted, snoozed, or session-silent
channel.dismiss(); // counts as one dismiss
channel.snoozeFor(60_000);
channel.mute(); // until unmute()
channel.reset();
// Detect intent → drive the level state machine
const sig = useAikoSignal("library.refresh-tip");
sig.level; // "off" | "whisper" | "nudge" | "intervene"
sig.signal("rage-click"); // user-triggered escalation
sig.resetLevel();
// Auto-detectors — point at any element ref
useAikoDwellSignal(ref, { channelId: "...", ms: 2000 });
useAikoRageClickSignal(ref, { channelId: "...", threshold: 3, windowMs: 1500 });
useAikoHoverLoopSignal(ref, { channelId: "...", loopThreshold: 3 });The state machine + persistence layer behind every Aiko surface. Provider owns per-channel mute/snooze/session-dismiss-count; hooks expose typed read/write APIs. Detectors translate user behavior (long hover, rage-click, hover loops) into level escalations without you wiring it. Full anti-intrusion contract spec at docs/contracts/aiko-intrusion.md.
Theming
ThemeProvider
import { ThemeProvider, useTheme } from "@aisquare/pretty";
<ThemeProvider defaultTheme="system" storageKey="aisquare-theme">
<App />
</ThemeProvider>;Adds light or dark to <html>, persists the choice in localStorage, and follows the OS prefers-color-scheme when set to "system". Read or change inside the tree:
const { theme, resolvedTheme, setTheme } = useTheme();
<Button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>Switch</Button>;For zero-flash apps (Next.js, Remix, Astro), inline the script in <head>:
import { getThemeScript } from "@aisquare/pretty";
<script dangerouslySetInnerHTML={{ __html: getThemeScript() }} />;StudioBrand
Per-studio brand cascade. Sets CSS vars on a wrapper so every primitive inside inherits the studio's brand, with no per-primitive code change.
<StudioBrand
primary="280 70% 50%"
accent="220 80% 60%"
radius="14px"
font="'Geist Sans', sans-serif"
>
<Button>Visit studio</Button>
</StudioBrand>Two studios side-by-side stay scoped:
<div className="grid grid-cols-2">
<StudioBrand primary="280 70% 50%">
<App />
</StudioBrand>
<StudioBrand primary="14 90% 55%">
<App />
</StudioBrand>
</div>Color values accept HSL triplets ("280 70% 50%") or wrapped strings ("hsl(280 70% 50%)"); both work. Use vars to set arbitrary CSS variables (--shadow-glow, custom tokens). Use asChild to apply the brand to your own element with no extra wrapper. Composes with ThemeProvider: light/dark applies globally, brand vars override locally.
Office surface primitives (iter-37-F2)
Seven primitives for the AISquare Office multi-agent collaboration surface. All compose existing pretty primitives where possible.
RailItem
Left-rail session navigation row. Composes SlugTag + CornerMark + PulseDot.
import { RailItem } from "@aisquare/pretty";
<RailItem slug="api-stabilization" hue="#1F8A5B" status="open" unread={3} summary="v2 contract" />
<RailItem slug="auth-refactor" hue="#D97757" status="stuck" active />
<RailItem slug="init" status="sealed" sealed />PaneHeader
Center-pane top-section layout shell (23px/700 title + status + summary + actions slots).
import { PaneHeader, Tag, Pill } from "@aisquare/pretty";
<PaneHeader
title="#api-stabilization"
status={<Tag label="OPEN" tone="success" />}
summary="Stabilise the v2 API contract."
actions={<Pill label="stuck" tone="destructive" appearance="solid" />}
/>OfficeMsg + sub-components
Office-specific message row. Distinct from MessageRow (role-based): sender identity expressed via SlugTag hue + 2px hue left-rail.
import { OfficeMsg, OfficeMsgTimestamp, OfficeMsgSender, OfficeMsgBody, ThreadRow } from "@aisquare/pretty";
<OfficeMsg slug="founder-0" hue="#D97757" ts="09:55" isOwner>
Synthesis complete. Proposing lock.
</OfficeMsg>
<OfficeMsg slug="founder-9" hue="#1F8A5B" ts="09:57" selfFlag="force-analysis"
thread={{ replyCount: 3, status: "open", loaded: ["f0"] }}>
Tests pass.
</OfficeMsg>F0Card
Founder-0 lock-proposal decision card. cardChrome(coral) surface with 4px left-bar and optional worklog row.
import { F0Card } from "@aisquare/pretty";
<F0Card
ts="10:01"
body="api-stabilization is locked."
worklog={{ id: "w24-032",