v0.6.0: beautifului 整合 + 会话/工作区/队列/权限/命令/附件全链路
- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import GlideMenu from "@/components/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* APPROVAL CARD (human-in-the-loop)
|
||||
* One question at a time. The stack slides vertically as you
|
||||
* move between questions (the card's height animates to fit),
|
||||
* the step counter rolls like an odometer, and the footer uses
|
||||
* pill actions — a quiet Skip and a dark Continue with a ⏎.
|
||||
* Single-choice answers auto-advance; multi-select waits.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApprovalQuestion = {
|
||||
q: string;
|
||||
type: "radio" | "check";
|
||||
options: string[];
|
||||
};
|
||||
|
||||
const QUESTIONS: ApprovalQuestion[] = [
|
||||
{
|
||||
q: "How many flavors should we launch?",
|
||||
type: "radio",
|
||||
options: ["Three (core line)", "Five (full case)", "Just one hero"],
|
||||
},
|
||||
{
|
||||
q: "Which mix-ins should we stock?",
|
||||
type: "check",
|
||||
options: ["Chocolate chips", "Waffle bits", "Sprinkles"],
|
||||
},
|
||||
{
|
||||
q: "Which market do we enter first?",
|
||||
type: "radio",
|
||||
options: ["Food trucks", "Grocery freezers", "Scoop shops"],
|
||||
},
|
||||
];
|
||||
|
||||
export type ApprovalLabels = {
|
||||
skip: string;
|
||||
continue: string;
|
||||
send: string;
|
||||
customPlaceholder: string;
|
||||
sentMessage: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ApprovalLabels = {
|
||||
skip: "Skip",
|
||||
continue: "Continue",
|
||||
send: "Send",
|
||||
customPlaceholder: "Something else…",
|
||||
sentMessage: "Answers sent",
|
||||
};
|
||||
|
||||
const ROLL_MS = 400;
|
||||
const SLIDE = "360ms cubic-bezier(0.22, 1, 0.36, 1)";
|
||||
|
||||
/* odometer digits — each character that changes rolls up (or down) */
|
||||
function RollingDigits({ value }: { value: string }) {
|
||||
const prevRef = useRef(value);
|
||||
const [oldVal, setOldVal] = useState(value);
|
||||
const [newVal, setNewVal] = useState(value);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [shifted, setShifted] = useState(false);
|
||||
const [dir, setDir] = useState<"up" | "down">("up");
|
||||
|
||||
useEffect(() => {
|
||||
if (prevRef.current === value) return;
|
||||
const from = prevRef.current;
|
||||
prevRef.current = value;
|
||||
const fromN = parseInt(from, 10);
|
||||
const toN = parseInt(value, 10);
|
||||
setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? "down" : "up");
|
||||
setOldVal(from);
|
||||
setNewVal(value);
|
||||
setRolling(true);
|
||||
setShifted(false);
|
||||
|
||||
let raf2 = 0;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShifted(true));
|
||||
});
|
||||
const done = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setOldVal(value);
|
||||
setShifted(false);
|
||||
}, ROLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1);
|
||||
cancelAnimationFrame(raf2);
|
||||
clearTimeout(done);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const chars = rolling ? newVal : oldVal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: chars.length }, (_, i) => {
|
||||
const o = oldVal[i] ?? "";
|
||||
const n = chars[i] ?? "";
|
||||
if (!rolling || o === n) {
|
||||
return <span key={`${i}-${n}`}>{n}</span>;
|
||||
}
|
||||
const top = dir === "down" ? n : o;
|
||||
const bottom = dir === "down" ? o : n;
|
||||
const restY = dir === "down" ? "0" : "-1em";
|
||||
const startY = dir === "down" ? "-1em" : "0";
|
||||
return (
|
||||
<span
|
||||
key={`${i}-${o}-${n}-${dir}`}
|
||||
style={{ display: "inline-block", position: "relative", overflow: "hidden", height: "1em", lineHeight: "1em", verticalAlign: "-0.05em" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "transform 350ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
transform: `translateY(${shifted ? restY : startY})`,
|
||||
}}
|
||||
>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{top}</span>
|
||||
<span style={{ height: "1em", lineHeight: "1em" }}>{bottom}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApprovalCard({
|
||||
questions = QUESTIONS,
|
||||
labels,
|
||||
onSubmitted,
|
||||
onAnswerChange,
|
||||
resettable = true,
|
||||
}: {
|
||||
questions?: ApprovalQuestion[];
|
||||
labels?: Partial<ApprovalLabels>;
|
||||
onSubmitted?: (answers: Record<number, number[]>) => void;
|
||||
onAnswerChange?: (questionIndex: number, answer: number[]) => void;
|
||||
resettable?: boolean;
|
||||
variant?: string;
|
||||
} = {}) {
|
||||
const t = { ...DEFAULT_LABELS, ...labels };
|
||||
const [qi, setQi] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<number, number[]>>({});
|
||||
const [custom, setCustom] = useState<Record<number, string>>({});
|
||||
const [sent, setSent] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const measured = useRef(false);
|
||||
const [viewportH, setViewportH] = useState<number | undefined>(undefined);
|
||||
const [trackY, setTrackY] = useState(0);
|
||||
const [animate, setAnimate] = useState(false);
|
||||
// Until the first question is measured, render only the active one so the
|
||||
// initial (and SSR) height is Q1's height — not all questions stacked, which
|
||||
// would flash to full height and then shrink on mount.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const last = qi === questions.length - 1;
|
||||
const selected = answers[qi] ?? [];
|
||||
const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());
|
||||
|
||||
const sync = (withAnim: boolean) => {
|
||||
const item = questionRefs.current[qi];
|
||||
if (!item) return;
|
||||
const reduce = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
setViewportH(item.offsetHeight);
|
||||
setTrackY(item.offsetTop);
|
||||
setAnimate(withAnim && !reduce);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const withAnim = measured.current;
|
||||
measured.current = true;
|
||||
sync(withAnim);
|
||||
setReady(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi, answers, custom, open, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => sync(measured.current));
|
||||
return () => cancelAnimationFrame(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qi]);
|
||||
|
||||
useEffect(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);
|
||||
|
||||
const goTo = (next: number) => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setQi(Math.min(Math.max(next, 0), questions.length - 1));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
setSent(true);
|
||||
onSubmitted?.(answers);
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
if (last) send();
|
||||
else goTo(qi + 1);
|
||||
};
|
||||
|
||||
const toggle = (index: number) => {
|
||||
const type = questions[qi].type;
|
||||
setAnswers((current) => {
|
||||
const picked = current[qi] ?? [];
|
||||
const next = type === "radio"
|
||||
? [index]
|
||||
: picked.includes(index)
|
||||
? picked.filter((item) => item !== index)
|
||||
: [...picked, index];
|
||||
onAnswerChange?.(qi, next);
|
||||
return { ...current, [qi]: next };
|
||||
});
|
||||
if (type === "radio") {
|
||||
setCustom((current) => ({ ...current, [qi]: "" }));
|
||||
if (advanceTimer.current) clearTimeout(advanceTimer.current);
|
||||
advanceTimer.current = setTimeout(() => {
|
||||
if (last) send();
|
||||
else setQi((current) => Math.min(questions.length - 1, current + 1));
|
||||
}, 480);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setQi(0);
|
||||
setAnswers({});
|
||||
setCustom({});
|
||||
setSent(false);
|
||||
setOpen(true);
|
||||
measured.current = false;
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)} className="rounded-control bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover">
|
||||
Open approval
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex w-full max-w-80 items-center gap-3" style={{ animation: "pop-in 260ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green">
|
||||
<span className="flex size-4.5 items-center justify-center rounded-full bg-green text-white">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
</span>
|
||||
{t.sentMessage}
|
||||
</span>
|
||||
{resettable && (
|
||||
<button type="button" onClick={reset} className="text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink">
|
||||
Start over
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-80">
|
||||
<div className="relative overflow-hidden rounded-card bg-surface shadow-card" style={{ animation: "fade-up 380ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => setOpen(false)}
|
||||
className="primitive-icon-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Ico size={14} sw={2.2} path={<path d="M18 6L6 18M6 6l12 12" />} />
|
||||
</button>
|
||||
<div className="primitive-card-pad">
|
||||
{/* the question itself is the heading */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 26,
|
||||
transform: `translate3d(0, ${-trackY}px, 0)`,
|
||||
transition: animate ? `transform ${SLIDE}` : undefined,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{questions.map((question, qIdx) => {
|
||||
const active = qIdx === qi;
|
||||
// Before the first measure, mount only the active question so the
|
||||
// card opens at its real height instead of flashing to full height.
|
||||
if (!ready && !active) return null;
|
||||
const picked = answers[qIdx] ?? [];
|
||||
const questionStyle: CSSProperties = {
|
||||
opacity: active ? 1 : 0,
|
||||
transition: animate ? `opacity ${SLIDE}` : undefined,
|
||||
pointerEvents: active ? undefined : "none",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={qIdx}
|
||||
ref={(el) => { questionRefs.current[qIdx] = el; }}
|
||||
aria-hidden={active ? undefined : true}
|
||||
style={questionStyle}
|
||||
>
|
||||
<div className="pr-7 text-[14px] font-medium text-ink">{question.q}</div>
|
||||
<GlideMenu className="mt-2.5 flex flex-col gap-1" highlightClassName="inset-x-0 rounded-control bg-hover">
|
||||
{question.options.map((option, i) => {
|
||||
const on = picked.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
data-menu-row
|
||||
aria-pressed={on}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => { if (active) toggle(i); }}
|
||||
className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200
|
||||
${question.type === "radio" ? "rounded-full" : "rounded-[5px]"}
|
||||
${on ? "bg-ink text-canvas" : "shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent"}`}
|
||||
>
|
||||
{question.type === "radio" ? (
|
||||
<span className="size-1.5 rounded-full bg-canvas transition-transform duration-200" style={{ transform: on ? "scale(1)" : "scale(0)" }} />
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-[13px] leading-none transition-colors duration-200 ${on ? "text-ink" : "text-ink-2"}`}>
|
||||
{option}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<label data-menu-row className="relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100">
|
||||
<input
|
||||
value={custom[qIdx] ?? ""}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onChange={(event) => {
|
||||
if (!active) return;
|
||||
setCustom((current) => ({ ...current, [qIdx]: event.target.value }));
|
||||
if (question.type === "radio") setAnswers((current) => ({ ...current, [qIdx]: [] }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && hasAnswer) {
|
||||
event.preventDefault();
|
||||
advance();
|
||||
}
|
||||
}}
|
||||
placeholder={t.customPlaceholder}
|
||||
aria-label="Custom answer"
|
||||
className="min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
</label>
|
||||
</GlideMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer — step nav (rolling counter) + pill actions */}
|
||||
<div className="primitive-card-footer flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1 text-ink-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous question"
|
||||
disabled={qi <= 0}
|
||||
onClick={() => goTo(qi - 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M18 15l-6-6-6 6" />} />
|
||||
</button>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3" style={{ letterSpacing: "-0.1px", lineHeight: 1 }}>
|
||||
<RollingDigits value={`${qi + 1} / ${questions.length}`} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next question"
|
||||
disabled={last}
|
||||
onClick={() => goTo(qi + 1)}
|
||||
className="flex size-[18px] items-center justify-center rounded-[5px] transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30"
|
||||
>
|
||||
<Ico size={14} path={<path d="M6 9l6 6 6-6" />} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="-mr-0.5 flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>
|
||||
{t.skip}
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" disabled={!hasAnswer} onClick={advance}>
|
||||
{last ? t.send : t.continue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const filledShadow = "shadow-[inset_0_1px_0_rgba(255,255,255,0.14)]";
|
||||
|
||||
/* Pill-shaped by default — the app's core button style. Explicit symmetric
|
||||
* padding (not a fixed height) so the top/bottom spacing is always equal. */
|
||||
export const buttonVariants = cva(
|
||||
`inline-flex items-center justify-center font-medium select-none
|
||||
transition-[transform,background-color,opacity] duration-150 ease-out
|
||||
active:scale-[0.96] disabled:opacity-50 disabled:pointer-events-none`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: `bg-ink text-canvas hover:opacity-90 dark:bg-ink dark:text-canvas ${filledShadow}`,
|
||||
secondary: "bg-surface text-ink shadow-btn hover:bg-inset aria-expanded:bg-hover",
|
||||
ghost: "bg-hover-2 text-ink hover:bg-line-strong",
|
||||
accent: `bg-accent text-white hover:bg-accent-ink ${filledShadow}`,
|
||||
success: `bg-green text-white hover:brightness-95 ${filledShadow}`,
|
||||
/* transparent until hovered — for dense toolbars/action rows */
|
||||
quiet: "text-ink hover:bg-hover",
|
||||
},
|
||||
size: {
|
||||
/* compact toolbar pill — fixed height, lighter weight */
|
||||
xs: "h-7 rounded-full px-2.5 text-[12px] font-normal leading-none gap-1",
|
||||
/* canonical action pill — 27px tall, roomy sides */
|
||||
sm: "h-[27px] px-3 text-[13px] leading-none rounded-full gap-1.5",
|
||||
md: "px-4 py-[9px] text-sm leading-none rounded-full gap-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "secondary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type GlideMenuProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
highlightClassName?: string;
|
||||
rowSelector?: string;
|
||||
};
|
||||
|
||||
/** A single hover layer that glides between interactive menu rows. */
|
||||
export default function GlideMenu({
|
||||
children,
|
||||
className = "",
|
||||
highlightClassName = "inset-x-0 rounded-[8px] bg-hover",
|
||||
rowSelector = "[data-menu-row]",
|
||||
}: GlideMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; height: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const moveTo = (target: EventTarget | null) => {
|
||||
const container = ref.current;
|
||||
if (!(target instanceof Element) || !container) return;
|
||||
const row = target.closest(rowSelector);
|
||||
if (!(row instanceof HTMLElement) || !container.contains(row)) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
setBox({ top: rowRect.top - containerRect.top, height: rowRect.height });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseOver={(event) => moveTo(event.target)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
onFocusCapture={(event) => moveTo(event.target)}
|
||||
onBlurCapture={(event) => {
|
||||
if (!ref.current?.contains(event.relatedTarget as Node | null)) setVisible(false);
|
||||
}}
|
||||
className={`group/glide-menu relative ${className}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute ${highlightClassName}`}
|
||||
style={{
|
||||
top: box?.top ?? 0,
|
||||
height: box?.height ?? 0,
|
||||
opacity: box && visible ? 1 : 0,
|
||||
transition:
|
||||
"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* LOADING STATE — pixel-grid loader for long-running work
|
||||
*
|
||||
* Variants:
|
||||
* Drive — square cells, chevron wavefront driving right;
|
||||
* the 650ms cycle is shorter than the sweep, so
|
||||
* two fronts are always in flight
|
||||
* Dots — same wavefront, circular cells
|
||||
* Orbit — a comet lapping the grid perimeter
|
||||
* Surfer — the Drive loader paired with a meme video below
|
||||
*
|
||||
* Paired with a shimmering label and a live elapsed timer
|
||||
* in mono tabular figures. Reduced motion freezes the grid
|
||||
* to its dim state; the timer still ticks.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const chevron = Array.from({ length: 9 }, (_, i) => {
|
||||
const r = Math.floor(i / 3), c = i % 3;
|
||||
return (c + Math.abs(r - 1)) * 90;
|
||||
});
|
||||
|
||||
const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3];
|
||||
const orbit = Array.from({ length: 9 }, (_, i) => {
|
||||
const k = ORBIT_ORDER.indexOf(i);
|
||||
return k === -1 ? null : k * 110;
|
||||
});
|
||||
|
||||
const PATTERNS: Record<string, { delays: (number | null)[]; dur: number; round: boolean }> = {
|
||||
Drive: { delays: chevron, dur: 650, round: false },
|
||||
Dots: { delays: chevron, dur: 650, round: true },
|
||||
Orbit: { delays: orbit, dur: 950, round: false },
|
||||
};
|
||||
|
||||
function LoaderGrid({
|
||||
delays,
|
||||
dur,
|
||||
round,
|
||||
}: {
|
||||
delays: (number | null)[];
|
||||
dur: number;
|
||||
round: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span aria-hidden className="grid shrink-0 grid-cols-[repeat(3,4px)] gap-[1.5px]">
|
||||
{delays.map((delay, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`size-[4px] bg-ink ${round ? "rounded-full" : "rounded-[1px]"}`}
|
||||
style={{
|
||||
opacity: delay === null ? 0.07 : 0.15,
|
||||
animation: delay === null ? "none" : `pixel-on ${dur}ms ease-in-out ${delay}ms infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useElapsed() {
|
||||
const [ds, setDs] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setDs((d) => d + 1), 100);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const total = ds / 10;
|
||||
if (total < 60) return `${total.toFixed(1)}s`;
|
||||
return `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function LoadingState({
|
||||
label,
|
||||
variant = "Drive",
|
||||
/** the meme feed for the Surfer variant; hosted on Vercel Blob so it plays in
|
||||
* production (the local /public/subway-surfers.mp4 stays gitignored) */
|
||||
videoSrc = "https://95dnc2a95qgwt9ff.public.blob.vercel-storage.com/subway-surfers.mp4",
|
||||
}: {
|
||||
label?: string;
|
||||
variant?: string;
|
||||
videoSrc?: string;
|
||||
}) {
|
||||
const elapsed = useElapsed();
|
||||
const surfer = variant === "Surfer";
|
||||
const resolvedLabel = label ?? (surfer ? "Subway surfing" : "Churning");
|
||||
const [videoOk, setVideoOk] = useState(true);
|
||||
const { delays, dur, round } = PATTERNS[variant] ?? PATTERNS.Drive;
|
||||
|
||||
const labelEl = (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
);
|
||||
const elapsedEl = <span className="font-mono text-[12px] text-ink-3 tabular-nums">{elapsed}</span>;
|
||||
|
||||
if (surfer) {
|
||||
return (
|
||||
<div role="status" className="flex w-fit flex-col items-start">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LoaderGrid {...PATTERNS.Drive} />
|
||||
{labelEl}
|
||||
{elapsedEl}
|
||||
</div>
|
||||
|
||||
{/* the context card follows the status text it is illustrating */}
|
||||
<div
|
||||
className="mt-2 w-56 overflow-hidden rounded-[10px] shadow-overlay"
|
||||
style={{ animation: "pop-in 200ms cubic-bezier(0.16,1,0.3,1) both", transformOrigin: "top left" }}
|
||||
>
|
||||
<div className="relative aspect-video w-full" style={{ background: "var(--tooltip-bg)" }}>
|
||||
{videoOk ? (
|
||||
<video
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onError={() => setVideoOk(false)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5">
|
||||
<LoaderGrid {...PATTERNS.Drive} />
|
||||
<span className="px-3 text-center font-mono text-[10px]" style={{ color: "var(--tooltip-muted)" }}>
|
||||
Video unavailable
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="status" className="flex w-fit items-center gap-2.5">
|
||||
<LoaderGrid delays={delays} dur={dur} round={round} />
|
||||
{labelEl}
|
||||
{elapsedEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconArrowBoxLeft } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconArrowBoxLeft";
|
||||
import { IconCheckmark1Small } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCheckmark1Small";
|
||||
import { IconChevronDownSmall } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconChevronDownSmall";
|
||||
import { IconCrossSmall } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconCrossSmall";
|
||||
import { IconEditBig } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconEditBig";
|
||||
import { IconHome } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconHome";
|
||||
import { IconMagnifyingGlass } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass";
|
||||
import { IconPlusMedium } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium";
|
||||
import { IconPopsicle2 } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPopsicle2";
|
||||
import { IconSettingsGear1 } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1";
|
||||
import { IconSidebarLeftArrow } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSidebarLeftArrow";
|
||||
import { IconUserAdd } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconUserAdd";
|
||||
import GlideMenu from "@/components/primitives/GlideMenu";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* SIDEBAR NAV
|
||||
* Shared by the design-system preview and the harness shell:
|
||||
* compact workspace switcher, primary navigation, searchable
|
||||
* chat history, and a collapse that preserves icon alignment.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORKSPACE = { key: "creamery", name: "Creamery Ops", monogram: "C" };
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "home", label: "Home", icon: <IconHome size={18} /> },
|
||||
{ key: "invite", label: "Invite users", icon: <IconUserAdd size={18} />, count: "3/10" },
|
||||
];
|
||||
|
||||
export type SidebarRecent = {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_RECENTS: SidebarRecent[] = [
|
||||
{ id: "suppliers", label: "Supplier records" },
|
||||
{ id: "todos", label: "Urgent to-dos this morning" },
|
||||
{ id: "flavor", label: "Flavor page ticket" },
|
||||
{ id: "workload", label: "Workload summary" },
|
||||
{ id: "offboarding", label: "Off-board a supplier" },
|
||||
{ id: "restock", label: "Batch restock function" },
|
||||
{ id: "edits", label: "Propose flavor edits" },
|
||||
{ id: "subway", label: "Subway surfing" },
|
||||
];
|
||||
|
||||
type SidebarNavProps = {
|
||||
activeTitle?: string | null;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
onNewChat?: () => void;
|
||||
onPick?: (id: string, label: string, prompt?: string) => void;
|
||||
/** controlled primary-nav selection (e.g. "home" | "invite") */
|
||||
activeNav?: string;
|
||||
onNavigate?: (key: string) => void;
|
||||
/** footer call-to-action — defaults to the demo "Upgrade" button */
|
||||
footerLabel?: string;
|
||||
footerIcon?: ReactNode;
|
||||
onFooterClick?: () => void;
|
||||
recents?: SidebarRecent[];
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
const SIDEBAR_MOTION = {
|
||||
expandedWidth: 224,
|
||||
collapsedWidth: 52,
|
||||
duration: 280,
|
||||
copyDuration: 180,
|
||||
copyOffset: 8,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
};
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* CHAT SEARCH STORYBOARD
|
||||
*
|
||||
* 0ms search is triggered; Chats label begins fading
|
||||
* 0ms field grows right → left from the search control
|
||||
* 180ms field fills the row; cursor is focused and ready
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
const CHAT_SEARCH_MOTION = {
|
||||
duration: 180,
|
||||
closedWidth: 28,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
};
|
||||
|
||||
function GlideGroup({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GlideMenu
|
||||
rowSelector="[data-row]"
|
||||
highlightClassName="sidebar-glide-highlight rounded-[7px] bg-hover-2"
|
||||
className="group/glide flex flex-col gap-px"
|
||||
>
|
||||
{children}
|
||||
</GlideMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RailButton({
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
count?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-row
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left
|
||||
transition-[width,background-color,color,transform] duration-150 active:scale-[0.98]
|
||||
${active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""}`}
|
||||
>
|
||||
<span className={`flex size-5 shrink-0 items-center justify-center ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className={`sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{label}
|
||||
</span>
|
||||
{count && (
|
||||
<span className="sidebar-copy mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceMenu({
|
||||
position,
|
||||
onClose,
|
||||
}: {
|
||||
position: { top: number; left: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return createPortal(
|
||||
<div
|
||||
data-workspace-menu
|
||||
className="fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<GlideMenu className="flex flex-col gap-px" highlightClassName="inset-x-0 rounded-[8px] bg-hover-2">
|
||||
<button
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-10 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-[11px] font-semibold text-surface">
|
||||
{WORKSPACE.monogram}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink">{WORKSPACE.name}</span>
|
||||
<span className="shrink-0 text-ink"><IconCheckmark1Small size={18} /></span>
|
||||
</button>
|
||||
<div className="my-1 h-px bg-line" />
|
||||
{[
|
||||
{ label: "New workspace", icon: <IconPlusMedium size={16} /> },
|
||||
{ label: "Workspace settings", icon: <IconSettingsGear1 size={16} /> },
|
||||
{ label: "Invite team members", icon: <IconUserAdd size={16} /> },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2">{item.icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 h-px bg-line" />
|
||||
<button
|
||||
data-menu-row
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left"
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-ink-2"><IconArrowBoxLeft size={16} /></span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-ink">Sign out</span>
|
||||
</button>
|
||||
</GlideMenu>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidebarNav({
|
||||
activeTitle,
|
||||
className = "",
|
||||
fill = false,
|
||||
onNewChat,
|
||||
onPick,
|
||||
activeNav,
|
||||
onNavigate,
|
||||
footerLabel = "Upgrade",
|
||||
footerIcon,
|
||||
onFooterClick,
|
||||
recents = DEFAULT_RECENTS,
|
||||
}: SidebarNavProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [internalNav, setInternalNav] = useState("chats");
|
||||
const currentNav = activeNav ?? internalNav;
|
||||
const selectNav = (key: string) => {
|
||||
setInternalNav(key);
|
||||
onNavigate?.(key);
|
||||
};
|
||||
const [demoActiveTitle, setDemoActiveTitle] = useState<string | null>(null);
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||
const [workspacePosition, setWorkspacePosition] = useState({ top: 0, left: 0 });
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const workspaceButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;
|
||||
const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceOpen) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest("[data-workspace-trigger]") && !target.closest("[data-workspace-menu]")) {
|
||||
setWorkspaceOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", close);
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [workspaceOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) searchRef.current?.focus();
|
||||
}, [searchOpen]);
|
||||
|
||||
const collapse = () => {
|
||||
setCollapsed(true);
|
||||
setWorkspaceOpen(false);
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-sidebar-collapsed={collapsed}
|
||||
aria-label="Workspace navigation"
|
||||
className={`relative flex shrink-0 overflow-hidden transition-[width] ${fill ? "h-full" : "h-[600px]"} ${className}`}
|
||||
style={{
|
||||
width: collapsed ? SIDEBAR_MOTION.collapsedWidth : SIDEBAR_MOTION.expandedWidth,
|
||||
transitionDuration: `${SIDEBAR_MOTION.duration}ms`,
|
||||
transitionTimingFunction: SIDEBAR_MOTION.easing,
|
||||
"--sidebar-copy-duration": `${SIDEBAR_MOTION.copyDuration}ms`,
|
||||
"--sidebar-copy-offset": `${SIDEBAR_MOTION.copyOffset}px`,
|
||||
"--sidebar-easing": SIDEBAR_MOTION.easing,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<div className="flex min-h-0 w-[224px] shrink-0 flex-col">
|
||||
<div className="relative mb-2.5 h-10 shrink-0">
|
||||
<button
|
||||
ref={workspaceButtonRef}
|
||||
data-workspace-trigger
|
||||
type="button"
|
||||
aria-expanded={workspaceOpen}
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => {
|
||||
if (!workspaceOpen && workspaceButtonRef.current) {
|
||||
const rect = workspaceButtonRef.current.getBoundingClientRect();
|
||||
setWorkspacePosition({ top: rect.bottom + 6, left: rect.left });
|
||||
}
|
||||
setWorkspaceOpen((open) => !open);
|
||||
}}
|
||||
className="sidebar-workspace-control absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-[8px] px-2 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99]"
|
||||
>
|
||||
<span className="sidebar-logo flex size-5 shrink-0 items-center justify-center text-ink">
|
||||
<IconPopsicle2 size={18} />
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2">
|
||||
{WORKSPACE.name}
|
||||
</span>
|
||||
<span className="sidebar-copy ml-1 flex shrink-0 text-ink-3">
|
||||
<IconChevronDownSmall size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-hidden={collapsed}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={collapse}
|
||||
className="sidebar-collapse-control absolute right-2 top-1 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<IconSidebarLeftArrow size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand sidebar"
|
||||
aria-hidden={!collapsed}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="sidebar-expand-control absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink"
|
||||
>
|
||||
<IconSidebarLeftArrow size={18} className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GlideGroup>
|
||||
<RailButton
|
||||
icon={<IconEditBig size={18} />}
|
||||
label="New chat"
|
||||
onClick={() => {
|
||||
if (activeTitle === undefined) setDemoActiveTitle(null);
|
||||
selectNav("chats");
|
||||
onNewChat?.();
|
||||
}}
|
||||
/>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<RailButton
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
count={item.count}
|
||||
active={currentNav === item.key}
|
||||
onClick={() => selectNav(item.key)}
|
||||
/>
|
||||
))}
|
||||
</GlideGroup>
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="sidebar-copy relative mx-2 mb-1 h-8">
|
||||
<div
|
||||
aria-hidden={searchOpen}
|
||||
className={`absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3 transition-[opacity,transform] ${searchOpen ? "pointer-events-none -translate-x-1 opacity-0" : "translate-x-0 opacity-100"}`}
|
||||
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`, transitionTimingFunction: CHAT_SEARCH_MOTION.easing }}
|
||||
>
|
||||
<IconChevronDownSmall size={16} />
|
||||
<span>Chats</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Search chats"
|
||||
aria-expanded={searchOpen}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className={`absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96] ${searchOpen ? "pointer-events-none opacity-0" : "opacity-100"}`}
|
||||
style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms` }}
|
||||
>
|
||||
<IconMagnifyingGlass size={16} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2 ${searchOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
style={{
|
||||
width: searchOpen ? "100%" : CHAT_SEARCH_MOTION.closedWidth,
|
||||
transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,
|
||||
transitionTimingFunction: CHAT_SEARCH_MOTION.easing,
|
||||
}}
|
||||
>
|
||||
<span className="ml-2 flex shrink-0 items-center justify-center">
|
||||
<IconMagnifyingGlass size={15} />
|
||||
</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder="Search chats"
|
||||
aria-label="Search chat history"
|
||||
className="ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close chat search"
|
||||
onClick={() => {
|
||||
setSearchOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]"
|
||||
>
|
||||
<IconCrossSmall size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlideGroup>
|
||||
{visibleRecents.map((item) => {
|
||||
const active = item.label === selectedTitle;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-row
|
||||
type="button"
|
||||
title={item.label}
|
||||
onClick={() => {
|
||||
selectNav("chats");
|
||||
if (activeTitle === undefined) setDemoActiveTitle(item.label);
|
||||
onPick?.(item.id, item.label, item.prompt);
|
||||
}}
|
||||
className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] ${
|
||||
active ? "bg-hover-2 group-hover/glide:bg-transparent" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium ${active ? "text-ink" : "text-ink-2"}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{query && visibleRecents.length === 0 && (
|
||||
<div className="sidebar-copy mx-2 px-2 py-2 text-[12.5px] text-ink-3">No chats found</div>
|
||||
)}
|
||||
</GlideGroup>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFooterClick ?? onNewChat}
|
||||
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-control bg-hover-2 text-[12.5px] font-medium text-ink transition-[background-color,transform] duration-150 hover:bg-line-strong active:scale-[0.98]"
|
||||
>
|
||||
{footerIcon}
|
||||
{footerLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* STREAMING TEXT
|
||||
* Words resolve out of blur, inline citations appear in
|
||||
* context, then actions and follow-up prompts become usable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const WORD_MS = 55;
|
||||
const HOLD_MS = 3400;
|
||||
|
||||
/* one streamed word, or a `cite` placeholder that renders an inline source chip */
|
||||
export type StreamingToken = { text: string; cite?: boolean };
|
||||
|
||||
const TOKENS: StreamingToken[] = [
|
||||
..."Pistachio is your fastest-growing flavor — sales are up 23% this month and margins beat vanilla by 8 points."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
{ text: "", cite: true },
|
||||
..."Stone-fruit flavors are trending in the same range."
|
||||
.split(" ")
|
||||
.map((text) => ({ text })),
|
||||
];
|
||||
|
||||
const FOLLOW_UPS = [
|
||||
"Which flavors sell best in winter",
|
||||
"Compare gelato and soft serve margins",
|
||||
];
|
||||
|
||||
const SOURCE_IMAGES = {
|
||||
scoop:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231f7a5f'/%3E%3Cpath d='M20 36c0 7 5.4 12 12 12s12-5 12-12H20Z' fill='%23fff'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23bff3dd'/%3E%3Cpath d='M24 24c4-7 13-7 17 0' fill='none' stroke='%231f7a5f' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
trends:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%232f6fec'/%3E%3Cpath d='M15 43 27 31l8 7 14-18' fill='none' stroke='%23fff' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='49' cy='20' r='5' fill='%23bfe0ff'/%3E%3C/svg%3E",
|
||||
market:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23e56d24'/%3E%3Cpath d='M17 45V25h8v20h-8Zm11 0V16h8v29h-8Zm11 0V30h8v15h-8Z' fill='%23fff'/%3E%3Cpath d='M16 49h32' stroke='%23ffd6b8' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
/* one cited source rendered as an inline chip and in the sources list */
|
||||
export type StreamingSource = { name: string; domain: string; href: string; image: string };
|
||||
|
||||
const SOURCES: StreamingSource[] = [
|
||||
{ name: "Scoop Data", domain: "scoopdata.io", href: "https://scoopdata.io/", image: SOURCE_IMAGES.scoop },
|
||||
{ name: "Trends Index", domain: "trends.google.com", href: "https://trends.google.com/trends/", image: SOURCE_IMAGES.trends },
|
||||
{ name: "Market Basket", domain: "marketbasket.io", href: "https://marketbasket.io/", image: SOURCE_IMAGES.market },
|
||||
];
|
||||
|
||||
function sourceImage(source: StreamingSource) {
|
||||
return source.image;
|
||||
}
|
||||
|
||||
function SourceChip({ source }: { source?: StreamingSource }) {
|
||||
if (!source) return null;
|
||||
return (
|
||||
<a
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-0 mr-1 inline-flex h-4.5 translate-y-[-1px] items-center gap-1 rounded-[5px]
|
||||
bg-inset pr-[3px] pl-[3px] align-middle font-mono text-[10.5px] text-ink-2 shadow-hairline
|
||||
transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
style={{ animation: "pop-in 250ms cubic-bezier(0.23,1,0.32,1) both" }}
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-3 rounded-[3px]" />
|
||||
<span>{source.domain}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_ICONS: React.ReactNode[] = [
|
||||
<g key="copy"><rect x="9" y="9" width="12" height="12" rx="2.5" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></g>,
|
||||
<path key="retry" d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />,
|
||||
<path key="up" d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88z" />,
|
||||
<path key="down" d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88z" />,
|
||||
];
|
||||
|
||||
export type StreamingLabels = {
|
||||
/** label on the collapsed sources toggle */
|
||||
sources: string;
|
||||
/** heading above the follow-up prompts */
|
||||
followUps: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: StreamingLabels = {
|
||||
sources: "10 sources",
|
||||
followUps: "Follow-ups",
|
||||
};
|
||||
|
||||
export default function StreamingText({
|
||||
content = TOKENS,
|
||||
sources = SOURCES,
|
||||
followUps = FOLLOW_UPS,
|
||||
labels,
|
||||
loop = true,
|
||||
fill = false,
|
||||
onDone,
|
||||
onFollowUp,
|
||||
}: {
|
||||
variant?: string;
|
||||
/** the streamed tokens; `cite` tokens render an inline source chip */
|
||||
content?: StreamingToken[];
|
||||
/** cited sources shown in the chip, avatar stack, and expanded list */
|
||||
sources?: StreamingSource[];
|
||||
/** follow-up prompt suggestions shown once the stream completes */
|
||||
followUps?: string[];
|
||||
/** prominent copy strings */
|
||||
labels?: Partial<StreamingLabels>;
|
||||
/** restart the stream after a hold; turn off when embedding in a real thread */
|
||||
loop?: boolean;
|
||||
/** fill the parent width instead of the gallery's fixed measure */
|
||||
fill?: boolean;
|
||||
onDone?: () => void;
|
||||
/** fired when a follow-up prompt is chosen */
|
||||
onFollowUp?: (text: string, index: number) => void;
|
||||
} = {}) {
|
||||
const l = { ...DEFAULT_LABELS, ...labels };
|
||||
const [count, setCount] = useState(0);
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const done = count >= content.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (done && !loop) {
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(
|
||||
() => setCount((c) => (c >= content.length ? 0 : c + 1)),
|
||||
done ? HOLD_MS : WORD_MS,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, done, loop]);
|
||||
|
||||
return (
|
||||
<div className={fill ? "w-full" : "min-h-[15.5rem] w-full max-w-95"}>
|
||||
<p className="text-[13px] leading-relaxed text-ink">
|
||||
{content.slice(0, count).map((token, i) =>
|
||||
token.cite ? (
|
||||
<SourceChip key={i} source={sources[0]} />
|
||||
) : (
|
||||
<span key={i} className="inline">
|
||||
{token.text}{" "}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{!done && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3 w-0.5 translate-y-0.5 rounded-full bg-ink"
|
||||
style={{ animation: "fade-in 150ms ease-out both" }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* action icons row */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-0.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
{ACTION_ICONS.map((icon, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label="Action"
|
||||
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3
|
||||
transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{icon}
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={sourcesOpen}
|
||||
onClick={() => setSourcesOpen((current) => !current)}
|
||||
className="ml-1.5 flex items-center gap-1.5 rounded-[6px] px-1 py-0.5 text-left transition-colors duration-150 hover:bg-hover"
|
||||
>
|
||||
<span className="flex -space-x-1">
|
||||
{sources.map((source) => (
|
||||
<img
|
||||
key={source.domain}
|
||||
src={sourceImage(source)}
|
||||
alt=""
|
||||
className="source-avatar size-3.5 rounded-full bg-surface shadow-[0_0_0_1.5px_var(--canvas)]"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-[12px] text-ink-2">{l.sources}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{
|
||||
gridTemplateRows: done && sourcesOpen ? "1fr" : "0fr",
|
||||
opacity: done && sourcesOpen ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-1.5 flex flex-col rounded-[10px] bg-inset p-1 shadow-hairline">
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={source.domain}
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[6px] px-1.5 py-1 text-[12px] text-ink-2 transition-colors duration-150 hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<img src={sourceImage(source)} alt="" className="source-avatar size-4 rounded-[4px]" />
|
||||
<span className="animated-underline">{source.name}</span>
|
||||
<span className="ml-auto font-mono text-[10.5px] text-ink-3">{source.domain}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* follow-ups */}
|
||||
<div
|
||||
className="mt-2.5 transition-opacity duration-400"
|
||||
style={{ opacity: done ? 1 : 0, pointerEvents: done ? "auto" : "none" }}
|
||||
>
|
||||
<p className="text-[12px] font-medium text-ink-2">{l.followUps}</p>
|
||||
<div className="mt-0.5 flex flex-col">
|
||||
{followUps.map((text, i) => (
|
||||
<button
|
||||
key={text}
|
||||
onClick={() => onFollowUp?.(text, i)}
|
||||
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line
|
||||
px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors
|
||||
duration-100 hover:bg-hover-2"
|
||||
style={
|
||||
done
|
||||
? { animation: `fade-up 350ms cubic-bezier(0.23,1,0.32,1) ${i * 90}ms both` }
|
||||
: { opacity: 0 }
|
||||
}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M9 10l-5 5 5 5" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* THINKING — expandable agent trace, four variants
|
||||
*
|
||||
* Steps step list with spinner → muted checks
|
||||
* Reasoning prose reasoning that expands, then settles
|
||||
* Search web-search trace: query + sources read
|
||||
* Coding tool trace: files read, edits, commands
|
||||
*
|
||||
* The trace runs once, settles, and remains expandable.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGES = [800, 600, 1800, 2600, 1600];
|
||||
|
||||
function useSequence(steps: number[]) {
|
||||
const [stage, setStage] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stage >= steps.length - 1) return;
|
||||
const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
|
||||
return () => clearTimeout(t);
|
||||
}, [stage, steps]);
|
||||
return stage;
|
||||
}
|
||||
|
||||
type Row = {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
mono?: boolean;
|
||||
add?: number;
|
||||
del?: number;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VARIANTS: Record<
|
||||
string,
|
||||
{ active: string; done: string; rows: Row[]; query?: string }
|
||||
> = {
|
||||
Steps: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Reading flavor briefs" },
|
||||
{ primary: "Scanning supplier lists" },
|
||||
{ primary: "Comparing tasting notes", secondary: "6 flavors" },
|
||||
{ primary: "Writing the scoop report" },
|
||||
],
|
||||
},
|
||||
Reasoning: {
|
||||
active: "Thinking",
|
||||
done: "Thought for 4 seconds",
|
||||
rows: [
|
||||
{ primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
|
||||
{ primary: "I should check cone inventory before promoting a waffle-bowl special." },
|
||||
],
|
||||
},
|
||||
Search: {
|
||||
active: "Searching the web",
|
||||
done: "Searched the web",
|
||||
query: "best waffle cone supplier",
|
||||
rows: [
|
||||
{ primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
|
||||
{ primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
|
||||
{ primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
|
||||
],
|
||||
},
|
||||
Coding: {
|
||||
active: "Running tools",
|
||||
done: "Ran 3 tools",
|
||||
rows: [
|
||||
{ primary: "Read", secondary: "flavors.ts", mono: true },
|
||||
{ primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
|
||||
{ primary: "Run", secondary: "npm run freeze", mono: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function Dot({ tone }: { tone: string }) {
|
||||
return (
|
||||
<span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = ["bg-accent", "bg-orange", "bg-green"];
|
||||
|
||||
export default function ThinkingState({ variant = "Steps", onSettled }: { variant?: string; onSettled?: () => void }) {
|
||||
const stage = useSequence(STAGES);
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const v = VARIANTS[variant] ?? VARIANTS.Steps;
|
||||
const autoExpanded = stage >= 1 && stage < 4;
|
||||
const expanded = manualExpanded ?? autoExpanded;
|
||||
const working = stage < 3;
|
||||
const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
|
||||
const traceRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
|
||||
}, [visible, expanded, variant, stage]);
|
||||
|
||||
/* let embedders sequence content after the trace settles */
|
||||
const settledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (working || settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
onSettled?.();
|
||||
}, [working, onSettled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant}
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: working || expanded ? 176 : undefined,
|
||||
transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
|
||||
}}
|
||||
>
|
||||
{/* header — shared across variants */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
|
||||
transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
<span role="status" className="contents">
|
||||
{working ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "shimmer-text 1.4s linear infinite",
|
||||
}}
|
||||
>
|
||||
{v.active}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
style={{ animation: "fade-in 350ms ease-out both" }}
|
||||
>
|
||||
{v.done}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className="transition-transform duration-300"
|
||||
style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* expandable trace */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-400"
|
||||
style={{
|
||||
gridTemplateRows: expanded ? "1fr" : "0fr",
|
||||
opacity: expanded ? 1 : 0,
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
{v.query && (
|
||||
<div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<span className="text-[12.5px] text-ink-2">{v.query}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.rows.slice(0, visible).map((row, i) => {
|
||||
const content = (
|
||||
<>
|
||||
{variant === "Search" && <Dot tone={TONES[i % 3]} />}
|
||||
{variant === "Steps" && (
|
||||
i < visible - 1 || !working ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
|
||||
)
|
||||
)}
|
||||
<span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
|
||||
{row.primary}
|
||||
</span>
|
||||
{row.secondary && (
|
||||
<span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
|
||||
{row.secondary}
|
||||
</span>
|
||||
)}
|
||||
{row.add !== undefined && (
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums">
|
||||
<span className="text-green">+{row.add}</span>{" "}
|
||||
<span className="text-red">−{row.del}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
|
||||
const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
|
||||
|
||||
if (variant === "Search") {
|
||||
return (
|
||||
<a
|
||||
key={row.primary}
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "Coding") {
|
||||
const selected = selectedTool === row.primary;
|
||||
return (
|
||||
<button
|
||||
key={row.primary}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setSelectedTool(selected ? null : row.primary)}
|
||||
className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
|
||||
style={animation}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={row.primary} className={rowClass} style={animation}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{variant === "Search" && stage >= 3 && (
|
||||
<span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
|
||||
+7 more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
* TOOL CHIPS
|
||||
* An agent run as compact rows: tool calls with inline
|
||||
* chips, then file-diff chips summarizing the edits.
|
||||
* Hover a row to reveal its chevron; every row expands
|
||||
* to show what the tool actually did.
|
||||
* ───────────────────────────────────────────────────────── */
|
||||
|
||||
const STEP_MS = 700;
|
||||
|
||||
const Icons: Record<string, React.ReactNode> = {
|
||||
think: <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />,
|
||||
write: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z" /></g>,
|
||||
run: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 17l6-5-6-5M12 19h8" /></g>,
|
||||
read: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></g>,
|
||||
};
|
||||
|
||||
export type ToolDetailLine = { text: string; tone?: "add" };
|
||||
|
||||
export type ToolStep = {
|
||||
icon: string;
|
||||
label: string;
|
||||
chip: string;
|
||||
mono: boolean;
|
||||
detailMono: boolean;
|
||||
detail: ToolDetailLine[];
|
||||
};
|
||||
|
||||
export type ToolDiff = { file: string; add: number; del: number };
|
||||
|
||||
export type ToolDiffLine = { text: string; tone: "add" | "del" | "ctx" };
|
||||
|
||||
export type ToolChipsLabels = {
|
||||
header: string;
|
||||
more: string;
|
||||
};
|
||||
|
||||
const DEFAULT_LABELS: ToolChipsLabels = {
|
||||
header: "4 tool calls, 2 messages",
|
||||
more: "+2 more",
|
||||
};
|
||||
|
||||
const ROWS: ToolStep[] = [
|
||||
{
|
||||
icon: "think", label: "Thinking", chip: "Planning the churn schedule…", mono: false, detailMono: false,
|
||||
detail: [
|
||||
{ text: "Weekend demand carries pistachio, so it churns first." },
|
||||
{ text: "Batch capacity leaves two evening freezer windows." },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "write", label: "Write 204 lines", chip: "ChurnSchedule.tsx", mono: true, detailMono: true,
|
||||
detail: [
|
||||
{ text: "+ const windows = slots.filter((s) => s.temp <= -12)", tone: "add" },
|
||||
{ text: "+ return schedule(windows, { hero: \"pistachio\" })", tone: "add" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "run", label: "Rebuild and verify", chip: "npm run freeze", mono: true, detailMono: true,
|
||||
detail: [
|
||||
{ text: "✓ built in 1.2s" },
|
||||
{ text: "✓ 34 checks passed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "read", label: "Read image", chip: "flavor-chart.png", mono: true, detailMono: false,
|
||||
detail: [
|
||||
{ text: "1280 × 720 · line chart, three summers." },
|
||||
{ text: "Mint chip trends up 12% through July." },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DIFFS: ToolDiff[] = [
|
||||
{ file: "flavors.css", add: 13, del: 0 },
|
||||
{ file: "ChurnSchedule.tsx", add: 74, del: 41 },
|
||||
{ file: "menu.ts", add: 8, del: 2 },
|
||||
];
|
||||
|
||||
/* hovering a file chip opens its diff — green added, red removed */
|
||||
const DIFF_LINES: Record<string, ToolDiffLine[]> = {
|
||||
"flavors.css": [
|
||||
{ text: ".scoop-card {", tone: "ctx" },
|
||||
{ text: " gap: 14px;", tone: "del" },
|
||||
{ text: " gap: 12px;", tone: "add" },
|
||||
{ text: " container-type: inline-size;", tone: "add" },
|
||||
{ text: "}", tone: "ctx" },
|
||||
],
|
||||
"ChurnSchedule.tsx": [
|
||||
{ text: "const slots = coldSlots(week);", tone: "ctx" },
|
||||
{ text: "const windows = slots;", tone: "del" },
|
||||
{ text: "const windows = slots.filter(", tone: "add" },
|
||||
{ text: " (s) => s.temp <= -12,", tone: "add" },
|
||||
{ text: ");", tone: "add" },
|
||||
],
|
||||
"menu.ts": [
|
||||
{ text: "export const hero = \"mint-chip\";", tone: "del" },
|
||||
{ text: "export const hero = \"pistachio\";", tone: "add" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ToolChips({
|
||||
steps = ROWS,
|
||||
diffs = DIFFS,
|
||||
diffLines = DIFF_LINES,
|
||||
labels,
|
||||
className,
|
||||
onOpenChange,
|
||||
onToggleRow,
|
||||
}: {
|
||||
/** Accepted for gallery/registry parity; ToolChips has no visual variants. */
|
||||
variant?: string;
|
||||
steps?: ToolStep[];
|
||||
diffs?: ToolDiff[];
|
||||
diffLines?: Record<string, ToolDiffLine[]>;
|
||||
labels?: Partial<ToolChipsLabels>;
|
||||
className?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onToggleRow?: (label: string, open: boolean) => void;
|
||||
} = {}) {
|
||||
const copy = { ...DEFAULT_LABELS, ...labels };
|
||||
const [step, setStep] = useState(0);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [openRows, setOpenRows] = useState<Set<string>>(new Set());
|
||||
/* Rendered in a body portal so animated/translated reply wrappers cannot
|
||||
* redefine the fixed-position coordinate system. */
|
||||
const [preview, setPreview] = useState<{
|
||||
file: string;
|
||||
x: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
} | null>(null);
|
||||
const openPreview = (file: string) => (event: React.SyntheticEvent) => {
|
||||
const rect = (event.currentTarget as Element).closest("[data-diffchip]")!.getBoundingClientRect();
|
||||
const previewHeight = 38 + (diffLines[file]?.length ?? 0) * 19;
|
||||
const fitsBelow = rect.bottom + 6 + previewHeight <= window.innerHeight - 12;
|
||||
setPreview({
|
||||
file,
|
||||
x: Math.max(12, Math.min(rect.left, window.innerWidth - 300)),
|
||||
...(fitsBelow
|
||||
? { top: rect.bottom + 6 }
|
||||
: { bottom: window.innerHeight - rect.top + 6 }),
|
||||
});
|
||||
};
|
||||
const closePreview = (file: string) => () =>
|
||||
setPreview((current) => (current?.file === file ? null : current));
|
||||
const total = steps.length + 1; // rows, then diff chips
|
||||
|
||||
useEffect(() => {
|
||||
if (step >= total) return;
|
||||
const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [step, total]);
|
||||
|
||||
const toggleRow = (label: string) =>
|
||||
setOpenRows((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(label) ? next.delete(label) : next.add(label);
|
||||
onToggleRow?.(label, next.has(label));
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`min-h-[220px] w-full max-w-80 pb-1${className ? ` ${className}` : ""}`}>
|
||||
{/* collapsed run header */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpen((current) => {
|
||||
onOpenChange?.(!current);
|
||||
return !current;
|
||||
})
|
||||
}
|
||||
className="-mx-1.5 flex w-fit items-center gap-1.5 rounded-control px-1.5 py-1 text-[12.5px] text-ink-2 transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="transition-transform duration-200" style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span className="tabular-nums">{copy.header}</span>
|
||||
</button>
|
||||
|
||||
{/* tool call rows */}
|
||||
<div className="grid transition-[grid-template-rows,opacity] duration-300" style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0 }}>
|
||||
{/* -mx-1 + px-1.5 keeps content at the same x while giving the
|
||||
row hover pills room inside this overflow-hidden clip box */}
|
||||
<div className="-mx-1 overflow-hidden px-1.5 pb-1">
|
||||
<div className="mt-1.5 flex flex-col gap-1">
|
||||
{steps.slice(0, step).map((row) => {
|
||||
const rowOpen = openRows.has(row.label);
|
||||
return (
|
||||
<div key={row.label} style={{ animation: "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={rowOpen}
|
||||
onClick={() => toggleRow(row.label)}
|
||||
className="group/row -mx-[3px] flex h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-control px-[3px] text-left transition-colors duration-100 hover:bg-hover-2"
|
||||
>
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center text-ink-3">
|
||||
<svg
|
||||
width="13" height="13" viewBox="0 0 24 24" fill={row.icon === "think" ? "currentColor" : "none"} stroke="currentColor"
|
||||
className={`transition-opacity duration-100 group-hover/row:opacity-0 ${rowOpen ? "opacity-0" : ""}`}
|
||||
>
|
||||
{Icons[row.icon]}
|
||||
</svg>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`absolute transition-[opacity,transform] duration-150 group-hover/row:opacity-100 ${rowOpen ? "opacity-100" : "opacity-0"}`}
|
||||
style={{ transform: rowOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="shrink-0 text-[12.5px] font-medium text-ink">{row.label}</span>
|
||||
<span
|
||||
className={`inline-flex h-5.5 min-w-0 flex-1 cursor-pointer items-center truncate rounded-chip bg-field px-1.5
|
||||
text-[11.5px] text-ink-2 shadow-hairline transition-colors duration-100 hover:bg-hover-2
|
||||
${row.mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{row.chip}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* expanded detail */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity] duration-300"
|
||||
style={{ gridTemplateRows: rowOpen ? "1fr" : "0fr", opacity: rowOpen ? 1 : 0, transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)" }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5">
|
||||
{row.detail.map((line) => (
|
||||
<span
|
||||
key={line.text}
|
||||
className={`truncate text-[11.5px] leading-[1.6] ${row.detailMono ? "font-mono" : ""} ${line.tone === "add" ? "text-green" : "text-ink-2"}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* file-diff chips */}
|
||||
{step >= total && (
|
||||
<div className="mt-2.5 flex max-w-full flex-wrap gap-1.5 border-t border-line pt-2.5">
|
||||
{diffs.map((d, i) => (
|
||||
<span
|
||||
key={d.file}
|
||||
data-diffchip
|
||||
className="relative"
|
||||
onMouseEnter={openPreview(d.file)}
|
||||
onMouseLeave={closePreview(d.file)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={preview?.file === d.file}
|
||||
aria-label={`Show diff for ${d.file}`}
|
||||
onFocus={openPreview(d.file)}
|
||||
onBlur={closePreview(d.file)}
|
||||
className="inline-flex h-7 max-w-full items-center gap-2 rounded-chip
|
||||
bg-surface px-2 font-mono text-[11.5px] text-ink shadow-btn
|
||||
transition-colors duration-100 hover:bg-hover"
|
||||
style={{ animation: `pop-in 250ms cubic-bezier(0.23,1,0.32,1) ${i * 80}ms both` }}
|
||||
>
|
||||
<span className="min-w-0 truncate">{d.file}</span>
|
||||
<span className="shrink-0 text-green tabular-nums">+{d.add}</span>
|
||||
{d.del > 0 && <span className="shrink-0 text-red tabular-nums">−{d.del}</span>}
|
||||
</button>
|
||||
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center rounded-chip px-1.5 font-mono text-[11.5px] text-ink-3
|
||||
underline decoration-transparent underline-offset-2 transition-colors duration-100
|
||||
hover:text-ink-2 hover:decoration-current"
|
||||
style={{ animation: `fade-in 300ms ease-out ${diffs.length * 80}ms both` }}
|
||||
>
|
||||
{copy.more}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{preview && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
className="fixed z-50 w-72 overflow-hidden rounded-[10px] bg-surface shadow-overlay"
|
||||
style={{
|
||||
left: preview.x,
|
||||
top: preview.top,
|
||||
bottom: preview.bottom,
|
||||
animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both",
|
||||
transformOrigin: preview.top === undefined ? "bottom left" : "top left",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-2.5 py-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate text-ink-2">{preview.file}</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
<span className="text-green">+{diffs.find((diff) => diff.file === preview.file)?.add}</span>
|
||||
{(diffs.find((diff) => diff.file === preview.file)?.del ?? 0) > 0 && (
|
||||
<span className="text-red"> −{diffs.find((diff) => diff.file === preview.file)?.del}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-1 font-mono text-[11px] leading-[1.8]">
|
||||
{(diffLines[preview.file] ?? []).map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 px-2.5 whitespace-pre ${
|
||||
line.tone === "add"
|
||||
? "bg-green-tint text-green"
|
||||
: line.tone === "del"
|
||||
? "bg-red-tint text-red"
|
||||
: "text-ink-2"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 shrink-0 select-none">{line.tone === "add" ? "+" : line.tone === "del" ? "−" : " "}</span>
|
||||
<span className="min-w-0 truncate">{line.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+643
@@ -0,0 +1,643 @@
|
||||
@import "tailwindcss";
|
||||
/* shadow-plugin (flornkm) — smooth, layered shadow stacks. The unprefixed
|
||||
* entry maps them onto Tailwind's --shadow-xs…2xl scale; our semantic tokens
|
||||
* below build on those values so the whole app picks up the smooth shadows. */
|
||||
@import "shadow-plugin/unprefixed";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* GENERIC FOUNDATION — tokens, @theme, base rules, the primitive-*
|
||||
* spacing utilities, and shared keyframes below are needed by EVERY
|
||||
* component. Blocks tagged COMPONENT-SPECIFIC further down belong to
|
||||
* one primitive each. */
|
||||
/* ──────────────────────────────────────────────────────────
|
||||
* Beautiful UI — design tokens
|
||||
* Cool near-white canvas, white cards, hairline borders, layered
|
||||
* single-digit-opacity shadows, a neutral ink ramp, and semantic
|
||||
* color used sparingly as a condiment.
|
||||
* ────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* surfaces — cool, blue-tinted neutrals */
|
||||
--page: oklch(0.985 0.001 286.376);
|
||||
--canvas: oklch(0.961 0.002 247.84);
|
||||
--surface: oklch(1 0 0);
|
||||
--inset: oklch(0.979 0.002 247.839);
|
||||
--hover: oklch(0.97 0.002 247.839);
|
||||
--hover-2: oklch(0.933 0.003 247.86);
|
||||
|
||||
/* ink ramp */
|
||||
--ink: oklch(0.247 0.006 258.361);
|
||||
--ink-2: oklch(0.506 0.01 264.477);
|
||||
--ink-3: oklch(0.695 0.009 264.505);
|
||||
|
||||
/* borders — solid and crisp, not alpha */
|
||||
--line: oklch(0.946 0.003 264.542);
|
||||
--line-strong: oklch(0.912 0.005 258.326);
|
||||
--line-soft: oklch(0.966 0.002 264.542);
|
||||
--grid-line: color-mix(in srgb, var(--line) 78%, transparent);
|
||||
--field: oklch(0.961 0.001 286.375);
|
||||
--stripe: oklch(0.405 0 0 / 0.075);
|
||||
--stripe-bg: oklch(0.97 0 0);
|
||||
|
||||
/* accent */
|
||||
--accent: oklch(0.626 0.205 254.947);
|
||||
--accent-ink: oklch(0.556 0.187 255.617);
|
||||
--accent-tint: oklch(0.96 0.019 252.878);
|
||||
|
||||
/* semantic */
|
||||
--green: oklch(0.603 0.155 150.883);
|
||||
--green-tint: oklch(0.958 0.017 159.118);
|
||||
--orange: oklch(0.689 0.179 49.902);
|
||||
--orange-tint: oklch(0.964 0.021 67.581);
|
||||
--red: oklch(0.621 0.192 23.042);
|
||||
--red-tint: oklch(0.956 0.017 17.462);
|
||||
|
||||
/* chart overlays — neutral system colors with enough contrast on either theme */
|
||||
--tooltip-bg: oklch(0.272 0.008 264.435);
|
||||
--tooltip-fg: oklch(0.976 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.356 0.007 264.474);
|
||||
|
||||
/* shadows — our solid hairline ring + shadow-plugin's smooth layered stacks
|
||||
* (--shadow-xs…lg come from shadow-plugin/unprefixed) so every surface reads
|
||||
* consistently across the app. */
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
--shadow-btn: 0 0 0 1px var(--line-strong), var(--shadow-xs);
|
||||
--shadow-card: 0 0 0 1px var(--line), var(--shadow-sm);
|
||||
--shadow-raised: 0 0 0 1px var(--line), var(--shadow-md);
|
||||
--shadow-overlay: 0 0 0 1px var(--line), var(--shadow-lg);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: oklch(0.209 0.004 264.477);
|
||||
--canvas: oklch(0.231 0.004 264.487);
|
||||
--surface: oklch(0.26 0.006 271.191);
|
||||
--inset: oklch(0.243 0.004 264.492);
|
||||
--hover: oklch(0.289 0.006 271.22);
|
||||
--hover-2: oklch(0.318 0.007 274.747);
|
||||
|
||||
--ink: oklch(0.964 0.002 247.839);
|
||||
--ink-2: oklch(0.731 0.008 260.731);
|
||||
--ink-3: oklch(0.541 0.01 264.484);
|
||||
|
||||
--line: oklch(0.308 0.006 258.354);
|
||||
--line-strong: oklch(0.356 0.007 264.474);
|
||||
--line-soft: oklch(0.278 0.006 258.354);
|
||||
--field: oklch(0.293 0.006 271.223);
|
||||
--stripe: oklch(1 0 0 / 0.055);
|
||||
--stripe-bg: oklch(0.226 0.004 264.485);
|
||||
|
||||
--accent: oklch(0.68 0.173 253.301);
|
||||
--accent-ink: oklch(0.788 0.113 248.33);
|
||||
--accent-tint: oklch(0.68 0.173 253.301 / 0.16);
|
||||
|
||||
--green: oklch(0.705 0.154 153.814);
|
||||
--green-tint: oklch(0.705 0.154 153.814 / 0.14);
|
||||
--orange: oklch(0.746 0.156 55.642);
|
||||
--orange-tint: oklch(0.746 0.156 55.642 / 0.14);
|
||||
--red: oklch(0.666 0.18 21.433);
|
||||
--red-tint: oklch(0.666 0.18 21.433 / 0.14);
|
||||
|
||||
--tooltip-bg: oklch(0.182 0.004 264.459);
|
||||
--tooltip-fg: oklch(0.964 0.002 247.839);
|
||||
--tooltip-muted: oklch(0.731 0.008 260.731);
|
||||
--tooltip-border: oklch(0.308 0.006 258.354);
|
||||
|
||||
--shadow-hairline: 0 0 0 1px var(--line);
|
||||
/* Elevation rings go to a subtle *light* hairline in dark mode: the ring
|
||||
* paints over the surface behind the element, so a low-alpha white edge
|
||||
* dissolves into the shadow as one continuous stroke (per smooth-shadow-ring),
|
||||
* graduated by how far the surface floats. */
|
||||
--shadow-btn: 0 0 0 1px oklch(1 0 0 / 0.1), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||
--shadow-card:
|
||||
0 0 0 1px oklch(1 0 0 / 0.11), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 2px 6px oklch(0 0 0 / 0.2);
|
||||
--shadow-raised:
|
||||
0 0 0 1px oklch(1 0 0 / 0.13), 0 2px 10px oklch(0 0 0 / 0.22);
|
||||
--shadow-overlay:
|
||||
0 0 0 1px oklch(1 0 0 / 0.15), 0 8px 28px oklch(0 0 0 / 0.34);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* colors */
|
||||
--color-page: var(--page);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-surface: var(--surface);
|
||||
--color-inset: var(--inset);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-2: var(--ink-2);
|
||||
--color-ink-3: var(--ink-3);
|
||||
--color-hover: var(--hover);
|
||||
--color-hover-2: var(--hover-2);
|
||||
--color-field: var(--field);
|
||||
--color-line: var(--line);
|
||||
--color-line-strong: var(--line-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-green: var(--green);
|
||||
--color-green-tint: var(--green-tint);
|
||||
--color-orange: var(--orange);
|
||||
--color-orange-tint: var(--orange-tint);
|
||||
--color-red: var(--red);
|
||||
--color-red-tint: var(--red-tint);
|
||||
|
||||
/* shadows */
|
||||
--shadow-hairline: var(--shadow-hairline);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-inset-field: var(--shadow-inset-field);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-raised: var(--shadow-raised);
|
||||
--shadow-overlay: var(--shadow-overlay);
|
||||
|
||||
/* type */
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono-face), ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* radii — chips 6, controls 8, cards 10, windows 14, pills 999 */
|
||||
--radius-chip: 6px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 10px;
|
||||
--radius-window: 14px;
|
||||
|
||||
/* easing — strong curves; built-ins are too weak */
|
||||
--ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-link: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── base ─────────────────────────────────────────────── */
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--stripe-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent 0,
|
||||
transparent 7px,
|
||||
var(--stripe) 7px,
|
||||
var(--stripe) 8px
|
||||
);
|
||||
/* Pin the stripe pattern to the viewport so it stays put on scroll. */
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-tint);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── interface hygiene (interfaces.dev cheat sheet) ────── */
|
||||
|
||||
/* headings balance; descriptions avoid orphans (skip long-form — we have none) */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* underlines clear descenders */
|
||||
a {
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: auto;
|
||||
}
|
||||
|
||||
/* theme switches swap every token at once — freeze transitions so the flip
|
||||
* is one clean repaint instead of hundreds of mismatched color fades */
|
||||
.theme-switching *,
|
||||
.theme-switching *::before,
|
||||
.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
|
||||
@media (hover: none) {
|
||||
.records-row:hover > .records-cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.records-row:hover > td.is-colsel {
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--surface));
|
||||
}
|
||||
|
||||
.records-header-button:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the component nav scrollable but hide its scrollbar. */
|
||||
.component-nav-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge */
|
||||
}
|
||||
.component-nav-scroll::-webkit-scrollbar {
|
||||
display: none; /* WebKit */
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.component-nav-scroll {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
oklch(0 0 0) 0,
|
||||
oklch(0 0 0) calc(100% - 6rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile demo gestures should operate controls and visualizations rather than
|
||||
* selecting surrounding labels. Text entry and code remain intentionally
|
||||
* selectable. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.primitive-showcase {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(input, textarea, [contenteditable="true"], pre, code) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]) {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(a, button, [role="button"]):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primitive-showcase :is(img, svg, canvas) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.insight-chart-stage {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tailwind v4 resets buttons to cursor:default — anything
|
||||
* clickable must invite the click */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label:has(input),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.animated-underline::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 280ms var(--ease-link);
|
||||
}
|
||||
|
||||
a:hover .animated-underline::after,
|
||||
a:focus-visible .animated-underline::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamingText / ContextCards
|
||||
* Favicon source avatars.
|
||||
* Copy this block only alongside StreamingText / ContextCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(0.21 0.034 263.436 / 0.1);
|
||||
}
|
||||
|
||||
.dark .source-avatar {
|
||||
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* ── primitive spacing system ─────────────────────────────
|
||||
* Shared optical offsets for small demo cards. Use these
|
||||
* instead of hand-tuning 10/12/14px per component.
|
||||
*/
|
||||
|
||||
.primitive-card-pad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.primitive-card-bar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-card-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.primitive-table-cell {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.primitive-icon-button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
/* ── shared keyframes ─────────────────────────────────── */
|
||||
|
||||
@keyframes shimmer-text {
|
||||
from {
|
||||
background-position: 150% center;
|
||||
}
|
||||
to {
|
||||
background-position: -50% center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.home-reveal {
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* The sidebar is one persistent 224px tree clipped by a 52px shell.
|
||||
* Every rail glyph remains centered at x=26 in both states. */
|
||||
.records-footer-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── filter table electric status colors ─────────────────── */
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — FilterTable
|
||||
* Electric status-pill colors.
|
||||
* Copy this block only alongside FilterTable; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — InsightCards
|
||||
* Liveline chart tooltip + cursor.
|
||||
* Copy this block only alongside InsightCards; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.insight-chart-stage {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insight-chart-cursor {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--ink);
|
||||
opacity: 0.26;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-anchor {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--tooltip-fg);
|
||||
background: var(--tooltip-bg);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.insight-chart-tooltip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Status pills share the same base-hue → color-mix derivation as .records-tag,
|
||||
* so both components stay consistent across light and dark. */
|
||||
.filter-status-todo {
|
||||
--tag-base: oklch(0.757 0.153 66.401);
|
||||
}
|
||||
.filter-status-progress {
|
||||
--tag-base: oklch(0.671 0.118 219.351);
|
||||
}
|
||||
.filter-status-done {
|
||||
--tag-base: oklch(0.652 0.131 162.865);
|
||||
}
|
||||
|
||||
.filter-status-todo,
|
||||
.filter-status-progress,
|
||||
.filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
|
||||
background: color-mix(in srgb, var(--tag-base) 20%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
/* Dark mode — a richer, saturated same-hue fill
|
||||
* with bright same-hue text and no visible border (light mode keeps the soft
|
||||
* tinted pastels above). Applies to both the records grid and status pills. */
|
||||
.dark .records-tag,
|
||||
.dark .filter-status-todo,
|
||||
.dark .filter-status-progress,
|
||||
.dark .filter-status-done {
|
||||
color: color-mix(in srgb, var(--tag-base) 86%, white);
|
||||
background: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--tag-base) 34%, var(--surface));
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes eq-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── StreamText primitive ─────────────────────────────────
|
||||
* Fast character reveal: the newest few characters carry a
|
||||
* soft, masked blur so the leading edge resolves smoothly as
|
||||
* text flows in. The caret holds solid while streaming, then
|
||||
* blinks once the string settles. */
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — StreamText
|
||||
* The streaming caret + tail.
|
||||
* Copy this block only alongside StreamText; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.stream-tail {
|
||||
filter: blur(1.6px);
|
||||
-webkit-mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
mask-image: linear-gradient(to right, oklch(0 0 0) 20%, oklch(0 0 0 / 0.2));
|
||||
}
|
||||
|
||||
.stream-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.05em;
|
||||
margin-left: 1.5px;
|
||||
translate: 0 -0.5px;
|
||||
border-radius: 1px;
|
||||
background: var(--ink);
|
||||
vertical-align: text-bottom;
|
||||
animation: caret-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.stream-caret.is-streaming {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stream-caret {
|
||||
animation: none;
|
||||
}
|
||||
.stream-tail {
|
||||
filter: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* pixel-grid loader — brighten plateau, staggered per cell */
|
||||
@keyframes pixel-on {
|
||||
0%, 100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
18%, 42% {
|
||||
opacity: 1;
|
||||
}
|
||||
62% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
* COMPONENT-SPECIFIC — SidebarNav
|
||||
* The 52↔224px collapsible sidebar tree.
|
||||
* Copy this block only alongside SidebarNav; it is not part of the
|
||||
* generic foundation above.
|
||||
* ══════════════════════════════════════════════════════════ */
|
||||
.sidebar-row {
|
||||
width: 208px;
|
||||
transition-duration: var(--sidebar-copy-duration);
|
||||
transition-timing-function: var(--sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
opacity var(--sidebar-copy-duration) ease-out,
|
||||
transform var(--sidebar-copy-duration) var(--sidebar-easing);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-collapse-control,
|
||||
.sidebar-expand-control {
|
||||
transition-duration: var(--sidebar-copy-duration);
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.sidebar-expand-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-glide-highlight {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-row {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-copy {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--sidebar-copy-offset) * -1));
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-workspace-control {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-logo,
|
||||
[data-sidebar-collapsed="true"] .sidebar-collapse-control {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-expand-control {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-sidebar-collapsed="true"] .sidebar-glide-highlight {
|
||||
right: auto;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── records table ────────────────────────────────────────
|
||||
* The grid is deliberately more architectural than card-like:
|
||||
* one quiet surface, precise rules, and color reserved for
|
||||
* meaning. The first column remains an anchor while both axes
|
||||
* can move independently inside the table viewport.
|
||||
*/
|
||||
Reference in New Issue
Block a user