Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/aura.css
Original file line number Diff line number Diff line change
Expand Up @@ -163,19 +163,25 @@
color: var(--destructive);
}
.chat-subline-pulse {
position: relative;
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.chat-subline-pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: currentColor;
animation: chat-subline-pulse 1.4s ease-out infinite;
}
@keyframes chat-subline-pulse {
from {
box-shadow: 0 0 0 0 currentColor;
}
to {
box-shadow: 0 0 0 6px transparent;
opacity: 0;
scale: 3;
}
}

Expand Down Expand Up @@ -309,7 +315,7 @@

@media (prefers-reduced-motion: reduce) {
.wisp-halo[data-state="working"]::before,
.chat-subline-pulse {
.chat-subline-pulse::after {
animation: none;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function ContextMenuContent({ className, children, ...props }: ContextMenuPrimit
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"z-50 min-w-52 rounded-xl border border-border bg-card p-1 text-foreground shadow-lg outline-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"z-50 min-w-52 rounded-xl border border-border bg-card p-1 text-foreground shadow-lg outline-none data-open:animate-in data-open:fade-in-0 data-open:ease-out data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 min-w-44 rounded-xl border border-border bg-card p-1 text-foreground shadow-lg outline-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"z-50 min-w-44 rounded-xl border border-border bg-card p-1 text-foreground shadow-lg outline-none data-open:animate-in data-open:fade-in-0 data-open:ease-out data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function PopoverContent({
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 w-72 rounded-xl border border-border bg-card p-2 text-foreground shadow-lg outline-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"z-50 w-72 rounded-xl border border-border bg-card p-2 text-foreground shadow-lg outline-none data-open:animate-in data-open:fade-in-0 data-open:ease-out data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function SelectContent({
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-open:animate-in data-open:fade-in-0 data-open:ease-out data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/toggle-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ function ToggleSwitch({ checked, label, onChange, disabled = false }: ToggleSwit
>
<span
className={cn(
"absolute top-[3px] left-[3px] size-3.5 rounded-full bg-dim transition-[left,background-color] duration-[140ms]",
checked && "left-[17px] bg-primary-foreground",
"absolute top-[3px] left-[3px] size-3.5 rounded-full bg-dim transition-[translate,background-color] duration-[140ms]",
checked && "translate-x-[14px] bg-primary-foreground",
)}
/>
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-sm text-background has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"z-50 inline-flex w-fit max-w-xs items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-sm text-background has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-open:animate-in data-open:fade-in-0 data-open:ease-out data-closed:animate-out data-closed:fade-out-0 data-instant:animate-none",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/update-toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function ToastFrame({ children }: { children: ReactNode }) {
return createPortal(
<div
role="status"
className="pointer-events-auto flex w-full items-start gap-2.5 rounded-xl border border-border bg-card p-3.5 text-foreground shadow-lg animate-in fade-in-0 slide-in-from-bottom-2"
className="pointer-events-auto flex w-full items-start gap-2.5 rounded-xl border border-border bg-card p-3.5 text-foreground shadow-lg animate-in fade-in-0 slide-in-from-bottom-2 ease-out"
>
{children}
</div>,
Expand Down
8 changes: 5 additions & 3 deletions src/components/wisp.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createElement, useId, type CSSProperties, type ComponentProps, type ReactNode } from "react";
import { createElement, useId, useRef, type CSSProperties, type ComponentProps, type ReactNode } from "react";

import { DEFAULT_WISP_APPEARANCE, type WispAppearance } from "../../shared/wisp-appearance";
import { useWispBlink } from "@/hooks/use-wisp-blink";
import { cn } from "@/lib/utils";
import {
BODY_GEOMETRY,
hashSeed,
NO_TRAIL_OFFSET,
TRAIL_GEOMETRY,
luminance,
Expand Down Expand Up @@ -132,6 +132,8 @@ function Wisp({
...props
}: WispProps) {
const id = `wisp-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
const eyesRef = useRef<SVGGElement>(null);
useWispBlink(eyesRef, name);
const pixels = PIXELS[size];
const small = pixels < 32;
const large = pixels >= 48;
Expand Down Expand Up @@ -227,7 +229,7 @@ function Wisp({
)}
<g transform={`translate(${faceX} ${faceY})`}>
<g className="wisp-look">
<g className="wisp-blink" style={{ animationDelay: `${-(hashSeed(name) % 6000)}ms` }}>
<g ref={eyesRef} className="wisp-blink">
<WispEyes kind={appearance.eyes} ink={ink} scale={eyeScale} />
</g>
</g>
Expand Down
76 changes: 76 additions & 0 deletions src/hooks/use-wisp-blink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { Wisp } from "@/components/wisp";
import { hashSeed } from "@/lib/wisp-appearance";

const NAME = "Nova";
const FIRST_BLINK_MS = (2760 - (hashSeed(NAME) % 6000) + 6000) % 6000;

function setReducedMotion(matches: boolean) {
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches }));
}

describe("useWispBlink", () => {
const animate = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
animate.mockClear();
setReducedMotion(false);
(SVGElement.prototype as unknown as { animate: typeof animate }).animate = animate;
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete (SVGElement.prototype as unknown as { animate?: typeof animate }).animate;
});

it("blinks once per period at the place the name gives it", () => {
render(<Wisp name={NAME} size="xl" />);

vi.advanceTimersByTime(FIRST_BLINK_MS - 1);
expect(animate).not.toHaveBeenCalled();

vi.advanceTimersByTime(1);
expect(animate).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(12000);
expect(animate).toHaveBeenCalledTimes(3);
});

it("blinks in the conversation list", () => {
render(
<span className="conversation-avatar">
<Wisp name={NAME} />
</span>,
);

vi.advanceTimersByTime(6000);
expect(animate).toHaveBeenCalled();
});

it("leaves a Wisp elsewhere still", () => {
render(<Wisp name={NAME} />);

vi.advanceTimersByTime(12000);
expect(animate).not.toHaveBeenCalled();
});

it("does not blink when the person asks for reduced motion", () => {
setReducedMotion(true);
render(<Wisp name={NAME} size="xl" />);

vi.advanceTimersByTime(12000);
expect(animate).not.toHaveBeenCalled();
});

it("stops when the Wisp leaves the screen", () => {
const { unmount } = render(<Wisp name={NAME} size="xl" />);

unmount();
vi.advanceTimersByTime(12000);
expect(animate).not.toHaveBeenCalled();
});
});
47 changes: 47 additions & 0 deletions src/hooks/use-wisp-blink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useEffect, type RefObject } from "react";

import { hashSeed } from "@/lib/wisp-appearance";

const BLINK_PERIOD_MS = 6000;
/** Where in its period a Wisp blinks, so a name keeps the same place in the rhythm. */
const BLINK_START_MS = 2760;
const BLINK_DURATION_MS = 240;

/** Only the large picture and the conversation list blink; elsewhere the eyes are too small to read. */
const BLINKING_WISP = '[data-slot="wisp"][data-size="xl"], .conversation-avatar [data-slot="wisp"]';

const BLINK_KEYFRAMES: Keyframe[] = [
{ scale: "1 1", easing: "ease" },
{ scale: "1 0.1", easing: "ease" },
{ scale: "1 1" },
];

/**
* Blinks a Wisp's eyes every few seconds. An endless CSS animation made Chromium
* recalculate styles and layout on every frame even between blinks, so the idle
* app never stopped working. A timer starts a short animation only when one is due.
*/
export function useWispBlink(eyesRef: RefObject<SVGGElement | null>, seed: string) {
useEffect(() => {
const eyes = eyesRef.current;
if (!eyes?.closest(BLINKING_WISP) || typeof eyes.animate !== "function") return;

function blink() {
// CSS reduced-motion rules do not reach animations started from script.
if (document.hidden || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
eyes?.animate(BLINK_KEYFRAMES, BLINK_DURATION_MS);
}

let interval: ReturnType<typeof setInterval> | undefined;
const firstDelay = (BLINK_START_MS - (hashSeed(seed) % BLINK_PERIOD_MS) + BLINK_PERIOD_MS) % BLINK_PERIOD_MS;
const first = setTimeout(() => {
blink();
interval = setInterval(blink, BLINK_PERIOD_MS);
}, firstDelay);

return () => {
clearTimeout(first);
clearInterval(interval);
};
}, [eyesRef, seed]);
}
24 changes: 7 additions & 17 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@
--text-lg: 17px;
--text-xl: 20px;

--animate-message-in: message-in 160ms ease-out;
--animate-message-highlight: message-highlight 1.8s ease-out;
--animate-panel-in: panel-in 150ms ease-out;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);

--animate-message-in: message-in 160ms var(--ease-out);
--animate-message-highlight: message-highlight 1.8s var(--ease-out);
--animate-panel-in: panel-in 150ms var(--ease-out);
--animate-typing-dot: typing-dot 1.2s ease-in-out infinite;
--animate-tab-forward: tab-forward 170ms ease-out;
--animate-tab-back: tab-back 170ms ease-out;
--animate-tab-forward: tab-forward 170ms var(--ease-out);
--animate-tab-back: tab-back 170ms var(--ease-out);

@keyframes message-in {
from {
Expand Down Expand Up @@ -289,7 +291,6 @@
.conversation-avatar [data-slot="wisp"] .wisp-blink {
transform-box: fill-box;
transform-origin: center;
animation: wisp-blink 6s infinite;
}
[data-slot="wisp"][data-state="working"] .wisp-trail {
animation: wisp-sway 1.5s ease-in-out infinite;
Expand All @@ -310,17 +311,6 @@
translate: 0 3px;
}

@keyframes wisp-blink {
0%,
46%,
50%,
100% {
scale: 1 1;
}
48% {
scale: 1 0.1;
}
}
@keyframes wisp-sway {
0%,
100% {
Expand Down
Loading