Fix MCP widgets according to consumer app (#1189)

Co-authored-by: ved015 <ved015@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Prasanna A P <106952318+Prasanna721@users.noreply.github.com>
This commit is contained in:
Ishaan Gupta 2026-07-21 19:38:57 +05:30 committed by GitHub
parent 2e8244f78d
commit 600537cd9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1036 additions and 554 deletions

View file

@ -1,4 +1,4 @@
import { useEffect } from "react"
import { type ReactNode, useEffect } from "react"
import type { ViewMessage } from "../shared/types"
import { useApplyHostTheme } from "./hooks/useApplyHostTheme"
import { useLog } from "./hooks/useLog"
@ -25,15 +25,70 @@ export function App() {
}
}, [state, log])
if (state.kind === "loading") return <Loading />
if (state.kind === "error") return <ErrorView message={state.message} />
if (state.kind === "loading") {
return (
<WidgetShell>
<Loading />
</WidgetShell>
)
}
if (state.kind === "error") {
return (
<WidgetShell>
<ErrorView message={state.message} />
</WidgetShell>
)
}
if (state.kind === "raw") {
return (
<ErrorView message="Received unrecognized response from server. Try again." />
<WidgetShell>
<ErrorView message="Received unrecognized response from server. Try again." />
</WidgetShell>
)
}
return renderView(state.message, setView, setError)
const isGraphView = state.message.view === "graph"
return (
<WidgetShell immersive={isGraphView}>
{renderView(state.message, setView, setError)}
</WidgetShell>
)
}
export function WidgetShell({
children,
immersive = false,
}: {
children: ReactNode
immersive?: boolean
}) {
const shellClassName = immersive
? "mcp-widget-shell mcp-widget-shell-graph"
: "mcp-widget-shell"
return (
<div className={shellClassName}>
<header className="mcp-widget-brand">
<span aria-hidden className="mcp-widget-brand-mark">
<svg aria-hidden="true" viewBox="0 0 314 256">
<path
d="M313.728 100.982H197.297V0H159.68V109.567C159.68 121.205 164.284 132.381 172.466 140.615L267.535 236.283L294.134 209.517L223.917 138.858H313.75V101.004L313.728 100.982Z"
fill="currentColor"
/>
<path
d="M19.616 46.5043L89.8323 117.163H0V155.017H116.431V255.999H154.048V146.432C154.048 134.795 149.444 123.618 141.262 115.384L46.2144 19.7383L19.616 46.5043Z"
fill="currentColor"
/>
</svg>
</span>
<span className="mcp-widget-brand-copy">
<span className="mcp-widget-brand-name">supermemory</span>
<span className="mcp-widget-brand-mode">MCP</span>
</span>
</header>
<main className="mcp-widget-content">{children}</main>
</div>
)
}
function renderView(
@ -82,13 +137,12 @@ function renderView(
case "confirmation":
return <Confirmation containerTag={msg.containerTag} />
case "save-success":
return <Success containerTag={msg.containerTag} id={msg.id} kind="save" />
return <Success containerTag={msg.containerTag} kind="save" />
case "upload-success":
return (
<Success
containerTag={msg.containerTag}
fileName={msg.fileName}
id={msg.id}
kind="upload"
/>
)

View file

@ -1,5 +1,3 @@
import { Badge } from "../design/ui"
interface Props {
permission: string
}
@ -7,11 +5,8 @@ interface Props {
export function PermissionBadge({ permission }: Props) {
const isWrite = permission === "write"
return (
<Badge
className="mt-2 self-start uppercase"
variant={isWrite ? "accent" : "neutral"}
>
{isWrite ? "read/write" : "read only"}
</Badge>
<span className="inline-flex items-center rounded-full bg-bg-muted px-2 py-0.5 text-[10px] font-medium text-text-muted">
{isWrite ? "Read / write" : "Read only"}
</span>
)
}

View file

@ -1,3 +1,5 @@
import { Loader2 } from "../lib/icons"
export function Spinner() {
return <div className="spinner" />
return <Loader2 className="size-5 animate-spin text-accent" />
}

View file

@ -1,6 +1,6 @@
import type { ContainerTag, ContainerTagAccess } from "../../shared/types"
import { Card, Stack } from "../design/ui"
import { Check, Package } from "../lib/icons"
import { cn } from "../design/lib/cn"
import { formatTagLabel } from "../lib/formatTag"
import { PermissionBadge } from "./PermissionBadge"
interface Props {
@ -16,51 +16,39 @@ export function WorkspaceCard({
access,
onClick,
}: Props) {
const name = containerTag.name || containerTag.containerTag
const name = containerTag.name || formatTagLabel(containerTag.containerTag)
const docs = containerTag.documentCount
const mems = containerTag.memoryCount
const meta =
docs > 0 || mems > 0
? `${docs} doc${docs === 1 ? "" : "s"} · ${mems} ${mems === 1 ? "memory" : "memories"}`
: "No memories yet"
return (
<Card
as="button"
className="w-full"
<button
className="workspace-card"
data-active={active}
onClick={() => onClick(containerTag.containerTag)}
variant={active ? "active" : "interactive"}
type="button"
>
<Stack gap="sm">
<Stack align="center" direction="row" gap="sm" justify="between">
<span className="flex min-w-0 items-center gap-(--space-2)">
{containerTag.emoji ? (
<span aria-hidden className="text-base leading-none">
{containerTag.emoji}
</span>
) : (
<Package className="size-4 shrink-0 text-text-secondary" />
<span className="workspace-card-inner">
<span className="workspace-card-main">
<span
className={cn(
"workspace-card-title truncate text-sm font-medium",
active ? "text-accent" : "text-text-primary",
)}
<span
className="truncate text-(length:--text-sm) font-semibold text-text-primary"
style={{ fontFamily: "var(--font-brand)" }}
>
{name}
</span>
>
{name}
</span>
{active ? <Check className="size-4 shrink-0 text-accent" /> : null}
</Stack>
<div className="truncate font-mono text-(length:--text-xs) text-text-muted">
{containerTag.containerTag}
</div>
{(containerTag.documentCount > 0 || containerTag.memoryCount > 0) && (
<div className="flex items-center gap-(--space-3) text-(length:--text-xs) text-text-muted">
<span>
{containerTag.documentCount} doc
{containerTag.documentCount === 1 ? "" : "s"}
</span>
<span aria-hidden>·</span>
<span>
{containerTag.memoryCount}{" "}
{containerTag.memoryCount === 1 ? "memory" : "memories"}
</span>
</div>
)}
{access ? <PermissionBadge permission={access.permission} /> : null}
</Stack>
</Card>
<span className="workspace-card-meta text-[11px] leading-normal text-text-muted">
{meta}
</span>
</span>
<span className="workspace-card-trailing">
{access ? <PermissionBadge permission={access.permission} /> : null}
</span>
</span>
</button>
)
}

View file

@ -0,0 +1,12 @@
import { formatTagLabel } from "../lib/formatTag"
// A calm workspace pill: a small accent dot + the workspace name on a neutral
// surface. Reads like a "space" tag rather than a blue-on-blue system badge.
export function WorkspaceChip({ containerTag }: { containerTag: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-bg-muted px-2.5 py-1 text-(length:--text-xs) font-medium text-text-primary">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-accent" />
{formatTagLabel(containerTag)}
</span>
)
}

View file

@ -3,20 +3,22 @@
@custom-variant dark (&:is([data-theme="dark"] *));
/* Map design tokens to Tailwind utilities. Mirrors console-v2's mapping
* so utility class names like `bg-bg-primary`, `text-text-primary`,
* `border-border-accent`, `font-brand`, `text-xs`, `shadow-md` etc.
* resolve from one source of truth.
*/
/* Map design tokens to Tailwind utilities so class names like `bg-bg-primary`,
* `text-text-primary`, `border-border-accent`, `text-xs`, `shadow-md` resolve
* from one source of truth. */
@theme inline {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-muted: var(--bg-muted);
--color-bg-elevated: var(--bg-elevated);
--color-bg-overlay: var(--bg-overlay);
--color-bg-panel: var(--bg-panel);
--color-bg-control: var(--bg-control);
--color-bg-control-hover: var(--bg-control-hover);
--color-border: var(--border);
--color-border-muted: var(--border-muted);
--color-border-control: var(--border-control);
--color-border-accent: var(--border-accent);
--color-text-primary: var(--text-primary);
@ -60,6 +62,7 @@
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-full: var(--radius-full);
--shadow-sm: var(--shadow-sm);
@ -67,15 +70,13 @@
--shadow-lg: var(--shadow-lg);
}
/* Excalidraw pattern: NO heights on root containers. The MCP host owns
* the iframe height; ext-apps' App.autoResize observes documentElement
* and reports content size back so the host right-sizes the iframe.
* Setting any height (vh, %, etc.) here breaks that feedback loop.
/* Excalidraw pattern: NO heights on root containers. The MCP host owns the
* iframe height; ext-apps' App.autoResize observes documentElement and reports
* content size back so the host right-sizes the iframe. Any height here (vh, %,
* etc.) breaks that feedback loop.
*
* The reset lives INSIDE @layer base so Tailwind's @layer utilities (which
* come after base) win an unlayered universal padding:0 reset would override
* every padding utility in the widget. box-sizing/margin are belt-and-braces
* on top of Tailwind preflight. */
* The reset lives INSIDE @layer base so Tailwind's @layer utilities win an
* unlayered universal padding:0 would override every padding utility. */
@layer base {
* {
margin: 0;
@ -85,9 +86,10 @@
}
body {
background: var(--bg-primary);
background: var(--app-bg);
color: var(--text-primary);
font-family: var(--font-sans);
letter-spacing: -0.01em;
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@ -107,41 +109,249 @@
position: relative;
}
/* Spinner — kept as global because primitives can't easily express a CSS animation */
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
/* ── Shell ────────────────────────────────────────────────────────────────── */
.mcp-widget-shell {
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
min-height: 220px;
overflow: hidden;
background: var(--widget-shell-bg);
color: var(--text-primary);
}
@keyframes spin {
.mcp-widget-shell-graph {
min-height: 420px;
background: var(--widget-shell-graph-bg);
}
.mcp-widget-content {
position: relative;
z-index: 1;
}
.mcp-widget-shell-graph .mcp-widget-content {
flex: 1;
min-height: inherit;
}
/* ── Brand header — compact inline wordmark with a hairline divider ───────── */
.mcp-widget-brand {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) var(--page-header-px) 0;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-muted);
}
.mcp-widget-brand-mark {
display: grid;
width: 18px;
height: 18px;
place-items: center;
color: var(--accent);
}
.mcp-widget-brand-mark svg {
width: 15px;
height: 12px;
}
.mcp-widget-brand-copy {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.mcp-widget-brand-name {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
}
.mcp-widget-brand-mode {
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
}
/* ── Workspace picker — a framed, scrollable list of rows ─────────────────── */
.workspace-picker-grid {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid var(--card-border);
border-radius: var(--radius-lg);
background: var(--card-bg);
/* Hide scrollbar chrome — the framed list keeps a stable height. */
scrollbar-width: none;
}
.workspace-picker-grid::-webkit-scrollbar {
display: none;
}
.workspace-card {
position: relative;
display: block;
width: 100%;
padding: var(--space-3) var(--space-3);
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
transition: background-color 0.15s ease;
}
.workspace-card:not(:last-child) {
border-bottom: 1px solid var(--border-muted);
}
.workspace-card:hover {
background: var(--card-bg-hover);
}
.workspace-card:focus-visible {
outline: none;
background: var(--card-bg-hover);
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
/* Active workspace is marked by an accent-tinted row + accent-colored name
* (see WorkspaceCard) no rail, no check, so the trailing badge stays aligned. */
.workspace-card[data-active="true"] {
background: var(--card-active-bg);
}
.workspace-card-inner {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.workspace-card-main {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.workspace-card-title {
min-width: 0;
}
.workspace-card-trailing {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
/* ── Loading — draw-in wordmark, no glow ──────────────────────────────────── */
.mcp-widget-content:has(.mcp-widget-loading) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.mcp-widget-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.super-loader {
display: inline-flex;
min-width: calc(var(--super-loader-size, 42px) + 10px);
align-items: center;
gap: var(--space-2);
color: var(--accent);
}
.super-loader-mark {
width: calc(var(--super-loader-size, 42px) * 0.5);
height: calc(var(--super-loader-size, 42px) * 0.5);
flex-shrink: 0;
overflow: visible;
}
.super-loader-path {
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0.2;
animation: super-loader-draw 0.9s ease-in-out infinite alternate;
}
.super-loader-path-left {
animation-delay: 0.18s;
}
.super-loader-label {
color: var(--text-muted);
font-size: calc(var(--super-loader-size, 42px) * 0.25);
font-weight: 500;
white-space: nowrap;
}
@keyframes super-loader-draw {
from {
stroke-dashoffset: 1;
opacity: 0.2;
}
to {
transform: rotate(360deg);
stroke-dashoffset: 0;
opacity: 1;
}
}
/* The graph canvas needs a definite height in both modes.
@media (prefers-reduced-motion: reduce) {
.super-loader-path {
stroke-dashoffset: 0;
opacity: 0.7;
animation: none;
}
}
/* ── Graph canvas ─────────────────────────────────────────────────────────── */
/* The graph needs a definite height in both modes.
*
* Inline: derive height from the (stable) width via aspect-ratio width is
* fixed by the chat column, so this breaks the autoResize feedback loop.
* fixed by the chat column, so this doesn't feed the autoResize loop.
*
* Fullscreen: the host expands the iframe, so 100dvh = the fullscreen height.
* We stay IN FLOW (no position:fixed) taking the element out of flow
* collapses the document, autoResize shrinks the iframe, and a fixed inset:0
* box in a collapsed iframe ends up with zero height (and never recovers on
* minimize). Keeping it in flow with an explicit height avoids that entirely. */
* We stay IN FLOW (no position:fixed) taking the element out of flow collapses
* the document, autoResize shrinks the iframe, and a fixed inset:0 box in a
* collapsed iframe ends up with zero height. Keeping it in flow with an explicit
* height avoids that. */
.graph-view {
width: 100%;
aspect-ratio: 4 / 3;
/* Never collapse to 0 during a resize/transition a 0-size frame makes the
* package drop its "already fitted" flag and re-fit (a camera animation that
* reads as a re-layout). The floor keeps the size continuous. */
min-height: 280px;
aspect-ratio: 16 / 9;
/* Never collapse to 0 during a resize a 0-size frame makes the package
* re-fit (a camera animation that reads as a re-layout). */
min-height: 420px;
position: relative;
background: var(--bg-primary);
background: transparent;
}
.graph-view.fullscreen {

View file

@ -1,64 +1,80 @@
/* Design tokens for Supermemory MCP widget.
/* Design tokens for the Supermemory MCP widget.
*
* Mirrors console-v2's tokens 1:1 so brand identity is consistent across
* surfaces. Diverge only when the widget's iframed context demands it.
* The widget renders inside a host chat surface (Cursor, Claude, ChatGPT). It
* should read as a native extension of the Supermemory console, whose language
* is: flat cool-neutral surfaces, a signature recessed inset-shadow on inputs,
* one restrained blue accent, and DM Sans. No gradients, no glows, no grids.
*
* Dark mode: MCP host applies `data-theme="dark"` on <body> via
* `applyDocumentTheme`. Tailwind's `dark:` variant is wired in globals.css
* via `@custom-variant dark (&:is([data-theme="dark"] *))`.
* The host applies `data-theme="light" | "dark"` on the document via ext-apps'
* `applyDocumentTheme`; Tailwind's `dark:` variant is wired in globals.css via
* `@custom-variant dark (&:is([data-theme="dark"] *))`. When no host is present
* (standalone / Studio) we fall back to the OS preference.
*/
/* ── Light (default) ──────────────────────────────────────────────────────── */
:root,
[data-theme="light"] {
color-scheme: light;
/* Accent gradient (Supermemory brand) */
--accent-start: #1148f7;
--accent-mid: #117dff;
--accent-end: #cdf4ff;
--accent-gradient: linear-gradient(
135deg,
var(--accent-start),
var(--accent-mid),
var(--accent-end)
);
/* Accent solid */
--accent: #117dff;
/* Accent one restrained blue. Deeper than the dark accent so it stays
* legible as text/border on white. */
--accent: #2e6fe6;
--accent-foreground: #ffffff;
--accent-hover: #1148f7;
--accent-muted: #cdf4ff;
--accent-hover: #2560d0;
--accent-muted: rgba(46, 111, 230, 0.1);
--accent-ring: rgba(46, 111, 230, 0.35);
/* Backgrounds (warm subtle) */
--bg-primary: #faf9f4;
--bg-secondary: #f3f1ec;
--bg-muted: #e8e5e0;
/* Surfaces cool light neutrals. The shell is the widget's own canvas;
* cards/panels sit a step brighter (white) so borders + shadow read. */
--app-bg: #eef1f6;
--widget-shell-bg: #f5f7fa;
--widget-shell-graph-bg: #f5f7fa;
--bg-primary: #f5f7fa;
--bg-secondary: #eef1f6;
--bg-muted: #e7ebf1;
--bg-elevated: #ffffff;
--bg-overlay: rgba(250, 249, 244, 0.7);
--bg-overlay: rgba(245, 247, 250, 0.8);
--bg-panel: #ffffff;
--bg-control: #ffffff;
--bg-control-hover: #f4f6fa;
/* Borders */
--border: #e3e0db;
--border-muted: #f0ede8;
--border-accent: var(--accent);
--border: #e2e7ef;
--border-muted: #edf0f5;
--border-control: #dce2ea;
--border-accent: rgba(46, 111, 230, 0.45);
/* Text */
--text-primary: #0a0a0a;
--text-secondary: #525252;
--text-muted: #a3a3a3;
--card-bg: #ffffff;
--card-bg-hover: #f6f8fb;
--card-border: #e2e7ef;
--card-border-hover: #d2dae5;
--card-active-bg: rgba(46, 111, 230, 0.06);
--card-active-border: rgba(46, 111, 230, 0.4);
--panel-bg: #ffffff;
--panel-border: #e2e7ef;
--panel-shadow:
0 1px 2px rgba(15, 23, 42, 0.04), 0 10px 26px rgba(15, 23, 42, 0.05);
/* Signature recessed shadow subtle in light so fields read as inset
* without going muddy. */
--shadow-inset: inset 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-inset-strong: inset 1px 1px 3px rgba(15, 23, 42, 0.08);
--text-primary: #0f1729;
--text-secondary: #55617a;
--text-muted: #8b95a7;
--text-inverse: #ffffff;
/* Status colors (soft pastel) */
--success: #4ade80;
--success-muted: rgba(74, 222, 128, 0.18);
--error: #f87171;
--error-muted: rgba(248, 113, 113, 0.18);
--warning: #fbbf24;
--warning-muted: rgba(251, 191, 36, 0.18);
--info: #60a5fa;
--info-muted: rgba(96, 165, 250, 0.18);
/* Action colors */
--danger: #ef4444;
--success: #15935a;
--success-muted: rgba(21, 147, 90, 0.12);
--error: #d64545;
--error-muted: rgba(214, 69, 69, 0.12);
--warning: #b7791f;
--warning-muted: rgba(183, 121, 31, 0.12);
--info: #2e6fe6;
--info-muted: rgba(46, 111, 230, 0.12);
--danger: #d64545;
/* Spacing scale (4px base) */
--space-1: 4px;
@ -72,11 +88,11 @@
--space-12: 48px;
--space-16: 64px;
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-display:
"Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif;
--font-brand: "Space Grotesk", "Inter", ui-sans-serif, system-ui, sans-serif;
/* Typography — DM Sans throughout, mirroring the console. */
--font-sans:
"DM Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font-sans);
--font-brand: var(--font-sans);
--font-mono:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
@ -94,13 +110,13 @@
--font-bold: 700;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
--leading-normal: 1.4;
--leading-relaxed: 1.55;
/* Unified component heights */
--height-xs: 28px;
--height-sm: 32px;
--height-md: 36px;
--height-sm: 34px;
--height-md: 38px;
--height-lg: 44px;
/* Icon sizes */
@ -109,169 +125,242 @@
--icon-md: 20px;
--icon-lg: 24px;
/* Radius */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
/* Radius — a touch rounder than the console for a consumer feel. */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* Border width */
--border-width: 1px;
/* Page layout (mirrors console-v2 widget iframes are narrow so we
* use the mobile values throughout: 16px horizontal, 16-24px vertical). */
/* Page layout — widget iframes are narrow, so use compact values. */
--page-header-px: var(--space-4);
--page-header-py: var(--space-4);
--page-title-size: var(--text-lg);
--page-title-weight: var(--font-semibold);
--page-title-mt: var(--space-5);
--page-title-mt: var(--space-4);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
/* Panel shadow family (also exposed to Tailwind as shadow-sm/md/lg). */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-md: 0 6px 18px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12);
/* Host-provided iframe dimensions; fallback for standalone dev. */
--host-height: 100vh;
--host-width: 100vw;
/* Memory graph canvas read by @supermemory/memory-graph's useGraphTheme.
* Mirrors console-v2 so the widget graph matches the console exactly. */
--graph-bg: var(--bg-secondary);
--graph-doc-fill: #e8e5e0;
--graph-doc-stroke: #d4d0cb;
--graph-doc-inner: #f3f1ec;
--graph-mem-fill: #dbeafe;
--graph-mem-fill-hover: #bfdbfe;
--graph-mem-stroke: #3b82f6;
--graph-accent: var(--accent);
--graph-text-primary: var(--text-primary);
--graph-text-secondary: var(--text-secondary);
--graph-text-muted: var(--text-muted);
/* Edge / legend colors bright + "hot" so derives/updates/extends read
* clearly against the light canvas (dark mode keeps its own hot set below). */
--graph-edge-derives: #f59e0b;
--graph-edge-updates: #8b5cf6;
--graph-edge-extends: #0ea5e9;
--graph-mem-border-forgotten: #dc2626;
--graph-mem-border-expiring: #d97706;
--graph-mem-border-recent: #059669;
--graph-glow: #3b82f6;
--graph-icon: #6b7280;
--graph-popover-bg: var(--bg-elevated);
--graph-popover-border: var(--border);
--graph-popover-text-primary: var(--text-primary);
--graph-popover-text-secondary: var(--text-secondary);
--graph-popover-text-muted: var(--text-muted);
--graph-control-bg: var(--bg-elevated);
--graph-control-border: var(--border);
/* Memory graph canvas read by @supermemory/memory-graph's theme.
* Neutral fills, one accent, semantic (not decorative) relation edges. */
--graph-bg: transparent;
--graph-doc-fill: #f2f5f9;
--graph-doc-stroke: #dce2ea;
--graph-doc-inner: #ffffff;
--graph-mem-fill: #eaf1fc;
--graph-mem-fill-hover: #dde9fb;
--graph-mem-stroke: #2e6fe6;
--graph-accent: #2e6fe6;
--graph-text-primary: #0f1729;
--graph-text-secondary: #55617a;
--graph-text-muted: #8b95a7;
--graph-edge-derives: #b0b7c3;
--graph-edge-updates: #2e6fe6;
--graph-edge-extends: #0e9488;
--graph-mem-border-forgotten: #d64545;
--graph-mem-border-expiring: #b7791f;
--graph-mem-border-recent: #15935a;
--graph-glow: rgba(46, 111, 230, 0.35);
--graph-icon: #8b95a7;
--graph-popover-bg: #ffffff;
--graph-popover-border: #e2e7ef;
--graph-popover-text-primary: #0f1729;
--graph-popover-text-secondary: #55617a;
--graph-popover-text-muted: #8b95a7;
--graph-control-bg: #ffffff;
--graph-control-border: #e2e7ef;
}
/* ── Dark ─────────────────────────────────────────────────────────────────── */
[data-theme="dark"] {
color-scheme: dark;
--bg-primary: #0a0a0a;
--bg-secondary: #171717;
--bg-muted: #262626;
--bg-elevated: #1c1c1c;
--bg-overlay: rgba(10, 10, 10, 0.7);
--accent: #4ba0fa;
--accent-foreground: #0a0f16;
--accent-hover: #66b0ff;
--accent-muted: rgba(75, 160, 250, 0.14);
--accent-ring: rgba(75, 160, 250, 0.4);
--border: #2e2e2e;
--border-muted: #1f1f1f;
--app-bg: #080c11;
--widget-shell-bg: #0b1119;
--widget-shell-graph-bg: #0b1119;
--text-primary: #fafafa;
--text-secondary: #a3a3a3;
--text-muted: #8a8a8a;
--text-inverse: #0a0a0a;
--bg-primary: #0b1119;
--bg-secondary: #0e1620;
--bg-muted: #17212f;
--bg-elevated: #101822;
--bg-overlay: rgba(11, 17, 25, 0.8);
--bg-panel: #101822;
--bg-control: #0d141d;
--bg-control-hover: #131d2a;
--success-muted: rgba(74, 222, 128, 0.2);
--error-muted: rgba(248, 113, 113, 0.2);
--warning-muted: rgba(251, 191, 36, 0.2);
--info-muted: rgba(96, 165, 250, 0.2);
--border: #1f2b3b;
--border-muted: #172230;
--border-control: #263141;
--border-accent: rgba(75, 160, 250, 0.4);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--card-bg: #101822;
--card-bg-hover: #131d2a;
--card-border: #1f2b3b;
--card-border-hover: #2b3a4e;
--card-active-bg: rgba(75, 160, 250, 0.1);
--card-active-border: rgba(75, 160, 250, 0.45);
/* Memory graph canvas — dark (mirrors console-v2) */
--graph-bg: #0f1419;
--graph-doc-fill: #1b1f24;
--graph-doc-stroke: #2a2f36;
--graph-doc-inner: #13161a;
--graph-mem-fill: #0d2034;
--graph-mem-fill-hover: #112840;
--graph-mem-stroke: #3b73b8;
--graph-edge-derives: #fbbf24;
--graph-edge-updates: #a78bfa;
--graph-edge-extends: #38bdf8;
--graph-mem-border-forgotten: #ef4444;
--graph-mem-border-expiring: #f59e0b;
--graph-mem-border-recent: #10b981;
--graph-glow: #3b73b8;
--graph-icon: #3b73b8;
--graph-popover-bg: #0c1829;
--graph-popover-border: #1a2333;
--graph-popover-text-primary: #ffffff;
--graph-popover-text-secondary: #e2e8f0;
--graph-popover-text-muted: #94a3b8;
--graph-control-bg: #0c1829;
--graph-control-border: #1a2333;
--panel-bg: #101822;
--panel-border: #1f2b3b;
--panel-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.4),
0 14px 34px rgba(0, 0, 0, 0.35);
/* Signature inset — the console's recessed emboss. */
--shadow-inset: inset 2px 2px 5px rgba(4, 8, 13, 0.55);
--shadow-inset-strong: inset 2.4px 2.4px 4.5px rgba(6, 10, 16, 0.7);
--text-primary: #eef2f7;
--text-secondary: #a9b4c2;
--text-muted: #6f7d8f;
--text-inverse: #0b1119;
--success: #34d399;
--success-muted: rgba(52, 211, 153, 0.14);
--error: #f87171;
--error-muted: rgba(248, 113, 113, 0.14);
--warning: #fbbf24;
--warning-muted: rgba(251, 191, 36, 0.14);
--info: #4ba0fa;
--info-muted: rgba(75, 160, 250, 0.14);
--danger: #f87171;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 6px 18px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.5);
--graph-bg: transparent;
--graph-doc-fill: #131d2a;
--graph-doc-stroke: #2b3a4e;
--graph-doc-inner: #0d141d;
--graph-mem-fill: #122437;
--graph-mem-fill-hover: #172d44;
--graph-mem-stroke: #4ba0fa;
--graph-accent: #4ba0fa;
--graph-text-primary: #eef2f7;
--graph-text-secondary: #a9b4c2;
--graph-text-muted: #6f7d8f;
--graph-edge-derives: #5b6675;
--graph-edge-updates: #4ba0fa;
--graph-edge-extends: #2dd4bf;
--graph-mem-border-forgotten: #f87171;
--graph-mem-border-expiring: #fbbf24;
--graph-mem-border-recent: #34d399;
--graph-glow: rgba(75, 160, 250, 0.45);
--graph-icon: #6f7d8f;
--graph-popover-bg: #101822;
--graph-popover-border: #1f2b3b;
--graph-popover-text-primary: #eef2f7;
--graph-popover-text-secondary: #a9b4c2;
--graph-popover-text-muted: #6f7d8f;
--graph-control-bg: #101822;
--graph-control-border: #1f2b3b;
}
/* ── Standalone / no host: follow the OS preference ───────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
:root:not([data-theme="light"]):not([data-theme="dark"]) {
color-scheme: dark;
--bg-primary: #0a0a0a;
--bg-secondary: #171717;
--bg-muted: #262626;
--bg-elevated: #1c1c1c;
--bg-overlay: rgba(10, 10, 10, 0.7);
--accent: #4ba0fa;
--accent-foreground: #0a0f16;
--accent-hover: #66b0ff;
--accent-muted: rgba(75, 160, 250, 0.14);
--accent-ring: rgba(75, 160, 250, 0.4);
--border: #2e2e2e;
--border-muted: #1f1f1f;
--app-bg: #080c11;
--widget-shell-bg: #0b1119;
--widget-shell-graph-bg: #0b1119;
--text-primary: #fafafa;
--text-secondary: #a3a3a3;
--text-muted: #8a8a8a;
--text-inverse: #0a0a0a;
--bg-primary: #0b1119;
--bg-secondary: #0e1620;
--bg-muted: #17212f;
--bg-elevated: #101822;
--bg-overlay: rgba(11, 17, 25, 0.8);
--bg-panel: #101822;
--bg-control: #0d141d;
--bg-control-hover: #131d2a;
--success-muted: rgba(74, 222, 128, 0.2);
--error-muted: rgba(248, 113, 113, 0.2);
--warning-muted: rgba(251, 191, 36, 0.2);
--info-muted: rgba(96, 165, 250, 0.2);
--border: #1f2b3b;
--border-muted: #172230;
--border-control: #263141;
--border-accent: rgba(75, 160, 250, 0.4);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--card-bg: #101822;
--card-bg-hover: #131d2a;
--card-border: #1f2b3b;
--card-border-hover: #2b3a4e;
--card-active-bg: rgba(75, 160, 250, 0.1);
--card-active-border: rgba(75, 160, 250, 0.45);
--graph-bg: #0f1419;
--graph-doc-fill: #1b1f24;
--graph-doc-stroke: #2a2f36;
--graph-doc-inner: #13161a;
--graph-mem-fill: #0d2034;
--graph-mem-fill-hover: #112840;
--graph-mem-stroke: #3b73b8;
--graph-edge-derives: #fbbf24;
--graph-edge-updates: #a78bfa;
--graph-edge-extends: #38bdf8;
--graph-mem-border-forgotten: #ef4444;
--graph-mem-border-expiring: #f59e0b;
--graph-mem-border-recent: #10b981;
--graph-glow: #3b73b8;
--graph-icon: #3b73b8;
--graph-popover-bg: #0c1829;
--graph-popover-border: #1a2333;
--graph-popover-text-primary: #ffffff;
--graph-popover-text-secondary: #e2e8f0;
--graph-popover-text-muted: #94a3b8;
--graph-control-bg: #0c1829;
--graph-control-border: #1a2333;
--panel-bg: #101822;
--panel-border: #1f2b3b;
--panel-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 1px 2px rgba(0, 0, 0, 0.4),
0 14px 34px rgba(0, 0, 0, 0.35);
--shadow-inset: inset 2px 2px 5px rgba(4, 8, 13, 0.55);
--shadow-inset-strong: inset 2.4px 2.4px 4.5px rgba(6, 10, 16, 0.7);
--text-primary: #eef2f7;
--text-secondary: #a9b4c2;
--text-muted: #6f7d8f;
--text-inverse: #0b1119;
--success: #34d399;
--success-muted: rgba(52, 211, 153, 0.14);
--error: #f87171;
--error-muted: rgba(248, 113, 113, 0.14);
--warning: #fbbf24;
--warning-muted: rgba(251, 191, 36, 0.14);
--info: #4ba0fa;
--info-muted: rgba(75, 160, 250, 0.14);
--danger: #f87171;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 6px 18px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.5);
--graph-bg: transparent;
--graph-doc-fill: #131d2a;
--graph-doc-stroke: #2b3a4e;
--graph-doc-inner: #0d141d;
--graph-mem-fill: #122437;
--graph-mem-fill-hover: #172d44;
--graph-mem-stroke: #4ba0fa;
--graph-accent: #4ba0fa;
--graph-text-primary: #eef2f7;
--graph-text-secondary: #a9b4c2;
--graph-text-muted: #6f7d8f;
--graph-edge-derives: #5b6675;
--graph-edge-updates: #4ba0fa;
--graph-edge-extends: #2dd4bf;
--graph-mem-border-forgotten: #f87171;
--graph-mem-border-expiring: #fbbf24;
--graph-mem-border-recent: #34d399;
--graph-glow: rgba(75, 160, 250, 0.45);
--graph-icon: #6f7d8f;
--graph-popover-bg: #101822;
--graph-popover-border: #1f2b3b;
--graph-popover-text-primary: #eef2f7;
--graph-popover-text-secondary: #a9b4c2;
--graph-popover-text-muted: #6f7d8f;
--graph-control-bg: #101822;
--graph-control-border: #1f2b3b;
}
}

View file

@ -3,43 +3,47 @@ import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from "react"
import { Loader2 } from "../../lib/icons"
import { cn } from "../lib/cn"
// Mirrors console-v2's button: uppercase + tracked, brand-font by default,
// CVA primary/secondary/ghost/danger × sm/icon. We skip `asChild` because the
// widget has no router links and dropping it saves a @radix-ui/react-slot dep.
// Token-driven, theme-aware pill controls. Primary is a high-contrast slab that
// inverts per theme (dark-on-light / light-on-dark); secondary is a neutral,
// input-like surface so it never reads as a heavy block on light backgrounds.
const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-2",
"uppercase tracking-[0.075em]",
"rounded-[var(--radius-md)]",
"transition-colors cursor-pointer",
"font-semibold whitespace-nowrap",
"rounded-full",
"transition-colors duration-150 cursor-pointer",
"disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--bg-primary)]",
"[&_svg:not([class*='size-'])]:size-4 shrink-0",
].join(" "),
{
variants: {
variant: {
// Nova's "insideOut" primary button: a near-black pill with the
// shadow-inside-out recessed inset. Same in both themes, exactly as
// the console renders it.
primary: [
"bg-[var(--accent)] text-[var(--accent-foreground)]",
"hover:bg-[var(--accent)]/90",
"bg-[#0D121A] text-[#FAFAFA]",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
"hover:bg-[#121820] active:bg-[#0A0E14]",
].join(" "),
secondary: [
"bg-transparent text-[var(--text-primary)]",
"border border-[var(--border)]",
"hover:bg-[var(--bg-muted)]",
"bg-[var(--bg-control)] text-[var(--text-primary)]",
"border border-[var(--border-control)]",
"hover:bg-[var(--bg-control-hover)] hover:border-[var(--card-border-hover)]",
].join(" "),
ghost: [
"text-[var(--text-primary)]",
"hover:bg-[var(--bg-muted)]",
"text-[var(--text-secondary)]",
"hover:bg-[var(--bg-muted)] hover:text-[var(--text-primary)]",
].join(" "),
danger: [
"bg-[var(--danger)] text-[var(--text-inverse)]",
"hover:bg-[var(--danger)]/90",
"bg-[var(--error-muted)] text-[var(--error)]",
"hover:bg-[var(--error-muted)] hover:brightness-95",
].join(" "),
},
size: {
sm: "h-[var(--height-sm)] px-[var(--space-4)] text-[length:var(--text-xs)]",
icon: "size-[var(--height-sm)] p-0",
sm: "h-9 px-4 text-[13px]",
icon: "size-8 p-0",
},
},
defaultVariants: {
@ -93,7 +97,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{shortcut && !loading ? (
<span
className={cn(
"text-[0.8em] tracking-[0.1em]",
"text-[0.8em]",
variant === "primary" || variant === "danger"
? "opacity-60"
: variant === "ghost"

View file

@ -7,14 +7,19 @@ import {
import { cn } from "../lib/cn"
const cardStyles = cva(
"text-left bg-bg-elevated border border-border rounded-lg p-4 transition-colors duration-150",
[
"group relative text-left",
"rounded-xl border border-[var(--card-border)] bg-[var(--card-bg)] p-4",
"transition-colors duration-150",
].join(" "),
{
variants: {
variant: {
default: "",
interactive:
"cursor-pointer hover:bg-bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
active: "cursor-pointer border-accent bg-accent-muted",
"cursor-pointer hover:border-[var(--card-border-hover)] hover:bg-[var(--card-bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
active:
"cursor-pointer border-[var(--card-active-border)] bg-[var(--card-active-bg)] hover:border-[var(--card-active-border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary",
},
},
defaultVariants: { variant: "default" },
@ -32,7 +37,7 @@ type ButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> &
export type CardProps = DivProps | ButtonElementProps
export const Card = forwardRef<HTMLElement, CardProps>(
({ className, variant, as = "div", ...props }, ref) => {
({ className, variant, as = "div", children, ...props }, ref) => {
const cls = cn(cardStyles({ variant }), className)
if (as === "button") {
return (
@ -41,7 +46,9 @@ export const Card = forwardRef<HTMLElement, CardProps>(
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
/>
>
{children}
</button>
)
}
return (
@ -49,7 +56,9 @@ export const Card = forwardRef<HTMLElement, CardProps>(
className={cls}
ref={ref as React.Ref<HTMLDivElement>}
{...(props as HTMLAttributes<HTMLDivElement>)}
/>
>
{children}
</div>
)
},
)

View file

@ -9,7 +9,7 @@ const chipStyles = cva(
selected: {
true: "bg-accent-muted border-accent text-accent",
false:
"bg-bg-elevated border-border text-text-secondary hover:border-border-accent hover:bg-bg-muted",
"bg-bg-elevated border-[var(--card-border)] text-text-secondary hover:border-[var(--card-border-hover)] hover:bg-bg-control-hover",
},
},
defaultVariants: { selected: false },

View file

@ -14,7 +14,7 @@ export function Field({ label, hint, error, className, children }: Props) {
<div className={cn("flex flex-col gap-1.5", className)}>
{label ? (
// biome-ignore lint/a11y/noLabelWithoutControl: generic field wrapper where children provide the input
<label className="text-xs font-medium text-text-secondary uppercase tracking-wide">
<label className="text-[11px] font-semibold uppercase tracking-[0.06em] text-text-muted">
{label}
</label>
) : null}

View file

@ -57,11 +57,11 @@ export function FileUpload({
className={cn(
"flex min-h-[180px] flex-col items-center justify-center gap-(--space-3)",
"py-(--space-12) px-(--space-6)",
"border-2 border-dashed rounded-(--radius-lg)",
"cursor-pointer transition-colors",
"border border-dashed rounded-(--radius-lg)",
"bg-[var(--bg-control)] cursor-pointer shadow-[var(--shadow-inset)] transition-colors",
dragOver
? "border-accent bg-accent-muted/30"
: "border-border hover:bg-bg-muted/40",
? "border-accent bg-[var(--accent-muted)]"
: "border-[var(--border-control)] hover:border-[var(--card-border-hover)] hover:bg-[var(--bg-control-hover)]",
disabled && "pointer-events-none opacity-50",
className,
)}

View file

@ -2,20 +2,21 @@ import { cva, type VariantProps } from "class-variance-authority"
import { forwardRef, type InputHTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Mirrors console-v2's Input: transparent surface, soft hover tint,
// focus shadow, aria-invalid styling. Sizes sm/md/lg match component heights.
// A recessed field: solid control surface, hairline border, and the console's
// signature inset shadow. Focus is a clean accent ring — no glow.
const inputVariants = cva(
[
"flex w-full",
"bg-transparent text-[var(--text-primary)]",
"border border-[var(--border)]",
"bg-[var(--bg-control)] text-[var(--text-primary)]",
"border border-[var(--border-control)]",
"rounded-[var(--radius-lg)]",
"shadow-[var(--shadow-inset)]",
"placeholder:text-[var(--text-muted)]",
"transition-colors",
"hover:bg-[var(--text-muted)]/10",
"focus-visible:outline-none focus-visible:shadow-[0_0_0_1px_var(--border)]",
"hover:border-[var(--card-border-hover)]",
"focus-visible:outline-none focus-visible:border-[var(--border-accent)] focus-visible:shadow-[var(--shadow-inset),0_0_0_2px_var(--accent-ring)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--error)] aria-invalid:ring-[var(--error)]",
"aria-invalid:border-[var(--error)]",
"file:border-0 file:bg-transparent file:text-[length:var(--text-sm)] file:font-medium",
].join(" "),
{

View file

@ -17,8 +17,8 @@ const PopoverContent = forwardRef<
className={cn(
"z-50 w-72 p-(--space-4)",
"bg-bg-elevated border border-border",
"rounded-(--radius-md)",
"shadow-md",
"rounded-(--radius-lg)",
"shadow-lg backdrop-blur-xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,

View file

@ -1,22 +1,22 @@
import { forwardRef, type TextareaHTMLAttributes } from "react"
import { cn } from "../lib/cn"
// Multi-line counterpart to Input. Same surface treatment (transparent,
// soft hover, focus shadow, aria-invalid). No height variants — caller
// controls min-height via className.
// Multi-line counterpart to Input — same recessed field, inset shadow, and clean
// accent focus ring.
const textAreaClass = [
"flex w-full",
"bg-transparent text-[var(--text-primary)]",
"border border-[var(--border)]",
"bg-[var(--bg-control)] text-[var(--text-primary)]",
"border border-[var(--border-control)]",
"rounded-[var(--radius-lg)]",
"px-[var(--space-3)] py-[var(--space-2)]",
"shadow-[var(--shadow-inset)]",
"px-[var(--space-3)] py-[var(--space-3)]",
"text-[length:var(--text-sm)] leading-normal font-sans",
"placeholder:text-[var(--text-muted)]",
"transition-colors resize-y",
"hover:bg-[var(--text-muted)]/10",
"focus-visible:outline-none focus-visible:shadow-[0_0_0_1px_var(--border)]",
"hover:border-[var(--card-border-hover)]",
"focus-visible:outline-none focus-visible:border-[var(--border-accent)] focus-visible:shadow-[var(--shadow-inset),0_0_0_2px_var(--accent-ring)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--error)] aria-invalid:ring-[var(--error)]",
"aria-invalid:border-[var(--error)]",
].join(" ")
export interface TextAreaProps

View file

@ -1,5 +1,5 @@
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { Check, ChevronDown, Package, Search } from "../../lib/icons"
import { ChevronDown, Search } from "../../lib/icons"
import { cn } from "../lib/cn"
import { Button } from "./Button"
import { Popover, PopoverContent, PopoverTrigger } from "./Popover"
@ -63,13 +63,12 @@ export function WorkspaceSelect({
<Button
brandFont={false}
className={cn(
"w-full justify-between normal-case tracking-normal",
"focus-visible:ring-0 focus-visible:ring-offset-0",
"data-[state=open]:bg-bg-muted",
"workspace-select-trigger w-full justify-between normal-case tracking-normal font-medium",
"shadow-[var(--shadow-inset)]",
"data-[state=open]:border-[var(--border-accent)]",
className,
)}
disabled={disabled}
iconLeft={<Package className="size-4 text-text-secondary" />}
iconRight={<ChevronDown className="size-3 text-text-muted" />}
size="sm"
type="button"
@ -111,12 +110,13 @@ export function WorkspaceSelect({
return (
<button
className={cn(
"flex items-start justify-between gap-(--space-2)",
"flex items-start gap-(--space-2)",
"px-(--space-3) py-(--space-2)",
"rounded-(--radius-md)",
"text-left cursor-pointer",
"hover:bg-bg-muted transition-colors",
"text-left cursor-pointer transition-colors",
"hover:bg-bg-muted",
"focus-visible:outline-none focus-visible:bg-bg-muted",
isSelected && "bg-accent-muted",
)}
key={option.value}
onClick={() => {
@ -127,10 +127,15 @@ export function WorkspaceSelect({
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-(--space-2)">
{option.icon ?? (
<Package className="size-3.5 text-text-muted shrink-0" />
)}
<span className="text-(length:--text-sm) text-text-primary truncate">
{option.icon ?? null}
<span
className={cn(
"text-(length:--text-sm) truncate",
isSelected
? "font-medium text-accent"
: "text-text-primary",
)}
>
{option.label}
</span>
</span>
@ -140,9 +145,6 @@ export function WorkspaceSelect({
</span>
) : null}
</span>
{isSelected ? (
<Check className="size-3.5 text-accent shrink-0 mt-0.5" />
) : null}
</button>
)
})

View file

@ -6,7 +6,7 @@
<title>Supermemory MCP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>

View file

@ -0,0 +1,15 @@
/**
* Turns a raw container-tag identifier into a human-friendly label.
*
* "sm_project_marketing" "Marketing"
* "sm_project_eng_rfcs" "Eng Rfcs"
* "my_custom_workspace" "My Custom Workspace"
*/
export function formatTagLabel(raw: string): string {
const slug = raw.replace(/^sm_project_/i, "").replace(/^sm_/i, "")
return slug
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}

View file

@ -6,7 +6,7 @@
<title>Supermemory MCP · Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="studio"></div>

View file

@ -1,4 +1,5 @@
import { type ReactNode, useState } from "react"
import { type ReactNode, useEffect, useState } from "react"
import { WidgetShell } from "../App"
import { WorkspaceCard } from "../components/WorkspaceCard"
import {
ActionGroup,
@ -30,10 +31,11 @@ import {
} from "./mocks"
type Theme = "light" | "dark"
type Width = "narrow" | "wide"
type Width = "narrow" | "wide" | "cursor"
const NARROW = 420
const WIDE = 720
const CURSOR = 1120
// A no-op handler set for views. In the Studio there is no MCP host, so
// widget-initiated tool calls would reject; that's fine — we only review
@ -118,15 +120,22 @@ function Swatch({ name, varName }: { name: string; varName: string }) {
}
export function Studio() {
const [theme, setTheme] = useState<Theme>("light")
const [theme, setTheme] = useState<Theme>("dark")
const [width, setWidth] = useState<Width>("narrow")
const frameWidth = width === "narrow" ? NARROW : WIDE
const frameWidth =
width === "narrow" ? NARROW : width === "wide" ? WIDE : CURSOR
const applyTheme = (t: Theme) => {
setTheme(t)
document.documentElement.setAttribute("data-theme", t)
}
// Keep the document theme in sync so the gallery matches the toggle,
// including on first paint.
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme)
}, [theme])
const saveStub = useStubHandlers("Save")
const uploadStub = useStubHandlers("Upload")
const pickerStub = useStubHandlers("Picker")
@ -175,11 +184,17 @@ export function Studio() {
<Chip onClick={() => setWidth("wide")} selected={width === "wide"}>
Wide {WIDE}
</Chip>
<Chip
onClick={() => setWidth("cursor")}
selected={width === "cursor"}
>
Cursor {CURSOR}
</Chip>
</div>
</div>
</div>
<div className="mx-auto flex max-w-5xl flex-col gap-(--space-12) px-(--space-6) py-(--space-8)">
<div className="mx-auto flex max-w-5xl flex-col-reverse gap-(--space-12) px-(--space-6) py-(--space-8)">
{/* ── Primitives ───────────────────────────────────────── */}
<Section
description="Atoms from design/ui — fully interactive, no host needed."
@ -226,7 +241,7 @@ export function Studio() {
<div className="flex flex-wrap items-center gap-(--space-2)">
<Chip selected>Selected</Chip>
<Chip>Unselected</Chip>
<Chip>sm_project_marketing</Chip>
<Chip>Marketing</Chip>
</div>
</div>
@ -334,73 +349,86 @@ export function Studio() {
{/* ── Views ────────────────────────────────────────────── */}
<Section
description="Full views with mock data, rendered at the selected width. Click events show below each frame (no host, so tool calls won't fire)."
title="Views"
description="The same widget shell Cursor renders, with mock MCP tool data."
title="Cursor widgets"
>
<div className="flex flex-wrap gap-(--space-8)">
<Frame label="Picker" width={frameWidth}>
<Picker
activeTag="sm_project_marketing"
assignedTags={mockAssignedTags}
containerTags={mockContainerTags}
onAdvance={pickerStub.onAdvance}
onError={pickerStub.onError}
/>
<WidgetShell>
<Picker
activeTag="sm_project_marketing"
assignedTags={mockAssignedTags}
containerTags={mockContainerTags}
onAdvance={pickerStub.onAdvance}
onError={pickerStub.onError}
/>
</WidgetShell>
</Frame>
<Frame label="Save" width={frameWidth}>
<Save
activeTag="sm_project_marketing"
onAdvance={saveStub.onAdvance}
onError={saveStub.onError}
prefill="The Q3 launch will target fintech enterprise buyers."
writableTags={mockWritableTags}
/>
<WidgetShell>
<Save
activeTag="sm_project_marketing"
onAdvance={saveStub.onAdvance}
onError={saveStub.onError}
prefill="The Q3 launch will target fintech enterprise buyers."
writableTags={mockWritableTags}
/>
</WidgetShell>
</Frame>
<Frame label="Upload" width={frameWidth}>
<Upload
activeTag="sm_project_marketing"
onAdvance={uploadStub.onAdvance}
onError={uploadStub.onError}
writableTags={mockWritableTags}
/>
<WidgetShell>
<Upload
activeTag="sm_project_marketing"
onAdvance={uploadStub.onAdvance}
onError={uploadStub.onError}
writableTags={mockWritableTags}
/>
</WidgetShell>
</Frame>
<Frame label="Confirmation" width={frameWidth}>
<Confirmation containerTag="sm_project_marketing" />
<WidgetShell>
<Confirmation containerTag="sm_project_marketing" />
</WidgetShell>
</Frame>
<Frame label="Success (save)" width={frameWidth}>
<Success
containerTag="sm_project_marketing"
id="mem_8fa92c"
kind="save"
/>
<WidgetShell>
<Success containerTag="sm_project_marketing" kind="save" />
</WidgetShell>
</Frame>
<Frame label="Success (upload)" width={frameWidth}>
<Success
containerTag="sm_project_marketing"
fileName="q3-brief.pdf"
id="doc_4b71a0"
kind="upload"
/>
<WidgetShell>
<Success
containerTag="sm_project_marketing"
fileName="q3-brief.pdf"
kind="upload"
/>
</WidgetShell>
</Frame>
<Frame label="Error" width={frameWidth}>
<ErrorView message="No write access to container tag 'sm_project_eng_rfcs'." />
<WidgetShell>
<ErrorView message="No write access to container tag 'sm_project_eng_rfcs'." />
</WidgetShell>
</Frame>
<Frame label="Loading" width={frameWidth}>
<Loading />
<WidgetShell>
<Loading />
</WidgetShell>
</Frame>
<Frame label="Graph (@supermemory/memory-graph)" width={frameWidth}>
<Graph
documents={mockDocuments}
totalCount={mockDocuments.length}
/>
<WidgetShell immersive>
<Graph
documents={mockDocuments}
totalCount={mockDocuments.length}
/>
</WidgetShell>
</Frame>
</div>

View file

@ -1,5 +1,6 @@
import { Badge, Stack } from "../design/ui"
import { CheckCircle } from "../lib/icons"
import { WorkspaceChip } from "../components/WorkspaceChip"
import { Stack } from "../design/ui"
import { Check } from "../lib/icons"
interface Props {
containerTag: string
@ -9,19 +10,20 @@ export function Confirmation({ containerTag }: Props) {
return (
<Stack
align="center"
className="px-(--page-header-px) py-(--space-10) text-center"
className="mx-(--page-header-px) my-(--space-6) rounded-xl border border-[var(--panel-border)] bg-[var(--panel-bg)] px-(--page-header-px) py-(--space-10) text-center shadow-[var(--panel-shadow)]"
gap="md"
>
<CheckCircle className="size-12 text-success" />
<span className="flex size-11 items-center justify-center rounded-full bg-[var(--success-muted)] text-success">
<Check className="size-6" />
</span>
<Stack align="center" gap="xs">
<div className="text-(length:--text-sm) font-medium text-text-primary">
<div className="text-(length:--text-sm) font-semibold text-text-primary">
Active workspace set
</div>
<Badge variant="accent">{containerTag}</Badge>
<WorkspaceChip containerTag={containerTag} />
</Stack>
<p className="max-w-xs text-(length:--text-xs) text-text-muted">
All subsequent saves and recalls will use this workspace until you
change it.
<p className="max-w-xs text-(length:--text-xs) leading-relaxed text-text-muted">
Saves and recalls will use this workspace until you change it.
</p>
</Stack>
)

View file

@ -9,11 +9,13 @@ export function ErrorView({ message }: Props) {
return (
<Stack
align="center"
className="px-(--page-header-px) py-(--space-10) text-center"
className="mx-(--page-header-px) my-(--space-6) rounded-xl border border-[var(--panel-border)] bg-[var(--panel-bg)] px-(--page-header-px) py-(--space-10) text-center shadow-[var(--panel-shadow)]"
gap="md"
>
<WarningCircle className="size-12 text-error" />
<div className="text-(length:--text-sm) font-medium text-text-primary">
<span className="flex size-11 items-center justify-center rounded-full bg-[var(--error-muted)] text-error">
<WarningCircle className="size-6" />
</span>
<div className="text-(length:--text-sm) font-semibold text-text-primary">
Something went wrong
</div>
<p className="max-w-sm text-(length:--text-xs) leading-relaxed text-text-muted">

View file

@ -58,19 +58,25 @@ function readGraphColors(): GraphThemeColors {
return out
}
// Read the graph palette from CSS, and re-read after a theme switch. We can't
// rely on the package's own useGraphTheme: it watches the `class` attribute,
// but the MCP host themes via `data-theme`, applied in a passive effect that
// runs AFTER children render. requestAnimationFrame fires after that effect,
// so by then `data-theme` (hence the --graph-* values) is current.
// Read the graph palette from CSS, and re-read whenever the document theme
// flips. We can't rely on the package's own useGraphTheme (it watches `class`),
// nor on the `theme` prop alone: the host themes via `data-theme`, and in
// standalone/Studio the attribute can change without the prop changing. So we
// observe `data-theme`/`class` on <html> directly and re-read after the next
// paint (rAF), by which point the --graph-* values are current.
function useGraphColors(theme: string): GraphThemeColors {
const [colors, setColors] = useState<GraphThemeColors>(readGraphColors)
useEffect(() => {
// `theme` change is the trigger; re-read after the host applies
// data-theme (rAF fires after that passive effect).
if (!theme) return
const id = requestAnimationFrame(() => setColors(readGraphColors()))
return () => cancelAnimationFrame(id)
const reread = () =>
requestAnimationFrame(() => setColors(readGraphColors()))
reread()
const observer = new MutationObserver(reread)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "class"],
})
return () => observer.disconnect()
}, [theme])
return colors
}
@ -151,6 +157,14 @@ export function Graph({ documents, totalCount }: Props) {
// mid-session theme switch re-themes the canvas.
const theme = (ctx?.theme as string | undefined) ?? "light"
const colors = useGraphColors(theme)
const graphColors = useMemo<Partial<GraphThemeColors>>(
() => ({
...colors,
bg: "transparent",
edgeDerives: "#9ca3af",
}),
[colors],
)
const fullscreenSupported = useMemo(() => {
const modes = (
@ -186,16 +200,21 @@ export function Graph({ documents, totalCount }: Props) {
return (
<div className="relative">
<div className={cn("graph-view", mode === "fullscreen" && "fullscreen")}>
<div
className={cn(
"graph-view overflow-hidden",
mode === "fullscreen" && "fullscreen",
)}
>
{/* No remount on toggle. The CSS just resizes the container; the
package's ResizeObserver re-lays-out and its node cache keeps
positions stable, so expand/minimize is instant with no reload.
Theme is fed reactively via the colors prop. */}
<MemoryGraph
colors={colors}
colors={graphColors}
documents={graphDocuments}
totalCount={totalCount}
variant="console"
variant="consumer"
/>
</div>
{fullscreenSupported ? (
@ -203,7 +222,7 @@ export function Graph({ documents, totalCount }: Props) {
aria-label={
mode === "fullscreen" ? "Exit fullscreen" : "Enter fullscreen"
}
className="absolute top-4 right-4 z-40 inline-flex items-center justify-center w-9 h-9 rounded-md bg-bg-elevated/80 backdrop-blur border border-border text-text-secondary hover:text-text-primary hover:bg-bg-muted transition-colors"
className="absolute top-3 right-3 z-40 inline-flex items-center justify-center w-9 h-9 rounded-full bg-bg-elevated/90 backdrop-blur border border-border text-text-secondary hover:text-text-primary hover:bg-bg-muted transition-colors"
onClick={toggleFullscreen}
title={mode === "fullscreen" ? "Exit fullscreen" : "Fullscreen"}
type="button"

View file

@ -1,7 +1,29 @@
export function Loading() {
return (
<div className="flex items-center justify-center py-(--space-12)">
<div className="spinner" />
<div className="mcp-widget-loading flex items-center justify-center py-(--space-12)">
<output
aria-label="Loading..."
className="super-loader"
style={{ ["--super-loader-size" as string]: "42px" }}
>
<svg
aria-hidden="true"
className="super-loader-mark"
viewBox="0 0 21 21"
>
<path
className="super-loader-path super-loader-path-right"
d="M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z"
pathLength={1}
/>
<path
className="super-loader-path super-loader-path-left"
d="M12.6994 9.02793V3.52344H10.6533V9.49591C10.6533 10.1302 10.9037 10.7395 11.3488 11.1883L16.5197 16.4032L17.9665 14.9441L14.1473 11.0926H19.0334V9.02914L12.6994 9.02793Z"
pathLength={1}
/>
</svg>
<span className="super-loader-label">Loading...</span>
</output>
</div>
)
}

View file

@ -8,7 +8,8 @@ import { WorkspaceCard } from "../components/WorkspaceCard"
import { Input, PageHeader } from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
import { Search } from "../lib/icons"
import { formatTagLabel } from "../lib/formatTag"
import { Package, Search } from "../lib/icons"
interface Props {
containerTags: ContainerTag[]
@ -70,55 +71,68 @@ export function Picker({
const count = containerTags.length
const description =
count === 0
? "No workspaces available."
: `${count} workspace${count === 1 ? "" : "s"} available — select one to set your active context.`
? "Create a workspace in Supermemory to get started."
: "Pick the workspace to save and recall from."
return (
<div className="flex flex-col">
<PageHeader description={description} title="Select Workspace" />
<div className="flex flex-col gap-(--space-4) px-(--page-header-px) pb-(--space-6)">
{count >= SEARCH_THRESHOLD ? (
<div className="relative max-w-sm">
<Search className="pointer-events-none absolute left-(--space-3) top-1/2 size-4 -translate-y-1/2 text-text-muted" />
<Input
className="pl-(--space-8)"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search workspaces…"
value={query}
/>
</div>
) : null}
{/* Bounded, scrollable list keeps a stable height so filtering or a
long workspace list doesn't resize (jump) the whole widget. */}
<div className="min-h-[220px] max-h-[60vh] overflow-y-auto pr-1">
{filtered.length === 0 ? (
<p className="py-(--space-6) text-center text-(length:--text-sm) text-text-muted">
No workspaces match {query}.
<PageHeader description={description} title="Workspaces" />
<div className="flex flex-col gap-(--space-3) px-(--page-header-px) pb-(--space-6)">
{count === 0 ? (
<div className="flex flex-col items-center gap-(--space-2) rounded-xl border border-border bg-[var(--card-bg)] px-(--space-6) py-(--space-10) text-center">
<Package className="size-7 text-text-muted" />
<p className="text-(length:--text-sm) font-medium text-text-primary">
No workspaces yet
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-(--space-3)">
{filtered.map((tag) => {
const access = assignedTags?.find(
(t) => t.containerTag === tag.containerTag,
)
return (
<WorkspaceCard
access={access}
active={activeTag === tag.containerTag}
containerTag={tag}
key={tag.id || tag.containerTag}
onClick={handleSelect}
/>
)
})}
</div>
)}
</div>
<p className="max-w-xs text-(length:--text-xs) leading-relaxed text-text-muted">
Workspaces you create in Supermemory show up here, ready to save
and recall from.
</p>
</div>
) : (
<>
{count >= SEARCH_THRESHOLD ? (
<div className="relative">
<Search className="pointer-events-none absolute left-(--space-3) top-1/2 size-4 -translate-y-1/2 text-text-muted" />
<Input
className="pl-(--space-8)"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search workspaces…"
value={query}
/>
</div>
) : null}
{filtered.length === 0 ? (
<div className="workspace-picker-grid items-center justify-center py-(--space-8)">
<p className="px-(--space-4) text-center text-(length:--text-sm) text-text-muted">
No workspaces match {query}.
</p>
</div>
) : (
<div className="workspace-picker-grid">
{filtered.map((tag) => {
const access = assignedTags?.find(
(t) => t.containerTag === tag.containerTag,
)
return (
<WorkspaceCard
access={access}
active={activeTag === tag.containerTag}
containerTag={tag}
key={tag.id || tag.containerTag}
onClick={handleSelect}
/>
)
})}
</div>
)}
</>
)}
{pending ? (
<p className="text-(length:--text-xs) text-text-muted">
Setting workspace to {pending}
Setting workspace to {formatTagLabel(pending)}
</p>
) : null}
</div>

View file

@ -11,6 +11,7 @@ import {
} from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
import { formatTagLabel } from "../lib/formatTag"
interface Props {
activeTag?: string | null
@ -42,7 +43,12 @@ export function Save({
}, [selectedTag, writableTags])
const options = useMemo(
() => writableTags.map((tag) => ({ value: tag, label: tag })),
() =>
writableTags.map((tag) => ({
value: tag,
label: formatTagLabel(tag),
description: tag,
})),
[writableTags],
)
@ -84,39 +90,41 @@ export function Save({
title="Add Memory"
/>
<div className="px-(--page-header-px) pb-(--space-6)">
<Stack gap="lg">
<Field label="Memory">
<TextArea
className="min-h-40"
onChange={(e) => setContent(e.target.value)}
placeholder="Enter content to save as a memory…"
value={content}
/>
</Field>
{writableTags.length > 0 ? (
<Field label="Workspace">
<WorkspaceSelect
onValueChange={setSelectedTag}
options={options}
value={selectedTag}
<div className="rounded-xl border border-[var(--panel-border)] bg-[var(--panel-bg)] p-(--space-4) shadow-[var(--panel-shadow)]">
<Stack gap="lg">
<Field label="Memory">
<TextArea
className="min-h-40"
onChange={(e) => setContent(e.target.value)}
placeholder="Enter content to save as a memory…"
value={content}
/>
</Field>
) : null}
<div className="flex justify-end border-t border-border-muted pt-(--space-4)">
<ActionGroup>
<Button
disabled={!canSave}
loading={saving}
onClick={handleSave}
variant="primary"
>
{saving ? "Saving" : "Save memory"}
</Button>
</ActionGroup>
</div>
</Stack>
{writableTags.length > 0 ? (
<Field label="Workspace">
<WorkspaceSelect
onValueChange={setSelectedTag}
options={options}
value={selectedTag}
/>
</Field>
) : null}
<div className="flex justify-end pt-(--space-1)">
<ActionGroup>
<Button
disabled={!canSave}
loading={saving}
onClick={handleSave}
variant="primary"
>
{saving ? "Saving" : "Save memory"}
</Button>
</ActionGroup>
</div>
</Stack>
</div>
</div>
</div>
)

View file

@ -1,15 +1,14 @@
import { Badge, Stack } from "../design/ui"
import { CheckCircle } from "../lib/icons"
import { WorkspaceChip } from "../components/WorkspaceChip"
import { Stack } from "../design/ui"
import { Check } from "../lib/icons"
interface SaveProps {
kind: "save"
id: string
containerTag: string
}
interface UploadProps {
kind: "upload"
id: string
fileName: string
containerTag: string
}
@ -21,19 +20,18 @@ export function Success(props: Props) {
return (
<Stack
align="center"
className="px-(--page-header-px) py-(--space-10) text-center"
className="mx-(--page-header-px) my-(--space-6) rounded-xl border border-[var(--panel-border)] bg-[var(--panel-bg)] px-(--page-header-px) py-(--space-10) text-center shadow-[var(--panel-shadow)]"
gap="md"
>
<CheckCircle className="size-12 text-success" />
<span className="flex size-11 items-center justify-center rounded-full bg-[var(--success-muted)] text-success">
<Check className="size-6" />
</span>
<Stack align="center" gap="xs">
<div className="text-(length:--text-sm) font-medium text-text-primary">
<div className="text-(length:--text-sm) font-semibold text-text-primary">
{isUpload ? `Uploaded ${props.fileName}` : "Memory saved"}
</div>
<Badge variant="accent">{props.containerTag}</Badge>
<WorkspaceChip containerTag={props.containerTag} />
</Stack>
<p className="break-all font-mono text-(length:--text-xs) text-text-muted">
{props.id}
</p>
</Stack>
)
}

View file

@ -11,6 +11,7 @@ import {
} from "../design/ui"
import { useApp } from "../hooks/useApp"
import { useLog } from "../hooks/useLog"
import { formatTagLabel } from "../lib/formatTag"
import { FileText, X } from "../lib/icons"
import { readFileAsBase64 } from "../lib/readFileAsBase64"
@ -39,7 +40,12 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
const [uploading, setUploading] = useState(false)
const options = useMemo(
() => writableTags.map((tag) => ({ value: tag, label: tag })),
() =>
writableTags.map((tag) => ({
value: tag,
label: formatTagLabel(tag),
description: tag,
})),
[writableTags],
)
@ -89,59 +95,61 @@ export function Upload({ activeTag, writableTags, onAdvance, onError }: Props) {
title="Upload File"
/>
<div className="px-(--page-header-px) pb-(--space-6)">
<Stack gap="lg">
{file ? (
<div className="flex items-center justify-between gap-(--space-3) rounded-(--radius-lg) border border-border bg-bg-elevated p-(--space-3)">
<div className="flex min-w-0 items-center gap-(--space-3)">
<FileText className="size-5 shrink-0 text-text-secondary" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-(length:--text-sm) font-medium text-text-primary">
{file.name}
</span>
<span className="text-(length:--text-xs) font-mono text-text-muted">
{formatFileSize(file.size)}
</span>
<div className="rounded-xl border border-[var(--panel-border)] bg-[var(--panel-bg)] p-(--space-4) shadow-[var(--panel-shadow)]">
<Stack gap="lg">
{file ? (
<div className="flex items-center justify-between gap-(--space-3) rounded-(--radius-lg) border border-[var(--border-control)] bg-[var(--bg-control)] p-(--space-3) shadow-[var(--shadow-inset)]">
<div className="flex min-w-0 items-center gap-(--space-3)">
<FileText className="size-5 shrink-0 text-text-secondary" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-(length:--text-sm) font-medium text-text-primary">
{file.name}
</span>
<span className="text-(length:--text-xs) font-mono text-text-muted">
{formatFileSize(file.size)}
</span>
</div>
</div>
<Button
aria-label="Remove file"
iconLeft={<X className="size-4" />}
onClick={() => setFile(null)}
size="icon"
variant="ghost"
/>
</div>
<Button
aria-label="Remove file"
iconLeft={<X className="size-4" />}
onClick={() => setFile(null)}
size="icon"
variant="ghost"
) : (
<FileUpload
accept={ACCEPT}
description="Supports TXT, PDF, PNG, JPG, MP4"
onFile={setFile}
/>
)}
{writableTags.length > 0 ? (
<Field label="Workspace">
<WorkspaceSelect
onValueChange={setSelectedTag}
options={options}
value={selectedTag}
/>
</Field>
) : null}
<div className="flex justify-end pt-(--space-1)">
<ActionGroup>
<Button
disabled={!canUpload}
loading={uploading}
onClick={handleUpload}
variant="primary"
>
{uploading ? "Uploading" : "Upload file"}
</Button>
</ActionGroup>
</div>
) : (
<FileUpload
accept={ACCEPT}
description="Supports TXT, PDF, PNG, JPG, MP4"
onFile={setFile}
/>
)}
{writableTags.length > 0 ? (
<Field label="Workspace">
<WorkspaceSelect
onValueChange={setSelectedTag}
options={options}
value={selectedTag}
/>
</Field>
) : null}
<div className="flex justify-end border-t border-border-muted pt-(--space-4)">
<ActionGroup>
<Button
disabled={!canUpload}
loading={uploading}
onClick={handleUpload}
variant="primary"
>
{uploading ? "Uploading" : "Upload file"}
</Button>
</ActionGroup>
</div>
</Stack>
</Stack>
</div>
</div>
</div>
)