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
272 changes: 270 additions & 2 deletions apps/web/bun.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,34 @@
"openapi-ts": "openapi-ts"
},
"dependencies": {
"@capacitor/app": "8.1.0",
"@capacitor/browser": "8.0.3",
"@capacitor/core": "8.3.4",
"@capacitor/filesystem": "8.1.2",
"@capacitor/local-notifications": "8.2.0",
"@capacitor/share": "8.0.1",
"@hey-api/client-fetch": "0.13.1",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slot": "1.2.4",
"@sentry/browser": "10.53.1",
"@sentry/react": "10.53.1",
"@tanstack/react-query": "5.90.21",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@vellum/design-library": "file:../../packages/design-library",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"jszip": "3.10.1",
"lucide-react": "1.16.0",
"motion": "12.39.0",
"pdfjs-dist": "5.7.284",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "10.1.0",
"react-router": "7.15.0",
"remark-gfm": "4.0.1",
"shiki": "4.1.0",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
Expand Down
85 changes: 85 additions & 0 deletions apps/web/src/components/app-nav-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@

import { ArrowUp, ChevronUp, Globe, Loader2, Pencil, X } from "lucide-react";

import { Button } from "@vellum/design-library";
import { Typography } from "@vellum/design-library";
import { useIsMobile } from "@/hooks/use-is-mobile.js";
import { cn } from "@/utils/misc.js";

export interface AppNavBarProps {
appName: string;
onEdit?: () => void;
/**
* Desktop: flips the left button label to "Close chat".
* Mobile: swaps the right-side edit icon to a chevron-up + active state,
* marking the bar as the slide-up affordance for the minimized app strip.
*/
isEditing?: boolean;
onShare?: () => void;
isSharing?: boolean;
onDeploy?: () => void;
isDeploying?: boolean;
onClose: () => void;
}

export function AppNavBar({ appName, onEdit, isEditing, onShare, isSharing, onDeploy, isDeploying, onClose }: AppNavBarProps) {
const isMobile = useIsMobile();
// While the bar is acting as the minimized strip on mobile, tapping the
// title is the primary "open app" affordance — same callback as the
// chevron-up icon next to it.
const titleClickEnabled = isMobile && isEditing === true && onEdit != null;

return (
<div className="flex items-center justify-between rounded-t-xl bg-[var(--surface-lift)] px-4 py-3">
<div className="hidden md:flex items-center min-w-[72px]">
{onEdit != null && (
<Button onClick={onEdit}>{isEditing ? "Close chat" : "Edit"}</Button>
)}
</div>

<Typography
variant="body-large-default"
className={cn(
"flex-1 truncate text-left md:text-center text-[var(--content-emphasised)]",
titleClickEnabled && "cursor-pointer",
)}
style={{ lineHeight: 1.4 }}
onClick={titleClickEnabled ? onEdit : undefined}
>
{appName}
</Typography>

<div className="flex items-center gap-1.5 min-w-[72px] justify-end">
{onDeploy != null && (
<Button
variant="outlined"
iconOnly={isDeploying ? <Loader2 className="animate-spin" /> : <Globe />}
onClick={onDeploy}
disabled={isDeploying}
tooltip={isDeploying ? "Deploying…" : "Deploy"}
/>
)}
{onShare != null && (
<Button
variant="outlined"
iconOnly={isSharing ? <Loader2 className="animate-spin" /> : <ArrowUp />}
onClick={onShare}
disabled={isSharing}
tooltip={isSharing ? "Sharing…" : "Share"}
/>
)}
{onEdit != null && (
<Button
variant="outlined"
iconOnly={isEditing ? <ChevronUp /> : <Pencil />}
onClick={onEdit}
tooltip={isEditing ? "Open app" : "Edit"}
active={isEditing}
className="md:hidden"
/>
)}
<Button variant="outlined" iconOnly={<X />} onClick={onClose} tooltip="Close" />
</div>
</div>
);
}
74 changes: 74 additions & 0 deletions apps/web/src/components/app-root-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@

import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";

/**
* Context exposing the live `.app-root` element to descendants.
*
* Overlay primitives (Modal, BottomSheet, Popover, Menu, ContextMenu,
* Dropdown) need to portal into `.app-root` so the theme variables defined
* there in `appTheme.css` (e.g. `--surface-lift`, `--border-base`) resolve
* inside the portal. Reading the DOM during render to find that element is
* unsafe — see the rule in `web/AGENTS.md` ("Don't read the DOM during
* render"): with React Compiler enabled, the result of a render-time
* `document.querySelector` can be auto-memoized into the fiber's compile
* cache and persist across subsequent renders, even after the DOM changes.
*
* Subscribing to `useAppRootContainer()` returns the actual element and
* re-renders when the host `<div>` mounts, with no DOM reads in render.
*/
const AppRootContext = createContext<HTMLElement | null>(null);

/**
* Provider that renders the host `<div class="app-root">` and shares it via
* context. Mount this at the top of the signed-in app shell so every
* descendant primitive can portal into the same theme-scoped element.
*/
export function AppRootProvider({ children }: { children: ReactNode }) {
// Use a stable `useRef` for the DOM node and `useState` for the published
// context value. `useEffect` with `[]` runs once after the first commit
// and copies the live element into state, which triggers a single
// re-render so consumers receive the resolved container.
//
// We deliberately avoid the `ref={setStateSetter}` pattern here. With
// React Compiler enabled, ref-callback identity can be observed as
// changing between renders, which causes React 19's cleanup-aware ref
// contract to fire setter(null) → setter(element) on every commit and
// produce a "Maximum update depth exceeded" loop in Suspense-wrapped
// trees. The split ref/state pattern below keeps the ref callback stable
// and decouples the published value from React's ref reconciliation.
const ref = useRef<HTMLDivElement | null>(null);
const [container, setContainer] = useState<HTMLElement | null>(null);

useEffect(() => {
setContainer(ref.current);
}, []);

return (
<div ref={ref} className="app-root w-full min-w-0 font-sans">
<AppRootContext value={container}>
{children}
</AppRootContext>
</div>
);
}

/**
* Returns the live `.app-root` element, or `null` when called outside the
* `<AppRootProvider>` tree (e.g. tests without a wrapper, marketing pages).
*
* Callers that need to feed a Radix `Portal` `container` prop should pass
* `useAppRootContainer() ?? undefined` so Radix falls back to
* `document.body` when no provider is mounted. Callers that drive
* `react-dom`'s `createPortal` directly should bail out / render inline
* when the value is `null`.
*/
export function useAppRootContainer(): HTMLElement | null {
return useContext(AppRootContext);
}
43 changes: 43 additions & 0 deletions apps/web/src/components/avatar-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

import { useMemo } from "react";

import { composeSvg } from "@/domains/avatar/svg-compositor.js";
import type { CharacterComponents } from "@/domains/avatar/types.js";

export interface AvatarRendererProps {
components: CharacterComponents;
bodyShapeId: string;
eyeStyleId: string;
colorId: string;
size?: number;
className?: string;
}

export function AvatarRenderer({
components,
bodyShapeId,
eyeStyleId,
colorId,
size = 56,
className,
}: AvatarRendererProps) {
const svgString = useMemo(() => {
try {
return composeSvg(components, bodyShapeId, eyeStyleId, colorId, size);
} catch {
return null;
}
}, [components, bodyShapeId, eyeStyleId, colorId, size]);

if (!svgString) {
return null;
}

return (
<div
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
dangerouslySetInnerHTML={{ __html: svgString }}
/>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/components/avatar/animated-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";

import { computeTransforms, resolveDefinitions } from "@/domains/avatar/svg-compositor.js";
import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js";
import type { CharacterComponents, CharacterTraits, EyePathDefinition } from "@/domains/avatar/types.js";

interface AnimatedAvatarProps {
components: CharacterComponents;
Expand Down Expand Up @@ -292,7 +292,7 @@ export function AnimatedAvatar({
transition: "transform 0.15s ease-in-out",
}}
>
{eyeStyle.paths.map((p, i) => (
{eyeStyle.paths.map((p: EyePathDefinition, i: number) => (
<path
key={i}
d={p.svgPath}
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/command-palette/command-palette-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@

import type { LucideIcon } from "lucide-react";
import { forwardRef, type ReactNode } from "react";

import { PanelItem } from "@vellum/design-library";

export interface CommandPaletteItemProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
shortcutHint?: ReactNode;
isSelected: boolean;
onClick: () => void;
}

/**
* A single result row inside the CommandPalette. Built on the PanelItem
* primitive for consistent hover/active treatment.
*/
export const CommandPaletteItem = forwardRef<
HTMLButtonElement,
CommandPaletteItemProps
>(function CommandPaletteItem(
{ icon, title, subtitle, shortcutHint, isSelected, onClick },
ref,
) {
return (
<PanelItem
ref={ref}
icon={icon}
label={
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{title}</span>
{subtitle ? (
<span className="shrink-0 truncate text-[var(--content-tertiary)] text-body-small-default">
{subtitle}
</span>
) : null}
{shortcutHint ? (
<span className="ml-auto shrink-0 text-[var(--content-tertiary)] text-body-small-default">
{shortcutHint}
</span>
) : null}
</span>
}
active={isSelected}
onSelect={onClick}
className="px-3 py-2"
/>
);
});
Loading