ui changes
This commit is contained in:
@@ -1,27 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { PanelLeftOpen, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import KrowAssistant from './KrowAssistant';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { ResizeDivider } from './ResizeDivider';
|
||||
|
||||
const DEFAULT_WIDTH = 380;
|
||||
const EXPANDED_WIDTH = 620;
|
||||
/** Gutter separating the panel from the dashboard. */
|
||||
const GUTTER = 24;
|
||||
/**
|
||||
* Gutter separating the panel from the dashboard, split between the drag handle
|
||||
* and the panel's own lead padding. Both halves are named because the track width
|
||||
* is their sum plus the panel: get that arithmetic wrong and the column either
|
||||
* overflows or leaves a strip of unexplained empty space at the shell's edge.
|
||||
*/
|
||||
const HANDLE_WIDTH = 12;
|
||||
const LEAD_PADDING = 8;
|
||||
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
||||
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
||||
const MAX_VIEWPORT_SHARE = 0.42;
|
||||
/**
|
||||
* Below this width there is no room for a column *beside* the dashboard — a
|
||||
* 380px track on a 375px phone collapses `main` to nothing — so the panel
|
||||
* becomes a bottom sheet over the page instead of an inline column.
|
||||
* 380px track on a 375px phone collapses `main` to nothing — so Owliver stacks
|
||||
* underneath the page instead.
|
||||
*
|
||||
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the
|
||||
* inline column out acceptably, so they keep the existing behaviour and only
|
||||
* phones switch to the sheet.
|
||||
* Deliberately Tailwind's `md` (768px), not `lg`: tablets already lay the inline
|
||||
* column out acceptably, so they keep the two-column workspace and only phones
|
||||
* stack.
|
||||
*/
|
||||
const OVERLAY_BREAKPOINT = 768;
|
||||
const STACK_BREAKPOINT = 768;
|
||||
|
||||
/** Tracks viewport width so the expanded panel can be clamped on smaller screens. */
|
||||
/** Tracks viewport width so the panel can be clamped and the layout switched. */
|
||||
function useViewportWidth() {
|
||||
const [width, setWidth] = React.useState(() =>
|
||||
typeof window === 'undefined' ? 1440 : window.innerWidth
|
||||
@@ -37,121 +44,156 @@ function useViewportWidth() {
|
||||
}
|
||||
|
||||
/**
|
||||
* AssistantPanel — the sliding column the layout renders.
|
||||
* The collapsed state — a compact docked trigger.
|
||||
*
|
||||
* Animating the aside's `width` (rather than overlaying) is what makes the
|
||||
* dashboard reflow smoothly instead of being covered, and what lets closing
|
||||
* restore the original layout exactly.
|
||||
* The earlier version was a full-height rail inside the layout column. It solved
|
||||
* the wrong problem: it kept Owliver *findable*, but it also kept the column, so
|
||||
* collapsing bought the dashboard 44px instead of the 400px the user was asking
|
||||
* for, and it did it with rotated text down the side of the page.
|
||||
*
|
||||
* Two structural details matter, and both were bugs at one point:
|
||||
* So collapsing now removes the column entirely — the layout goes to one column
|
||||
* and `main` takes the whole shell — and the way back is this small docked pill.
|
||||
* It is deliberately the only floating control in the product, and it exists only
|
||||
* in the collapsed state, where there is nowhere else for it to live.
|
||||
*/
|
||||
function CollapsedTrigger({ page, onRestore }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
aria-label={`Show the Owliver workspace for ${page}`}
|
||||
aria-expanded={false}
|
||||
className="group fixed bottom-5 right-5 z-30 inline-flex items-center gap-2 rounded-full border border-border
|
||||
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
|
||||
hover:border-krow-blue/40 hover:shadow-lg
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-krow-blue text-white shadow-brand">
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AssistantPanel — the Owliver workspace column the Admin layout renders.
|
||||
*
|
||||
* 1. The clip lives *inside* the sticky element. An ancestor with
|
||||
* `overflow: hidden` becomes the nearest scroll container and silently
|
||||
* disables `position: sticky` beneath it.
|
||||
* Owliver is part of the page on every supported route, so this is a column in
|
||||
* the layout rather than an overlay: the dashboard reflows beside it instead of
|
||||
* being covered, and collapsing restores the original layout exactly.
|
||||
*
|
||||
* Five structural details matter, and every one of them was a bug at some point:
|
||||
*
|
||||
* 1. Nothing above the sticky element clips. An ancestor with `overflow: hidden`
|
||||
* becomes the nearest scroll container and silently disables
|
||||
* `position: sticky` beneath it.
|
||||
* 2. The aside stretches to the shell's full height (`self-stretch`), so its
|
||||
* sticky context runs the whole page rather than ending partway down and
|
||||
* leaving empty space beside a long dashboard.
|
||||
* 3. `min-w-0` on the aside. A flex item's default `min-width: auto` refuses to
|
||||
* go narrower than its content, so the track ignored its own width and stalled
|
||||
* at the panel's old size on the way back from expanded.
|
||||
* 4. Only the track has a width. The panel fills what the track leaves after the
|
||||
* handle and the lead padding, so the two can never disagree — which is
|
||||
* exactly what (3) was: two widths transitioning, one winning.
|
||||
* 5. Collapsing removes the column outright and leaves a compact docked trigger.
|
||||
* An earlier version kept a full-height rail in place, which bought the
|
||||
* dashboard 44px when the user had asked for 400 — and spelled "Owliver" down
|
||||
* the side of the page to do it.
|
||||
*
|
||||
* Expanding widens the same panel in place — it is an analysis workspace inside
|
||||
* the application layout, not a fullscreen takeover, and never covers the
|
||||
* dashboard.
|
||||
* Expanding widens the same panel in place — an analysis workspace inside the
|
||||
* application layout, never a fullscreen takeover.
|
||||
*/
|
||||
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
||||
const { context, isOpen, isExpanded, close, expand, restore } = useAssistantPanel();
|
||||
const {
|
||||
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
||||
setWidth, resetWidth, open, close, expand, restore,
|
||||
} = useAssistantPanel();
|
||||
const viewportWidth = useViewportWidth();
|
||||
|
||||
// Clamped so a wide panel cannot squeeze the dashboard on a small screen.
|
||||
// No assistant on this route: no column, no rail, no trace in the layout.
|
||||
if (!context) return null;
|
||||
|
||||
/* Expanded overrides the dragged width; otherwise the user's own width wins.
|
||||
Both are clamped against the viewport so the dashboard is never squeezed. */
|
||||
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
||||
const panelWidth = isExpanded
|
||||
? Math.max(DEFAULT_WIDTH, Math.min(EXPANDED_WIDTH, Math.round(viewportWidth * MAX_VIEWPORT_SHARE)))
|
||||
: DEFAULT_WIDTH;
|
||||
? Math.max(width, Math.min(EXPANDED_WIDTH, viewportCap))
|
||||
: Math.min(width, Math.max(minWidth, viewportCap));
|
||||
|
||||
/* Collapsed: no column at all. The layout falls back to a single column, `main`
|
||||
takes the whole shell, and the only thing left is the docked trigger — so
|
||||
there is never a reserved empty gutter where the panel used to be. */
|
||||
if (!isOpen) {
|
||||
return <CollapsedTrigger page={context.page} onRestore={open} />;
|
||||
}
|
||||
|
||||
const trackWidth = panelWidth + GUTTER;
|
||||
const easing = [0.65, 0, 0.35, 1];
|
||||
|
||||
// Phone/small-tablet: a sheet over the dashboard rather than a column beside
|
||||
// it. Same assistant, same props — only the container differs.
|
||||
if (viewportWidth < OVERLAY_BREAKPOINT) {
|
||||
/* Phones: stack Owliver under the dashboard. A 380px column beside the page is
|
||||
not a layout at this width, and an overlay that opens on load would put a
|
||||
sheet between the user and the page they asked for. */
|
||||
if (viewportWidth < STACK_BREAKPOINT) {
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && context && (
|
||||
<>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-40 bg-black/40"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: easing }}
|
||||
onClick={close}
|
||||
/>
|
||||
<motion.aside
|
||||
key="assistant-sheet"
|
||||
aria-label="Krow AI Assistant"
|
||||
className="fixed inset-x-0 bottom-0 z-50 max-h-[85dvh] overflow-y-auto overscroll-contain rounded-t-2xl bg-white shadow-2xl"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
>
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={false}
|
||||
onClose={close}
|
||||
onExpand={expand}
|
||||
onRestore={restore}
|
||||
/>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<aside aria-label="Owliver workspace" className="pb-6 pt-2">
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={false}
|
||||
onClose={close}
|
||||
onExpand={null}
|
||||
onRestore={null}
|
||||
className="h-[32rem]"
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && context && (
|
||||
<motion.aside
|
||||
key="assistant"
|
||||
aria-label="Krow AI Assistant"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: trackWidth, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2). */
|
||||
className="relative shrink-0 self-stretch"
|
||||
>
|
||||
<div className={cn('sticky', stickyClassName)}>
|
||||
{/* The clip: contains the slide-in and the panel width while the
|
||||
track animates, without breaking the sticky above it. */}
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
animate={{ width: trackWidth }}
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
>
|
||||
<motion.div
|
||||
className="pl-6"
|
||||
initial={{ x: 32, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 32, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: easing }}
|
||||
>
|
||||
{/* Keyed on the context so switching pages gives a clean panel
|
||||
and aborts any response still streaming for the last one. */}
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={isExpanded}
|
||||
onClose={close}
|
||||
onExpand={expand}
|
||||
onRestore={restore}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<aside
|
||||
aria-label="Owliver workspace"
|
||||
style={{ width: trackWidth }}
|
||||
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
||||
`min-w-0` — see (3).
|
||||
|
||||
A CSS transition rather than an animated width: the width is already a
|
||||
pure function of state, so there is nothing for an animation library to
|
||||
own, and the declarative version cannot get stranded mid-collapse the way
|
||||
an interrupted JS animation can. Reduced motion is handled globally in
|
||||
index.css. */
|
||||
className="relative min-w-0 shrink-0 self-stretch transition-[width] duration-slow ease-out"
|
||||
>
|
||||
<div className={cn('sticky flex', stickyClassName)}>
|
||||
{/* Drag handle. Sits in the gutter, so resizing never overlaps either
|
||||
surface. Double-click restores the default width. */}
|
||||
<ResizeDivider
|
||||
width={panelWidth}
|
||||
min={minWidth}
|
||||
max={Math.min(maxWidth, viewportCap)}
|
||||
onResize={setWidth}
|
||||
onDoubleClick={resetWidth}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
|
||||
{/* The panel takes whatever the track leaves after the handle and the
|
||||
lead padding, which is `panelWidth` by construction — see (4). */}
|
||||
<div className="min-w-0 flex-1 pl-2">
|
||||
{/* Keyed on the context so switching pages gives a clean panel and
|
||||
aborts any response still streaming for the last one. */}
|
||||
<KrowAssistant
|
||||
key={context.id}
|
||||
context={context}
|
||||
expanded={isExpanded}
|
||||
onClose={close}
|
||||
onExpand={expand}
|
||||
onRestore={restore}
|
||||
className={panelHeightClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user