"use client" import type { ComponentDocSpec } from "@/lib/design-system/component-doc-types" import { AskLeoButtonIconOnlyPreview, AskLeoButtonRouteActionPreview, AskLeoButtonSizePreview, AskLeoButtonStarPreview, AskLeoButtonStatePreview, AskLeoTogglePreview, LeoMotionStatePreview, } from "@/components/design-system/ask-leo-previews" function ex( section: Omit, children: React.ReactNode, description?: string, ) { return { ...section, children, description } } export const askLeoButtonComponentDoc: ComponentDocSpec = { slug: "ask-leo-button", summary: "Package-owned Leo action that shares the utility launcher's brand border, moving wash, star, and state treatment. Default click toggles the Ask Leo sidebar; pass onClick for a route-local action.", sections: [ ex( { id: "sizes", title: "Sizes" }, , "sm is the chart card header density (h-7, text-xs). lg matches page header actions beside Save.", ), ex( { id: "star", title: "Star treatment" }, , "sm defaults to the static duotone glyph so dense chart headers stay quiet; lg defaults to the animated LeoIcon star. Set animatedStar to override either default.", ), ex( { id: "icon-only", title: "Icon only" }, , "Drops the label for chart selectors and dense rails. ariaLabel becomes the accessible name, and the tooltip carries the same text.", ), ex( { id: "states", title: "States" }, , "Busy swaps the label for busyLabel and sets aria-busy so screen readers announce work in progress. Disabled is for unmet preconditions, not for in-flight requests.", ), ex( { id: "motion", title: "Motion states" }, , "The star is still at rest, plays a one-shot reaction on hover or focus, and loops only while Leo is actually working. Working also turns the whole glyph a slow quarter-turn — 90°, not 360°, because the star is 4-fold symmetric, so the turn lands on itself and the loop has no seam to snap back from. Every looping value is a multiple of one 900ms beat and every stagger comes from the sparkle's fixed position in the clockwise sweep, so the gesture is identical on every play. Motion here is a status signal, not decoration — reach for it only when the state it reports is real.", ), ex( { id: "route-action", title: "Route-local action" }, , "When onClick runs a local action instead of opening the sidebar, set showShortcut to false so the tooltip does not promise the global shortcut.", ), ex( { id: "shell-toggle", title: "Shell launcher (AskLeoLauncher)" }, , "The utility bar uses AskLeoLauncher because shell open, busy, and arrival state differ from a route action. AskLeoButton composes that same package-owned material without replaying the shell arrival. Existing customer utility bars adopt the launcher through the verified ask-leo-launcher migration codemod; customized wiring stops for review instead of being overwritten.", ), ], anatomy: [ { part: "AskLeoButton", description: "Route-action binding over the package-owned AskLeoLauncher material; it supplies action semantics without replaying shell arrival." }, { part: "LeoIcon", description: "Ambient Leo star used when animatedStar is true. No pulsing aura on button chrome." }, { part: "AskLeoShortcutKbds", description: "Tooltip shortcut chips for the sidebar shortcut; suppressed by showShortcut={false}." }, { part: "AskLeoLauncher", description: "Shell sibling for the utility bar, from @exxatdesignux/ui/components/shell/ask-leo-launcher. Unfilled brand-bordered chip via askLeoLauncherChipClass, optional label, full-strength border when open. It owns every element the arrival's stylesheet targets — the glow wrap, the halo, the wash, the star slot, the label — because the pieces only animate as one. open, busy, and onToggle are props: which thread is open, and whether it is mid-answer, is the app's model. showLabel is the density decision, and at false there is no label box for the opening beat to open. introId scopes the once-per-page-load record; null opts out, which is what the previews on this page use so the arrival replays when you scroll to it." }, { part: "Launcher halo", description: "Sibling element sized to the chip, carrying --ask-leo-utility-glow-shadow. An outward shadow paints only outside the border box, so the chip's face never picks up the glow. It takes its corner from --exxat-radius-2, the same variable the chip resolves, so the halo traces the button at either shell density. Only opacity ever animates: its first shadow layer is a hairline that traces the border, and transforming the box carries that hairline off the button's edge. Blooms once and fades to nothing — --ask-leo-glow-rest-opacity is 0, and the keyframe reads that variable rather than a literal so the two cannot drift. Hover and focus do not bring it back." }, { part: "Arrival sheen", description: "A specular highlight in --ask-leo-sheen-color that crosses the chip's border once as it arrives, then leaves. It rides the halo's ::after, masked down to a 1.5px border band so the gradient never reaches the chip's face — which is what keeps it distinct from the face wash rather than doubling up with it. A left-to-right pass, not an arc travelling the perimeter: an arc chasing a border is the indeterminate-progress idiom, which on a launcher reads as loading rather than ready, and on a 3:1 pill a conic sweep spends nearly the whole rotation sliding along the long edges anyway. Light mode takes near-full brand because saturation is what shows on a light bar; dark mode lifts the brand toward white." }, { part: "Launcher wash", description: "AskLeoLauncherWash — the Leo Assist search bar's own drifting lobes, sized to the chip through the blob layer's fit=chip. Not a gradient standing in for them, and not the composer's geometry clipped either: a lobe 96 to 128px tall behind a 32px chip only ever shows its flat middle, so fit=chip scales each lobe to about 1.5x the control and lets its vertical drift cross the chip the way it crosses the composer, with the blur pulled in to match so three hot spots do not fuse into one film. Its own element rather than a pseudo-element, because it is three boxes plus a connective floor and a sheen. A sibling of the chip at z-0 with the chip at z-1, so the lobes pass behind the label: not on the halo, whose animated opacity would multiply theirs, and not on the chip, whose ::before is spent on the resting glow. --ask-leo-chip-blob-opacity is the contrast budget — the composer can afford these lobes at full strength because an input surface sits between them and its text, and the chip cannot. The field is always on and rests at 60 percent of that budget, since chrome idling at its ceiling reads as an alert; data-emphatic spends the rest while Leo is arriving, open, or working, and hover and focus spend it too. The sheen is held for the live states only. Placement offsets exist here as they do on the composer, but on their own range: LEO_LAUNCHER_OFFSET_MAX is 14px against the composer's 120, so the pad can nudge the field within the chip without parking it off the control. The dot overlay is left behind, being texture on type behind a 96px label." }, { part: "Edge light", description: "A second copy of the same field, masked to a ring straddling the chip's border and then blurred, so an arriving lobe lights the outline where it reaches it and blooms about 6px beyond the box. It is the part of the field the wash's overflow:hidden has to throw away, and putting it back is what makes the chip read as holding light rather than as a window cut into a field. Two nested elements because the order matters: the inner one masks the field down to the band with mask-composite: exclude, the outer one blurs the result — blurring first and masking after cuts the glow off at a hard rounded-rect edge, which is the seam the effect exists to avoid. Nothing tracks lobe positions: it is the same lobes at the same size mounted at the same moment, so the bloom sits under its own lobe by construction. Two things decide whether the outline reads as lit by the lobes or as one flat colour, and the ring looks correct without either: the copy runs lobesOnly, which drops the field's connective floor — flat across the width, and read through an 8px band it lights the whole outline evenly — and it paints at z-2, over a chip whose own border is a flat brand/45 that otherwise decides the colour whatever the lobes do. The mask keeps it off the face, so being on top costs the label nothing. It rests at half the field's budget and lifts to nine tenths on the live states, hover, and focus. Where mask-composite is unsupported the ring cannot be cut and the layer stays display:none — a blurred brand field across the label is the worse failure." }, { part: "Border arrival", description: "The chip's outline is part of the arrival rather than the frame the arrival happens inside: border-color runs from transparent up past its resting strength and settles, so light gathers around an edge that is resolving instead of one that was already finished. The keyframes name no landing value at all — an implicit end frame takes the element's own computed border-color, so the arrival lands on whatever the chip declares, including the different values hover and data-leo-open set, and the resting value stays in one place on askLeoLauncherChipClass. backwards fill holds the transparent frame through the delay; nothing is held forwards. The one literal it carries is the peak, and that is hover's own brand/70: the arrival may be as bright as the pointer makes the chip and no brighter. Both gates switch it off — an edge is form, not flourish, and in forced colors it is the user's ButtonBorder, not ours to animate away." }, { part: "Chip opening", description: "The Comfort launcher lands icon-only, about 38px wide, and opens into icon and name over 380ms. Only the label's own box moves: the chip's width is its content, so growing [data-ask-leo-utility-label] from max-width 0 with a negative start margin cancelling the button's gap-1.5 is the whole mechanism — neither the closed nor the open width is written down anywhere, and the halo, wash, and edge light are inset-0 on the wrapper, so all three follow the opening without being told to. No overflow clip, deliberately: the type is still at zero opacity while its box is moving, so there is nothing visible to clip, and a clip here would have cut the label's own bloom off at the edges of a box that hugs its glyphs. backwards fill covers the delay and nothing is held forwards, so the generous max-width ceiling stops governing the moment the animation lands. The cost is honest: the utility icons to its left slide about 60px as the chip opens, once, during the shell's first paint. Both gates switch it off, which leaves the label at its own width from the first frame." }, { part: "Label arrival", description: "The launcher's own type is part of the arrival rather than the one thing on the chip already finished: [data-ask-leo-utility-label] rises 2px from zero opacity and takes one bloom of --ask-leo-sheen-color through the glyphs, starting as the chip finishes opening so the read is Leo turning up and then saying what it is. The bloom is a text-shadow rather than a gradient clipped to the glyphs: background-clip: text needs the label's colour transparent to show through, which puts a contrast we no longer control on the smallest type in the bar. The shadow's geometry is identical in all three keyframes and only its alpha moves, because none does not interpolate with a shadow — written at the ends, the bloom pops. It lands on plain type: full opacity, no offset, no shadow, so dropping data-intro changes nothing. Its window closes well inside the halo's, and only the Comfort chip has a label to animate at all." }, { part: "Label shimmer", description: "One band of --ask-leo-sheen-color crosses the name once it is legible, left to right, on the same envelope as the border sheen — up fast, gone before the end — so the two read as one light crossing the whole chip rather than two highlights travelling independently. It is a second copy of the glyphs on the label's ::after, drawn from the attribute with attr() and clipped to text, laid over the real text and transparent everywhere except the band. That is what lets a gradient run through type without owning its contrast: the label underneath keeps the ink the bar gave it, and this layer can only add light. The band peaks at two thirds alpha rather than full, so even the brightest glyph still reads its own ink through it. The attribute carries the string the label renders, so the copy cannot trace a stale name. Neither gate renders it — a travelling highlight has no static form worth parking on type." }, { part: "Star turn", description: "The star turns a quarter into place as the chip opens: [data-ask-leo-utility-star], the slot the glyph sits in, runs from rotate(-90deg) scale(0.55) up past 1 and lands on no transform at all. This is the part of the greeting a user can actually see. LeoIcon's own invited gesture is sized for hover — a 6% squash, a 5deg tip, and sparkles flicking 8 units of a 168-unit viewBox — which on a 20px glyph is under a pixel of travel, so beside a chip changing width, an outline drawing itself in, and a label inking it was not visible at all. The two compose rather than replace each other: the gesture's sparkle brightening is the part of it that reads at this size, and this carries the movement. A quarter and only a quarter, because the mark is 4-fold symmetric — 90deg lands the star exactly on itself, so there is no seam to hide and nothing to unwind, and no frame of it shows a mark that could be mistaken for a different icon. One eased pass, never repeated: repetition is what makes a turning mark read as indeterminate progress. It runs on the wrapper, not the glyph, so it composes with the gesture's own transforms instead of competing for the same property, and so the turn costs a composite rather than a layout in the shell's first paint. It starts just before the box moves and settles while the name is still inking, overlapping rather than queueing. Both gates drop it: upright is where it lands anyway." }, { part: "Star greeting", description: "The arrival runs LeoIcon's invited gesture once as the chip lands, so the star anticipates and overshoots on load rather than waiting to be hovered. A one-shot, not a loop: the working loop belongs to Leo actually working, and chrome that keeps moving is chrome competing with the page. It is the same gesture hover and focus play, ORed with them, so an arrival interrupted by the pointer does not fight itself. Staged into the arrival by useStagedGreeting rather than fired at mount, which is where it was invisible for two reasons worth keeping apart: the gesture is a 420ms keyframe on SVG transforms driven from JavaScript, so in the mount frame it competes with the page's first layout and loses the frames that carry it; and the chip is still icon-only there with its wash 150ms out, so the star had finished moving before the box started opening. It now starts 160ms in, inside the opening beat, and hands over to the label's ink and shimmer. The hold is released when the gesture ends rather than when the arrival does, because invited is a one-shot state: left set, a hover is not a state change and Leo would ignore the pointer for the rest of the arrival." }, { part: "Chip glow", description: "--ask-leo-chip-glow on the chip's ::before, faded in once the arrival has played. On ::before rather than a sibling so it inherits the chip's corner, and behind the star rather than spread evenly so it reads as light from the halo settling in rather than a flat tint. Suppressed while the arrival is still running, which is what makes the wash the only light inside the chip during it." }, { part: "Arrival gating", description: "The label is the one arrival with a static form worth keeping — it is the control's name — so both gates hold it at its end frame instead of hiding it: animation off, opacity 1, no shadow, no offset. The sheen, the wash, and the edge light are motion and nothing else, so none of them render under reduced motion or forced colors — no static brand slab parked on the chip, which would be worse than no wash at all. For the wash that is not hypothetical: strip the bar lobes' keyframes and they hold at full opacity, so the component declines to mount and the stylesheet keeps a display:none floor behind it. The Leo ambience prefs switch it off the same way they do in the composer. Both gates are asserted against the stylesheet in ask-leo-arrival-gates.test.ts, since jsdom applies no stylesheet and resolves no pseudo-element. useOneShotIntro carries a ceiling timer for exactly those cases: without it the resting glow would never fade in for the users least able to afford a missing affordance." }, ], api: [ { prop: "size", type: `"sm" | "lg"`, defaultValue: `"sm"`, description: "sm for chart card headers, lg for page header actions." }, { prop: "iconOnly", type: "boolean", defaultValue: "false", description: "Hides the label. Pair with ariaLabel." }, { prop: "label", type: "string", defaultValue: `"Ask Leo"`, description: "Visible label. Use a verb phrase for route-local actions." }, { prop: "onClick", type: "() => void", defaultValue: "toggles Ask Leo sidebar", description: "Override for a route-local Leo action." }, { prop: "animatedStar", type: "boolean", defaultValue: `size === "lg"`, description: "Animated LeoIcon star instead of the static duotone glyph." }, { prop: "aria-busy", type: "boolean", description: "Announces in-flight work; shows busyLabel." }, { prop: "busyLabel", type: "string", description: "Label shown while aria-busy is true." }, { prop: "ariaLabel", type: "string", defaultValue: "label", description: "Accessible name. Required when iconOnly." }, { prop: "tooltipLabel", type: "string", defaultValue: "label", description: "Tooltip body before the shortcut chips." }, { prop: "showShortcut", type: "boolean", defaultValue: "true", description: "Set false when onClick does not open the sidebar." }, ], ux: { job: "Let someone hand the surface they are already reading to Leo, so they get a draft or an explanation without restating the context.", budgets: [ { label: "Triggers per surface", value: "1", rationale: "The utility bar toggle is always present; a second CTA on the same page splits the entry point." }, { label: "Label length", value: "3 words", rationale: "Sits inside an h-7 outline button beside a card title." }, ], principles: ["P3", "P5", "P8", "P19"], modernReferences: [ "Notion AI block trigger (M1, M4)", "Linear issue AI summary action (M4, M7)", ], patternDoc: "apps/web/docs/ask-leo-pattern.md", rulePath: ".cursor/rules/exxat-ask-leo.mdc", whenToUse: [ "A chart, record, or draft on screen is the context Leo should read.", "The action produces long-form output such as a drafted answer or an explanation.", ], whenNotToUse: [ "Short lookup or navigation. Use the command menu instead.", "Surfaces where the utility bar toggle is the only entry point that makes sense.", ], }, guidelines: { do: [ "Use ChartCard headers and page header actions as the mounting points.", "Keep size sm on chart cards so the CTA stays quieter than the card title.", "Set showShortcut to false whenever onClick is a route-local action.", "Give iconOnly triggers an ariaLabel that names the target, such as Ask Leo about this chart.", ], dont: [ "Do not use for short lookups or navigation. That is the command menu.", "Do not add particles or a second icon animation. The utility-bar wash is the chip's own surface and brightens only on hover, focus, and Leo's live states; LeoIcon keeps its existing built-in hover motion.", "Do not swap in a Lucide sparkle. The mark is LeoIcon or the Font Awesome duotone star.", "Do not duplicate the trigger on a surface that already shows the utility bar toggle.", ], }, accessibility: [ { principle: "perceivable", criterion: "1.1.1", criterionTitle: "Non-text Content", level: "A", guidance: "The star mark is aria-hidden. The accessible name lives on the button via label or ariaLabel.", }, { principle: "operable", criterion: "2.1.1", criterionTitle: "Keyboard", level: "A", guidance: "The tooltip opens on keyboard focus, so the shortcut hint is not hover-only.", }, { principle: "operable", criterion: "2.5.8", criterionTitle: "Target Size (Minimum)", level: "AA", guidance: "Both sizes clear the 24 by 24 CSS px floor, including the iconOnly form.", }, { principle: "understandable", criterion: "2.4.6", criterionTitle: "Headings and Labels", level: "AA", guidance: "Tooltip text matches the accessible name, so sighted and AT users get the same label.", }, { principle: "robust", criterion: "4.1.2", criterionTitle: "Name, Role, Value", level: "A", guidance: "aria-busy reports in-flight drafting inline instead of a toast, per the no-toast rule.", }, ], extraImports: [ { label: "Package action", path: "@exxatdesignux/ui/components/shell/ask-leo-button" }, { label: "Package launcher", path: "@exxatdesignux/ui/components/shell/ask-leo-launcher" }, ], relatedSlugs: ["message", "leo-icon", "button", "kbd", "chart", "utility-bar"], }