0. Design Principles
The memax design philosophy. Read this first — every token, component, and pattern decision flows from these 8 rules.
If you are an AI agent generating memax UI code, these are the hard rules. Each rule maps to a section. Violating any rule produces non-memax output.
MEMAX DESIGN SYSTEM — HARD RULES FOR CODE GENERATION
═══════════════════════════════════════════════════════
COLORS (§13)
text: text-fg-1 (titles, body) | text-fg-2 (secondary) | text-fg-3 (meta) | text-fg-4 (decorative)
surfaces: bg-surface-1 (subtle) | bg-surface-2 (hover) | bg-surface-3 (active)
accent: var(--signature) — ONLY for AI indicators (✦) AND Intelligence tab controls, never decorative
NEVER: text-gray-*, bg-gray-*, hardcoded colors, text-foreground/XX
CONTROL COLOR SEMANTICS (§19)
rule: purple = Intelligence tab ONLY. Everywhere else uses NEUTRAL_INK.
NEUTRAL_INK: var(--fg-1) — active toggles, radio rings, radio dots
NEUTRAL_INK_INVERSE: var(--background) — toggle thumb when filled
off states: NEUTRAL_TRACK_OFF / NEUTRAL_BORDER_OFF / NEUTRAL_THUMB_OFF
import from: @memaxlabs/ui/tokens/controls
toggle: iOS-solid. Track fills with active color, thumb inverts.
radio: border-2 ring + NEUTRAL_INK dot. NEVER fill the outer ring.
pills vs radio rows: pills = self-explanatory labels (roles, tiers). radio rows = needs descriptions (Plain/Signature/Time).
See §19 "Control color semantics" card for the canonical rule block.
TYPOGRAPHY (§12)
H1: text-[21px] font-bold (one per view, memory detail title)
H2: text-[16px] font-bold (section headers)
H3: text-[14px] font-semibold (card titles)
body: text-[14px] text-fg-1 (default)
secondary: text-[13px] text-fg-2 or text-fg-3 (descriptions, meta)
caption: text-[12px] text-fg-3 (timestamps, kind)
micro: text-[10px] text-fg-4 uppercase tracking-wider font-semibold (group headers)
weights: 400-700 only. NEVER font-light (300) or font-black (900)
prose: leading-[1.65] for AI answers and readable paragraphs
SURFACES (§14)
card grid: <Surface variant="default"> (bar-border + bar-shadow)
detail: <Surface variant="subtle"> (border only)
mobile: <Surface variant="borderless"> (full bleed)
minimal: <Surface variant="clean"> (bg only)
note: Surface's 'rounded' prop ("xl"|"2xl"|"lg") is a component API
that resolves to the unified tokens — "xl"/"2xl" resolve to
rounded-surface (20px); "lg" is legacy — don't use it. Surface
defaults to rounded-surface when the prop is omitted.
radius: TWO tiers only — rounded-surface (20px) | rounded-chrome (14px)
surface: cards, dialogs, popovers, bar, form sub-cards, containers
chrome: buttons, inputs, chips, role tags, code pills, any interactive
element ≤40px tall (even role tags become pill-ish at that size)
source: packages/ui/src/surface-radius.css (root-scoped, theme-safe)
NEVER use rounded-lg/md/xl/2xl/3xl on product UI — those are stale.
Exceptions:
rounded-full — true circles (avatars, status dots) OR status pills
≤24px tall where the half-height clamps to the same
curve as rounded-chrome anyway
rounded-sm — 2px focus outlines on inline text links only
LAYOUT (§20)
page: max-w-4xl mx-auto px-5 sm:px-8 pb-36 md:pb-32
top: paddingTop: CONTENT_TOP (80px desktop)
entrance: animate-content-ready (on every page load)
bar: fixed bottom center, z-bar, max-w-[640px]
modal: z-modal (backdrop + content — DOM order stacks)
Z-INDEX SCALE — semantic tokens only (§20)
NEVER use hardcoded z-50, z-[60], etc. Pick from this scale:
z-page (0) regular page content
z-bar-notif (30) bar notification (below bar)
z-bar (40) command bar, mobile dock, top-right chrome row
z-topic-tree (50) Topic Explorer floating panel + mobile fullscreen
z-modal (60) Settings panel, Settings dialog, memory modal,
admin drawer, batch-toolbar backdrop
z-popover (70) ALL popovers/dropdowns/menus/InfoPopover (above
modal so popovers triggered inside a dialog work)
z-takeover (80) full-screen immersive: lightbox, hub-create,
mobile compose, surface-transition-overlay
z-toast (90) top-most ephemeral feedback, impersonation-bar
Invariant: popover > modal > topic-tree > bar > bar-notif > page.
Invariant: takeover > popover (full-screen covers dropdowns).
Tokens are defined as CSS vars (--z-*) in :root + @utility blocks
in globals.css. Do NOT invent new tiers unless the token scale
genuinely can't express the layer — add a tier with a rationale.
GLASS + BACKDROP-BLUR — unified surface material (§14)
ALL translucent floating surfaces use ONE of three sibling classes,
each paired with backdrop-blur-sm (8px) so the standard
backdrop-filter property emits (Lightning CSS strips the standard
form from raw custom-CSS declarations in some cases).
.glass-bar command bar → paired in layout.tsx
.glass-panel Topic Explorer tree panel → paired in topic-tree-panel.tsx
.glass-dropdown ALL popovers (via PopoverContent) → paired in packages/ui/src/components/popover.tsx
Recipe: 65% fill + saturate(180%) contrast(1.05) + rim inset + ambient shadow.
NEVER hand-roll a fourth glass variant. If a new surface needs glass,
reuse one of the three. The popover primitive OWNS the material for
every dropdown — consumers pass NO variant/override.
Popovers: PopoverContent is always glass — no variant prop.
Menu rows: <MenuItem> from @memaxlabs/ui — never hand-roll a button
with px/py/hover/radius classes for menu items.
MOTION (§18)
easing: var(--ease-spring) — cubic-bezier(0.16, 1, 0.3, 1)
NEVER: linear, ease-in-out, ease
fast: 0.15s (hover, modal, dropdown)
normal: 0.2s (content transitions)
AI breathe: state-slow-breathe (2.5s loop)
entrance: animate-content-ready (0.15s opacity + translateY)
MOBILE MOTION — HARD RULES (§38e3 mobile lifecycle)
░░░ THE SINGLE-BLUR INVARIANT ░░░
At most ONE backdrop-filter: blur() element composited at a time on mobile.
Stacking (scrim + glass edge + bar) is the #1 frame-drop cause on mobile GPUs.
Never blur over a solid background — if bg-card / bg-background is opaque,
backdrop-filter is INVISIBLE but still costs a full compositing pass per
frame. Check --card / --background opacity BEFORE adding blur.
CROSS-TAB DOCK SWITCH (mobile)
Instant. No fade, no surface-transition overlay, no color-interpolation
on tab buttons. router.push() only. Desktop keeps subtle fade-in; mobile
snaps. NEVER call requestSurfaceTransitionForNavigation on mobile tabs.
MEMORY DETAIL NAVIGATION
Entry: opacity fade via template's animate-fade-in (150ms). No slide-in.
Exit: router.back() INSTANT. No setTimeout delay, no slide-out-right.
NEVER: animate-slide-in-right / animate-slide-out-right on mobile routes.
TOPIC DRILL (kitchen 29n spec)
Duration: FAST (150ms) on mobile, NORMAL (200ms) on desktop.
Translate: ±16px on x-axis.
Easing: var(--ease-spring).
Parallel: AnimatePresence WITHOUT mode="wait". Serial mode doubles
perceived duration (exit 200ms + enter 200ms = 400ms).
MOBILE COMPOSE SHEET (kitchen 38e3)
Surface: ONE solid var(--background) sheet. NEVER translucent + blur.
The compose state TAKES OVER, it doesn't LAYER on top.
Motion: opacity fade only (FAST 150ms). NO y-slide, NO delayed content
fade, NO framer layout animations.
Gesture: drag-to-dismiss is user-initiated — keep it. Rubber-band
resist 0.55, dismiss >96px OR velocity >720px/s, settle 0.25s.
BAR ON MOBILE
- No 100ms setTimeout stagger before barVisible flips.
- No CSS transition on outer positioning div (top / transform constants).
- No framer opacity+y fade-in on mount/tab-switch.
- Position: calc(100dvh - 96px - var(--safe-bottom, 0px)) above dock.
- Rest dock has zero backdrop-filter (see single-blur invariant).
CHROME ROW (logo + hub chip + avatar)
- No transition-all on mobile — tab switches must not animate bg/border.
- Always transparent on mobile; banner-mode tint transition is desktop-only.
- BrandLogo: h-5 w-24 (mobile) / h-6 w-30 (desktop).
MEMORY ROW COMPACT (mobile recent surface)
- showSummary = false (no description preview).
- useStackedRecent = false (single-line layout).
- showCopy = false (saves horizontal space).
- leadingIdentity = "none" (title leads the scan).
- trailingActor = author avatar | agent icon | none.
- showTrailingContentMeta = true for pdf/image/link (NOT for docs/notes).
- Flag: isMobileCompactRecent in memory-row-presentation.ts.
POPOVERS / DROPDOWNS
- No backdrop-filter over solid bg-card. Remove it — the card is opaque.
- data-open:duration-100 (not default 150) for snap-open feel.
- For mobile SettingsPanel-style: plain conditional render, no framer
AnimatePresence. Framer overhead eats the budget at sub-150ms durations.
FRAMER MOTION BUDGET
- Never nest motion.div with opacity fades 3 levels deep for one appearance.
- duration < 0.15s → prefer CSS transition or plain render (framer tax).
- AnimatePresence mode="wait" → NEVER on routes; use default parallel.
CONTROLS (§19)
buttons: <Button variant="default|outline|secondary|ghost|destructive">
sizes: xs (h-6) | sm (h-7) | default (h-8) | lg (h-9)
icon: icon-xs (24px) | icon-sm (28px) | icon (32px) | icon-lg (36px)
send btn: h-8 w-8 rounded-lg. Push=foreground fill, Recall=signature fill
toggle: w-10 h-6 rounded-full. See CONTROL COLOR SEMANTICS above for on/off colors.
ACCESSIBILITY (§27)
fg-1: 12.2:1 — safe everywhere
fg-2: 5.2:1 — safe everywhere
fg-3: 2.5:1 — meta/labels only, FAILS AA normal text in light mode
fg-4: 1.5:1 — decorative only, NEVER readable text
focus: focus-visible:ring-3 focus-visible:ring-ring/50
touch: 44px primary, 32px secondary, 24px minimumAll text and surface colors derive from --foreground via opacity. Never hardcode color values in components.
Why: One variable change updates the entire palette. Dark mode, themes, and accessibility come free.
text-fg-2 (65% opacity) instead of text-gray-500The same container adapts to different states. Never overlay when the surface can transform.
Why: Overlays break spatial continuity. Users lose context when content jumps between surfaces.
Memory card morphs into detail view — no modal popup, no new page.Dream Violet (--signature) appears only where memax AI is actively working. Never decorative.
Why: If signature color is everywhere, it means nothing. Scarcity creates meaning.
✦ star breathes during AI streaming, static when complete. Never on borders or backgrounds.Floating bottom bar, centered content. Tree navigation is peek-on-demand, not always visible.
Why: Maximum content density. The bar is the only persistent UI element.
Bar: fixed bottom center (z-50). Content: scrolls freely with max-w-4xl.Use var(--ease-spring) for all transitions. Never linear, never ease-in-out.
Why: Spring easing (fast start, gentle settle) creates the memax feel — responsive, alive, never sluggish.
cubic-bezier(0.16, 1, 0.3, 1) for all UI motion.Skeletons mirror the final layout exactly. Loading states show shape, not spinners.
Why: The brain anchors to layout. When content loads into the same shape, it feels instant.
Shimmer skeleton with dot+title+body matches loaded MemoryRow.Use spacing and opacity to separate sections. Never add visible dividers or accent borders.
Why: Dividers add visual noise. Whitespace is a stronger (and quieter) grouping signal.
divide-border/20 (barely visible) or gap spacing, never border-l-4 colored accent.Default styles are mobile. Add sm:/md:/lg: for larger screens. Never block keyboard handlers behind isMobile.
Why: Touch targets and keyboard shortcuts coexist. isMobile is for visual hints only, never for disabling functionality.
Mobile: pill chips (44px touch). Desktop: text toggle. Both work with keyboard.At most ONE backdrop-filter element composited at a time on mobile. Never stack scrim + edge + bar. Never blur a solid bg.
Why: Mobile GPUs choke on stacked backdrop-filter. Invisible blur (over opaque bg-card) still costs a full compositing pass — pure waste.
Mobile compose = solid var(--background) sheet, zero backdrop-filter. Popover over bg-card = no blur.Cross-tab dock swap, memory detail back, and in-app route changes run with ZERO fade or slide on mobile. Entrance animations are opacity-only when present.
Why: Any motion on cross-tab navigation reads as lag. Apple Notes, Linear, Instagram all snap-switch tabs. Motion is reserved for the compose/drill flows that need it (kitchen 29n topic drill only).
mobile-dock onClick → router.push(). No requestSurfaceTransitionForNavigation. No color transition on tab button. router.back() has no setTimeout.No visual noise. Opacity hierarchy instead of color variety. Whitespace instead of dividers. The content breathes.
Spring easing on everything. ✦ breathes when AI works. Containers morph instead of swapping. Nothing feels static or dead.
One accent color, used sparingly. No gradients, no decorative elements. The product gets out of the way. Content is the hero.
text-gray-500text-fg-3Gray doesn't adapt to dark mode or themesease-in-outvar(--ease-spring)Feels sluggish and genericborder-l-4 border-blue-500Surface variant + shadow liftColored accent lines are not memaxbg-gray-100bg-surface-1Surface tokens adapt to any themeshadow-mdvar(--bar-shadow)Bar shadow is a composed, branded shadowrounded-md (6px) / rounded-lg (8px) on cards or buttonsrounded-surface (20px) on cards/dialogs; rounded-chrome (14px) on buttons/inputs/chipsTwo-tier system from surface-radius.css: --app-radius-surface (20px) for large containers, --app-radius-chrome (14px) for interactive chrome ≤40px. Everything else is stale.Modal overlay for editingInline transform / morphContainer morphing principleSpinner for loadingSkeleton shimmerShape before content principletext-foreground/40text-fg-3Use semantic tokens, not raw opacityfont-light (300)font-normal (400) minimumProduct uses 400-700 weight range onlybackdrop-filter: blur() over bg-cardno blur — bg-card is solid, blur is invisibleFull-viewport GPU compositing pass per frame for zero visual benefitStacked blur layers on mobile (scrim + edge + bar)One blur surface OR solid sheetMobile GPUs drop frames on multi-backdrop-filter compositingAnimatePresence mode="wait" on routesDefault parallel AnimatePresenceSerial mode doubles perceived duration (exit + enter)animate-slide-in-right / out-right on mobile routesCSS fade-in + instant router.back()Slide wrappers add 250-350ms lag to every mobile route swapsetTimeout(() => router.back(), 250)router.back() instantUsers perceive the delay as lag, not animation polishMultiple motion.div nested for one fadeSingle motion.div OR plain renderFramer overhead eats the budget at sub-150ms durationsrequestSurfaceTransitionForNavigation on mobilerouter.push() onlyOverlay + content scale/translate is desktop polish; mobile snapsTranslucent scrim over page for mobile composeSolid var(--background) sheet (container takeover)Kitchen 38e3 — compose TAKES OVER, doesn't LAYER. Apple Notes patternFoundations
Colors → Section 13
Typography → Section 12
Surfaces → Section 14
Spacing → Section 20
Motion → Section 18
Branding → Section 11
Components
Buttons/Badges → Section 19
Indicators → Section 17
State Machine → Section 16
Loading → Section 08
Empty States → Section 09
globals.css— all CSS custom properties (tokens)ui/button.tsx— Button (CVA variants + sizes)ui/badge.tsx— Badge (CVA variants)ui/surface.tsx— Surface (5 container variants)ui/pill.tsx— Pill (select / remove / add / static)ui/skeleton.tsx— Skeleton (loading placeholder)ui/memax-loader.tsx— MemaxLoader (signature dots)lib/motion.ts— JS timing constantslib/kind.ts— Kind dot colors11. Branding
Logo wordmark, icon at multiple sizes, and loader variants.
Light
Dark
The wordmark is the stable brand anchor. Next to the right-side hub capsule it was reading like a watermark — too small, too muted, hard to notice. The fix is size + opacity, not color. The ink stays neutral var(--foreground) so signature (violet) can keep its job as accent for hub avatars / dreams / active states without the wordmark stealing that vocabulary.
Before → after (rendered against the real top-bar right capsule)
✗ current — h-5 (20px mobile) / h-6 (24px desktop), opacity 0.45, neutral foreground. Reads as a faded watermark next to a 48px-tall capsule. Weight ratio ≈ 1:2.
✓ proposed — h-6 (24px, same mobile + desktop), opacity 0.9, var(--foreground). Bumping opacity 0.45 → 0.9 does the heavy lifting (2× readable weight); the mobile h-5 → h-6 nudge is a small follow-up so mobile matches desktop. Container h-12 so the vertical center lines up with the capsule.
Why neutral, not signature
Signature (violet today) is memax's accent language: hub avatar fill, dreams state, active tab indicator, recall star. Painting the wordmark with signature too would flatten that hierarchy — everything in the frame would be “the violet thing” and the accent stops carrying meaning.
Linear, Notion, Vercel, Apple all keep their wordmarks neutral for exactly this reason. The brand anchor stays stable; accents move around it.
var(--foreground) is already theme-aware at the only layer that matters: lightness. Near-black in light mode, near-white in dark, with a subtle palette-specific gray tint (see the grays table in _kitchen-context.tsx — each palette adds ~0.005 chroma so the neutral drifts warm / cool / earth / dream without ever becoming chromatic). That is the theme-awareness the wordmark needs — no hue swap required.
Wordmark vs hub ambience — two different layers
The top-bar memax wordmark is global product brand. The hub header behind it (aurora / signature fill / time-of-day drift) is local hub ambience. They are two different layers and neither should recolor the other.
Personal hubs can drift through time-based aurora, team hubs can stay on signature, dreams mode can pulse purple — none of that touches the wordmark. The logo stays stable while the header mood changes behind it.
Rule: if product branding changes, the wordmark can change. If hub ambience changes, the wordmark stays put.
Size — why h-6 (24px), not h-7 or h-5
h-5(20px, original mobile) was a genuine “too small” problem — at opacity 0.45 it read as a watermark, not a brand.
h-7 (28px) is too wide on mobile. SVG wordmark aspect ratio is 4.65:1, so h-7 → w-33 (132px). On a 375px viewport that's 35% of screen width competing with the capsule at the right edge — the top bar feels overstuffed.
h-6(24px, w-30 = 120px) is the sweet spot. On mobile it's 32% of a 375px screen instead of 35%, still leaves breathing room for the right capsule. On desktop it matches the current production value, so we ship one size for both — no md: breakpoint split. The wordmark is the stable brand anchor; making it different between breakpoints is inconsistency for no reason.
The real “feels present” fix isn't size anyway — it's opacity 0.45 → 0.9. That alone doubles the perceived weight; the size nudge is just making mobile match desktop.
Alignment — both containers at h-12, same top
Today the wordmark container is h-11 (44px) and the right capsule derives a ~46px height from its flex children. Close but not identical — and near-misses at 1–2px read as misalignment. Fix: give both containers flex h-12 items-center so they lock to the same vertical center at the same top value.
Implementation spec — use the @memaxlabs/ui component
// packages/web/src/app/(app)/layout.tsx
import { MemaxWordmark } from "@memaxlabs/ui";
function BrandLogo({ isMobile }: { isMobile: boolean }) {
return (
<div
className="fixed z-50 flex h-12 items-center" // was h-11
style={{
left: isMobile ? 16 : 32,
top: isMobile
? "max(20px, calc(8px + var(--safe-top, 0px)))"
: "32px",
}}
>
<a
href="/home"
className="cursor-pointer text-foreground opacity-90 transition-opacity hover:opacity-100"
>
<MemaxWordmark height={24} />
</a>
</div>
);
}
// Right-capsule container on line ~143 — lock height to match:
<div
className="fixed right-4 md:right-8 z-50 flex h-12 items-center gap-2 ..."
// ^^^^^ add h-12
...
>Switches from the mask-based /images/memax-wordmark.svg URL to the MemaxWordmark React component exported from @memaxlabs/ui. The component uses fill="currentColor", so text-foreground opacity-90 on the parent controls both ink and strength. No static asset to maintain, no CSS mask plumbing, and the kitchen demo above renders the exact same primitive production will use.
Four edits total: container h-11 → h-12, wordmark rendering swap to <MemaxWordmark />, text-foreground opacity-90 (replaces the hardcoded 0.45 foreground mask), and h-12 added to the right capsule so both sides share one vertical anchor.
Hub headers drift through aurora gradients (time-of-day, signature, dreams). When an aura sits behind the wordmark, the fixed var(--foreground) ink can fall into the gradient and become hard to read. This card answers the question: should the wordmark auto-adapt to the aura behind it?
The failure — dark ink on a darker aura
Light-theme foreground (near-black) at 0.9 on a saturated deep violet aura — legibility drops off a cliff. Dark-theme foreground (near-white) on a light peach aura has the same problem in reverse. We need a mechanism that doesn't require the wordmark to care what's behind it.
Four approaches the industry uses
✓ A. Scrim backdrop (Apple, Notion, Vercel, macOS Control Center)
The nav / logo sits on its own translucent glass backdrop (backdrop-blur-md + bg-background/52), which absorbs whatever is behind it. The wordmark itself never changes color — legibility comes from the scrim, not the ink. Brand identity is preserved (memax is always foreground-neutral), and the scrim is visually consistent regardless of aura mood. Industry default.
⚠️ B. mix-blend-mode: difference / luminosity (editorial, video heroes)
One CSS line auto-inverts the wordmark against whatever is behind it. Works well over grayscale or muted backgrounds. Against vivid gradients it goes psychedelic — the wordmark turns green/orange/cyan depending on which gradient stop is under each letter. Also kills brand intent: you lose “memax is this color”, you get “memax is whatever difference-blend says”. Wrong for a product brand that ships a distinctive signature feature elsewhere.
⚠️ C. IntersectionObserver + section-aware class swap (Linear docs, Stripe)
Each section declares its theme="light" or theme="dark", a scroll observer watches which section intersects the nav, and the wordmark container swaps a class. Full control, brand-preserving. But: stateful, JS-coupled, easy to break on dynamic content (e.g. dream aurora that drifts during a single session view). Overkill for a top-bar wordmark that lives above one kind of container.
❌ D. Auto-sample background color at runtime
Canvas-sample the pixels behind the wordmark, compute luminance, flip ink. Never do this. Expensive, flaky on animated backgrounds, impossible to SSR, impossible to snapshot test. Listed only so we name it and reject it.
Recommended — extend showBannerChrome to the wordmark side
memax already has the scrim mechanism for the right capsule: showBannerChrome is the boolean state that switches the right container between glass chrome and transparent. We apply the same treatment to the wordmark container — one source of truth, zero new state, both sides light up together when the banner is visible.
showBannerChrome = false — plain background, both sides transparent. Wordmark reads directly against var(--background).
showBannerChrome = true — both sides wear the same glass scrim (bg-background/52 + backdrop-blur-md + ring-1 ring-black/5). Wordmark ink stays neutral; legibility comes from the scrim. Brand anchor never wiggles.
Why scrim beats auto-adaptive color
Legibility without identity loss. The wordmark never changes color, so the brand stays recognizable. Users learn “memax looks like this” once and it holds in every surface.
Stateless. No canvas sampling, no scroll listeners, no viewport math. CSS alone. SSR-friendly, snapshot-testable, cacheable.
Symmetry. The right-side hub capsule already flips on showBannerChrome. Making the wordmark do the same thing ties the two sides into one visual beat instead of two independent rules.
Future-proof for dreams mode. If dreams ever ships an aurora that saturates more deeply, the scrim absorbs it automatically — the darker the aura, the more the scrim carries. No per-aura color tuning.
Implementation — wordmark container mirrors the right capsule
// packages/web/src/app/(app)/layout.tsx
function BrandLogo({
isMobile,
showBannerChrome, // NEW — accept the same flag the right capsule uses
}: {
isMobile: boolean;
showBannerChrome: boolean;
}) {
return (
<div
className={`fixed z-50 flex h-12 items-center rounded-2xl px-3 py-1.5 transition-all ${
showBannerChrome
? "border border-white/10 bg-background/52 backdrop-blur-md ring-1 ring-black/5"
: "border border-transparent bg-transparent backdrop-blur-0 ring-0"
}`}
style={{
left: isMobile ? 16 : 32,
top: isMobile
? "max(20px, calc(8px + var(--safe-top, 0px)))"
: "32px",
}}
>
<a
href="/home"
className="cursor-pointer text-foreground opacity-90 transition-opacity hover:opacity-100"
>
<MemaxWordmark height={24} />
</a>
</div>
);
}
// Layout already computes showBannerChrome for the right capsule — pass the
// same value into <BrandLogo /> so both sides light up together.Single state, two consumers. The wordmark container now wears the identical glass chrome as the right capsule, gated on the same showBannerChrome flag. When the banner is absent both sides are invisible chrome; when it is present both sides wear a matching scrim. The wordmark ink (text-foreground opacity-90) never changes.
48px
32px (favicon)
20px (nav)
48px dark
inline
compact
with label
3 dots in signature color, sequential pulse. Pure CSS — no requestAnimationFrame.
13. Color Tokens
Semantic color system built on oklch opacity architecture. All derived from --foreground, adapts to any theme or dark mode automatically.
--fg-1: oklch(from var(--foreground) l c h / var(--op-primary));
--surface-1: oklch(from var(--foreground) l c h / var(--op-bg-subtle));All semantic colors are computed from --foreground with opacity multipliers. Change one variable, entire palette adapts. No hardcoded color values in components.
text-fg-1Titles, body, input text
/90text-fg-2Descriptions, placeholders, secondary labels
/65text-fg-3Timestamps, meta, muted labels
/40text-fg-4Hints, annotations, divider text
/20bg-surface-1Slight tint, chip background, hover hint
/3bg-surface-2Hover state, selected row, active tab
/5bg-surface-3Active/pressed state, strong selection
/8Stacking demo
--background#fafafaoklch(0.155 0.01 260)Page background--foreground#1c1c1eoklch(0.985 0 0)Primary text base--card#ffffffoklch(0.2 0.008 260)Card, bar, modal surfaces--border#e5e5e5oklch(0.275 0 0)Card borders, separators--muted#f2f2f2oklch(0.269 0 0)Summary callouts, subtle bg--destructive#ff3b30oklch(0.704 0.191 22)Error, delete states--primary#000000oklch(0.922 0 0)Primary button, CTA fill--secondary#e8e8e8oklch(0.269 0 0)Secondary button, modal headerBackground
--backgroundCard
--cardForeground
--foregroundBorder
--borderMuted
--mutedDestructive
--destructivePrimary
--primarySecondary
--secondaryoklch(0.62 0.16 290)The memax intelligence marker. Appears only where memax is actively working — never decorative, never structural.
Allowed usage
Never use for
Decorative borders or backgrounds
Regular button fills or highlights
Text color for non-AI content
Branding outside the app
--signature-mutedoklch(signature / 0.12) — subtle tint for backgrounds
--bar-borderBar border color (12% opacity)--bar-shadowBar shadow (layered: outline + medium + large)--bar-bgBar background (falls back to --card)12. Typography
Complete type system. Every text element maps to a CSS variable + Tailwind class. 7-step scale, 3-level heading hierarchy, 4 weights.
Sans is the OS default — SF Pro on macOS/iOS, Segoe UI on Windows, Roboto on Android. No brand typeface, no Geist, no Inter, no Space Grotesk. Geist Mono is the only webfont we ship, for code/kbd/tokens.
Hierarchy is size × weight × tracking, never family swap. A heading is the same typeface as a memory row — just bigger, bolder, tighter. Content stays louder than chrome because the chrome literally disappears into the OS.
font-heading and font-display both alias to font-sans. Semantic intent in markup, native family at runtime. The stack is declared once in globals.css :root — never hardcode font-family.
Scar tissue: 2026-04-14 shipped next/font/google Geist, which injected a literal Geist into every font stack via its src: local() fallback. Designers with Geist locally installed saw their local Geist instead of SF Pro — thin, geometric, mismatched against every native element. Non-devs saw a completely different product. Never import Google web sans fonts into product surfaces again.
font-sansRemember what your agents learn. Recall it anywhere.
SF Pro on macOS/iOS, Segoe UI on Windows, Roboto on Android, system-ui elsewhere. Every piece of UI text. Zero webfont load.
font-headingThe bar is a search input that happens to save.
normal trackingThe bar is a search input that happens to save.
display trackingSemantic alias of font-sans. Use it on section headers to signal intent. Same native family, tighter tracking via text-display-* classes.
font-monoconst memory = await memax.recall(query);
Code snippets, keyboard glyphs, tokens, CLI output. The only webfont we ship — cross-OS mono drift (Menlo vs Consolas vs DejaVu) is too wide to leave to the system.
小宝记忆空间 remember what your agents learn
小宝记忆空间 remember what your agents learn
Latin glyphs should stay in Geist. CJK falls back to system PingFang/YaHei/Noto without changing hierarchy.
Settings
text-[21px] font-bold21px · 700 (bold)— Memory detail title, page header (one per view)Deployment Strategy
text-[16px] font-bold16px · 700 (bold)— Section header, note detail titleReact Server Components
text-[14px] font-semibold14px · 600 (semibold)— Card title, list item title, sub-sectionRule: one H1 per view. H2 for sections within a page. H3 for cards/list items. Never skip levels.
--text-title18pxtext-[18px]Modal title, page header--text-input15→16pxtext-[15px] sm:text-[16px]Bar input, placeholders--text-body14pxtext-[14px]Card title, body, AI answer--text-secondary13pxtext-[13px]Snippet, citation, button label--text-caption12pxtext-[12px]Kind, timestamp, meta--text-micro11pxtext-[11px]Group header, keyboard hint--text-nano10pxtext-[10px]Citation badge, section label, DemoCard labelThe quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
The quick brown fox jumps over the lazy dog
Rule: never use font-light (300) or font-black (900). Product uses 400–700 range only.
fg-1Primary text — titles, body, input valuefg-2Secondary text — descriptions, placeholdersfg-3Tertiary text — timestamps, meta, labelsfg-4Decorative — hints, divider text, annotationsAll computed from --foreground via oklch opacity. Adapts automatically to dark mode. Never use raw foreground/XX — always use text-fg-N classes.
1.0leading-none1.4leading-tight1.5leading-snug1.65leading-[1.65]Server components render on the server and send HTML. Client components hydrate on the client.
Title: text-[14px] font-semibold text-fg-1 (H3)
Body: text-[13px] text-fg-2 leading-[1.65] line-clamp-2
Meta: text-[12px] text-fg-3 / text-fg-4
14. Surfaces
5-level elevation hierarchy + 5 Surface variants. Every container in the product uses one of these combinations.
Page canvas, scroll body. No border, no shadow.
Layout root, brain view scroll areaContent container. Subtle border (--border), no shadow.
Memory detail body (desktop), settings sectionsPrimary cards. Bar-border (12% opacity) + layered shadow.
Memory grid cards, topic cards, bar, floating panelsAbove-page overlays. Glass blur + frosted edge.
Dropdowns, popovers, command paletteFull-screen overlay with backdrop dimming.
MemoryModal, settings dialog, confirmationsRule: never skip elevation levels. Page → Content → Elevated → Floating → Modal.
<Surface variant="default">Memory grid, topic cards, bar<Surface variant="subtle">Memory detail body (desktop), inline containers<Surface variant="flat">Inline sections, nested containers<Surface variant="borderless">Mobile detail page, fullscreen views<Surface variant="clean">Minimal containers, collapsed sectionsh-14 · rounded-2xl · bg-card · bar-border · bar-shadowItem one
Description here
Item two (hover)
Same border/shadow as bar. Separator: border-t border-border/30. Hover: bg-surface-1.
20pxrounded-surface14pxrounded-chrome9999pxrounded-fullRule: two tiers only — rounded-surface (20px) wraps interactive rounded-chrome (14px). Source: packages/ui/src/surface-radius.css. Pick by element size — ≤40px is chrome, >40px is surface.
Glass communicates layer hierarchy: “this floats above that.” Never decorative.
Frosted edge (scroll top/bottom)
backdrop-blur(20px) saturate(1.4) · mask-image dissolveDissolve gradient (infinite canvas)
linear-gradient(to top, var(--card), transparent) · 120px20. Layout
Page widths, 8px spacing grid, responsive breakpoints, z-index hierarchy, and safe area insets. Every layout decision references this.
max-w-[640px]Global input bar. Centered, responsive width shrinks on larger screens.
w-[calc(100%-32px)] sm:w-[calc(100%-96px)] md:w-[calc(100%-128px)]max-w-4xl (896px)Memory grid, topic detail. Outer padding + inner max-width.
px-4 sm:px-8max-w-4xl (896px)Same max-w as list — container morphs, no new surface.
px-5 sm:px-8 md:px-10880pxCentered dialog. Sidebar nav (desktop), tabs (mobile).
sidebar 220px + content px-8max-w-sm (384px)Single card, centered. PWA share sheet capture.
p-54pxgap-1Micro gaps (icon-to-icon, badge internal)8pxgap-2Inline gaps (icon + text, dots)12pxgap-3Tight element spacing (badge group)16pxgap-4Card padding, grid gap, section gaps24pxgap-6Section spacing within a page32pxgap-8Section margin, page top padding48pxgap-12Large section breaks, page bottom64pxgap-16Page-level spacing (rarely used)(default)0px — 639pxSingle column, pill chips, bottom bar, borderless surfaces
sm:640px — 767px2-column grid, text mode toggle, larger type
md:768px — 1023px3+ column grid, sidebar peek, full bar width
lg:1024px — —Pinned sidebar, max content width, settings modal
Rule: mobile-first. Default styles = mobile. Add sm: / md: / lg: for larger screens. Never hide critical functionality behind a breakpoint.
z-toastz-takeoverz-popoverz-modalz-topic-treez-barz-bar-notifz-pageRule: semantic tokens only. NEVER use hardcoded z-50, z-[65], etc. Pick from this list. Invariants: popover > modal (popovers must work inside a Settings dialog) and takeover > popover (full-screen surfaces cover stray popovers). Tokens defined as CSS vars --z-* on :root plus @utility blocks in globals.css.
--safe-topStatus bar inset (iOS notch)--safe-bottomHome indicator (iPhone), bar clearance--safe-leftLandscape notch (left)--safe-rightLandscape notch (right)padding-bottom: calc(var(--safe-bottom) + 16px);Bar uses safe-bottom for iPhone home indicator clearance. Always add safe area to fixed bottom elements.
z-page: content
z-bar: bar
Content scrolls behind bar. Bar is fixed bottom center. No sidebar, no top bar. Bottom padding: safe-bottom + bar height + 16px clearance.
18. Motion Tokens
Named timing constants + easing curves. Every animation references a token — never raw durations. Spring easing is the default.
INSTANT0.1s
UI state changes — intent label swap, right slot toggle, hover feedback
opacity: 0 → 1 on hoverFAST0.15s
Modal open/close, expand slot, view switch, dropdown appear
scale: 0.96 → 1 on mountNORMAL0.2s
Content dissolve, list reorder, layout shift, page transition
translateY: 6px → 0 on enterLOADING0.5s
Breathing dots, loading pulses, skeleton shimmer
opacity: 0.3 → 1 loopSIGNATURE3.5s
Ghost breathing, slow atmospheric loops
breathing ✦ states, idle animationsCSS: var(--duration-*) · JS: import { INSTANT, FAST, NORMAL, LOADING, SIGNATURE }from "@/lib/motion"
0.1s0.15s0.2s0.5s3.5scubic-bezier(0.16, 1, 0.3, 1)Default for all UI transitions. Fast start, gentle settle. Apple-like feel.
Use for: Everything unless specified otherwise
cubic-bezier(0.34, 1.56, 0.64, 1)Slight overshoot then settle. Playful, attention-drawing.
Use for: Toast enter, success indicator, notification badge
Rule: never use linear or ease-in-out. Spring easing is the memax feel. Set it once in Framer Motion transition or CSS transition-timing-function.
Red = forbidden easings. Notice how linear feels robotic and ease-in-out feels sluggish compared to spring.
animate-content-readySkeleton → content transition. Opacity 0→1 + translateY 6px→0.
0.15s— All loading → loaded state changes globallystate-slow-breatheGentle opacity pulse 0.3→1. For AI processing indicators.
2.5s infinite— ✦ star during AI streaming, "organizing..." textstate-fast-pulseQuick opacity pulse. For active processing feedback.
1s infinite— Kind dot during processing, inline loadinganimate-fade-upEntrance: opacity 0→1 + translateY 8px→0.
0.3s— Section entrance, staggered list itemsMemaxLoaderSequential pulse dots in signature color. Brand loading.
per-dot stagger— Full-page cold start, route transitionslow-breathe: AI streaming/processing. fast-pulse: active card processing. static: complete/idle.
Crossfade cycle during AI loading. Source: RecallingText component. verb breathes in signature color.
Never use raw durations — always reference a token (CSS var or JS constant)
Spring easing everywhere — never ease-in-out, never linear
Signature color only breathes — never flashes, never blinks
No animation > bad animation — if unsure, use a simple opacity fade
prefers-reduced-motion — respect system setting, disable non-essential motion
27. Accessibility
WCAG contrast ratios (calculated from real tokens), focus management, reduced motion, touch targets, color blindness. Hard rules, not guidelines.
fg-1/9012.2:112.7:112.9:1PASSPASSUse for all body text, titles, headings, input values. No restrictions.fg-2/655.2:15.3:17.3:1PASSPASSSafe for descriptions, placeholders, secondary labels. Passes AA at all sizes.fg-3/402.5:12.5:13.7:1FAILFAILDECORATIVE ONLY in light mode. Timestamps, meta, kind labels. Never for readable body text. Dark mode passes AA large (3.7:1).fg-4/201.5:11.5:11.9:1FAILFAILNON-TEXT ONLY. Hints, annotations, divider text, decorative labels. Never for any content a user needs to read.WCAG 2.1 AA requirements:
Normal text (<18px): minimum 4.5:1 contrast ratio
Large text (≥18px bold or ≥24px): minimum 3.0:1 contrast ratio
Non-text (icons, borders): minimum 3.0:1 contrast ratio
4.8:1AA large PASS3.3:1AA large PASSSignature passes AA large text (3:1) in both modes. Safe for ✦ indicators (typically 12-20px) and button fills (white text on signature bg). Not safe for small body text in light mode.
fg-1Any size, any weight. No restrictions. (12.2:1+)
fg-2Any size, any weight. Safe everywhere. (5.2:1+)
fg-3Minimum 12px. Use for timestamps, meta, labels — never for content users must read. (2.5:1 light, 3.7:1 dark)
fg-4Decorative only. Annotations, DemoCard labels, divider dots. Never for readable text. (1.5:1+)
Known trade-off: fg-3 fails WCAG AA for normal text in light mode. This is intentional — fg-3 is for supplementary metadata (timestamps, kind labels) where the information is also conveyed by position, grouping, or icon. If the text must be readable standalone, use fg-2.
focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:border-ring3px ring at 50% opacity of --ring. Border also changes to --ring.focus-visible:border-destructive/40 focus-visible:ring-destructive/20Red-tinted ring for destructive actions.focus:border-ring focus:ring-2 focus:ring-ring/502px ring (smaller than button). Border transitions to --ring.Browser default (no custom ring)Relies on native focus indicator. TODO: add visible ring.focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:roundedSubtle 2px ring with rounded corners.Focus rules:
1. Always use focus-visible, never focus — keyboard users only, not mouse clicks
2. Ring color is --ring at 50% opacity — visible but not aggressive
3. Tab order follows visual layout. No custom tabIndex unless absolutely necessary
4. All interactive elements must be reachable via Tab key
Tab through to see focus rings. Ring is always 3px (buttons) or 2px (inputs) with --ring at 50%.
When prefers-reduced-motion: reduce is set, these animations are disabled:
animate-content-ready → instant opacity (no transform)
animate-fade-up → removed
state-fast-pulse → static (no pulse)
state-slow-breathe → static (no breathe)
Framer Motion respects useReducedMotion() automatically. CSS-only animations use the media query in globals.css (line 1016).
What stays:
Color transitions (opacity, background) — these are non-motion
MemaxLoader dots — reduced to static display (no sequential pulse)
Placeholder text crossfade — kept but instant (no blur transition)
44×44pxPrimary actions: send button, mode pills, nav items, toggle switches
36×36pxSecondary actions: icon buttons (icon-lg), close buttons
32×32pxTertiary: icon buttons (default), copy buttons, tag remove
24×24pxMinimum: icon-xs buttons. Only for non-critical, supplementary actions
WCAG 2.5.8: minimum 24×24px target. Apple HIG: 44×44pt for primary actions. Memax follows Apple for primary, WCAG for minimum.
Touch target patterns (pick one per element)
A. min-h-11 — element IS 44px. Use for list items, picker rows, toolbar buttons.
B. relative + after:absolute after:-inset-1.5 after:content-[''] — element visually compact, invisible pseudo extends touch area. Use for floating pills, compact toggles, icon buttons where visual 44px is too large.
C. p-3 (12px all sides) — enough padding that content + padding ≥ 44px. Simple, no pseudo needed.
Rule: choose the pattern that keeps visual density while hitting 44px tappable. Never mix — pick one per component.
Kind dots use 13 distinct hues — not distinguishable by color alone
Every dot is accompanied by a text label (kind name) — color is supplementary
Red (destructive) is always paired with text or icon (Trash2), never color-only
Signature (violet) vs error (red) — sufficiently different hue for deuteranopia
Never convey meaning through color alone — always pair with text, icon, or shape
15. Palette Explorer
Interactive theme preview. Switch presets to compare text hierarchy, surfaces, and accents live.
Purple-blue inspired by dreams. Distinctive, creative, memax's signature feature.
/90Memory card title
/52Last recalled 3 hours ago from CLI
/40core / api-design / 2024-03-15
/25Content hash: 8f3a...
Readability test on card surface
Secondary text should be clearly readable without straining. If you squint to read this, the opacity is too low.
Tertiary text recedes but stays legible — timestamps, kinds, metadata.
Muted text: placeholders, disabled labels, hashes.
| Level | Safe Neutral | Warm Apricot | Cool Slate | Earth Copper | Dream Violet |
|---|---|---|---|---|---|
| Primary | 0.9 | 0.9 | 0.9 | 0.92 | 0.9 |
| Secondary | 0.5 | 0.5 | 0.52 | 0.55 | 0.52 |
| Tertiary | 0.38 | 0.4 | 0.4 | 0.42 | 0.4 |
| Muted | 0.25 | 0.25 | 0.25 | 0.28 | 0.25 |
oklch(0.50 0 0)oklch(0.72 0.14 60)oklch(0.65 0.10 240)oklch(0.62 0.14 45)oklch(0.62 0.16 290)Safe Neutral (pure achromatic)
Dream Violet tint
REST endpoints should use plural nouns. Pagination via cursor, not offset.
We chose River over SQS because Postgres-backed queues simplify our infra. Trade-off: no cross-region fan-out, but we don't need it at current scale.
oklch(0.65 0.18 35)oklch(0.82 0.16 85)oklch(0.72 0.10 155)oklch(0.55 0.08 260)oklch(0.52 0.14 310)oklch(0.58 0.06 50)oklch(0.62 0.22 25)2026 trend: warm-tinted grays (Linear, Notion) + bold single accent (Superhuman 0.18, Vercel 0.21). Kind dots unchanged — data layer, not UI chrome.
19. Controls
Complete interactive control gallery. All variants rendered from production components (ui/button.tsx, ui/badge.tsx, ui/pill.tsx).
<Button variant="default|outline|secondary|ghost|destructive|link">
Source: ui/button.tsx — CVA variants, @base-ui/react primitive
Use data-icon="inline-start|inline-end" for padding adjustment
<Badge variant="default|secondary|outline|destructive|ghost">
Source: ui/badge.tsx — h-5, rounded-4xl, text-xs
One pill to rule them all. Any chip-shaped element in the product — hub switcher, topic selector, memory tag, row metadata label — uses this primitive. Four variants cover every case; two sizes cover every density. If you find yourself writing rounded-full border bg-... inline, you're reinventing this. Stop and use <Pill> or pillClass() instead.
Variants (md size)
select: chevron trailing, click opens popover. remove: × inside, for applied tags. add: dashed border empty state. static: read-only label.
Sizes — pick by the weight of surrounding text
lg: comfortable weight — use when the pill is a primary element sitting next to 14px list or body content (e.g. the top-bar hub switcher whose dropdown items are 14px too). Matching weights keeps the trigger from feeling small next to its own menu. md: default chip — topic selectors, inline content chips, filter chips, role pickers. sm: ultra-dense metadata in memory rows where space is scarce.
Weight match — trigger must feel as substantial as its menu
Rule of thumb: if the pill's own menu uses text-[14px], use size="lg"on the trigger so it doesn't feel 小气 next to its list.
In the wild — these are the four canonical uses
Composing with PopoverTrigger / Link / custom wrappers
import { Pill, pillClass, Popover, PopoverTrigger } from "@memaxlabs/ui";
// Option 1 — <Pill> component (preferred for plain cases)
<Pill variant="select" icon={<Icon />} onClick={open}>Hub name</Pill>
// Option 2 — pillClass() helper (when another element owns the interaction,
// e.g. Base UI PopoverTrigger needs to BE the button itself)
<PopoverTrigger className={pillClass({ variant: "select" })}>
<Icon />
<span>Hub name</span>
<ChevronDown className="size-3.5 shrink-0 text-fg-4" />
</PopoverTrigger>Migration map — legacy call sites to replace
standalone variant → pillClass({ variant: "select" }) on the PopoverTrigger. Keep HubBadge as the icon slot, keep HubRoleTag, drop the hand-rolled border border-border/70 bg-card... and inherit the canonical rounded-lg arc.
filled state → pillClass({ variant: "select" }). Empty state → pillClass({ variant: "add" }). Keep the external × button as-is (separate 44px clear target). Canonical radius is rounded-lg— the crisp 8px arc that matches section 2a's TopicPill reference.
→ <Pill size="sm" variant="static">. Drop the inline text-[10px] bg-surface-1 px-1.5 py-0.5 rounded.
Source: ui/pill.tsx — rounded-lg, bg-surface-1, border-border/60, text-[14px] (lg) / text-[12px] (md) / text-[11px] (sm). Variants: select | remove | add | static. 8px arc matches section 2a's TopicPill reference — crisp, not overly round.
<Pill variant="remove" onRemove={fn}>label</Pill> — Source: ui/pill.tsx. Use the same `Pill` primitive for removable tags instead of a separate tag component. This keeps chip semantics and tokens aligned across the product.
h-8 w-8 rounded-lg. Push: foreground fill. Recall: signature fill. Source: bar-right-portal.tsx
Neutral (Account / Teams / Appearance / everywhere else)
Intelligence (Intelligence tab only — AI behavior)
Neutral: NEUTRAL_INK (var(--fg-1)) track when on, NEUTRAL_TRACK_OFF when off. Intelligence: var(--signature) track when on. Both: same geometry — w-10 h-6 rounded-full track, w-4.5 h-4.5 thumb.
Optimistic: switch moves immediately. Pending: border pulses until server confirms (600ms simulated).
border-2 outer ring. Ring color = NEUTRAL_INK when selected, NEUTRAL_BORDER_OFF otherwise. Inner dot = NEUTRAL_INK. NO background fill on the ring — radios are border+dot, not filled pills. See real impl in SelectionOption (settings-dialog.tsx) and hub-permissions-section.tsx.
Invite as
flex gap-1.5 flex-wrap. Selected: bg-foreground text-background font-medium. Unselected: bg-surface-1 text-fg-2 hover:bg-surface-2. Use when option labels are self-explanatory (role names, plan tiers, filter chips). Canonical impl: hub-invites-section.tsx:204-217. NOT for options that need descriptions — use RadioRow above for those.
h-8, rounded-lg, border-border, focus: border-ring + ring-2 ring-ring/50
Desktop: text "remember · recall" (dot separator). Mobile: pill chips with 44px min touch target. Source: layout.tsx mode toggle
One rule rules them all: purple is reserved for Intelligence, everything else uses neutral ink. This card is the single source of truth — if you're building a new control and wondering “what color does active use?”, the answer is here.
Purple (var(--signature)) = Intelligence tab ONLY. Dreams, merge, archive, organize — AI behavior toggles. Using signature purple signals “this is the AI”. Nowhere else: not in Account, Teams, Appearance, Security, Dev. If a new setting is purple and it isn't AI behavior, it's wrong.
NEUTRAL_INK = everywhere else. var(--fg-1) = foreground at 0.9 opacity = body-text ink weight. One active color unifies every non-Intelligence control: toggle track on, toggle border on, radio ring on, radio dot. Visible, authoritative, not pure black.
Toggles are iOS-solid. When on, the track is filled with the active color (ink or signature), border matches fill, thumb uses NEUTRAL_INK_INVERSE (var(--background)) for contrast. When off, transparent track, subtle border, muted thumb. Geometry: w-10 h-6 rounded-full track, w-4.5 h-4.5 thumb.
Radios are border-2 + dot, never filled. Outer ring uses border-2 (2px), not border — 1px at 0.18 opacity is invisible. Ring color = NEUTRAL_INK when selected, NEUTRAL_BORDER_OFF when not. Inner dot = NEUTRAL_INK. No background fill on the ring — that makes the radio look like a filled pill, which is wrong.
Radio rows vs pills — the decision tree. Radio rows: each option needs a description because the label alone isn't self-explanatory (“Plain / Signature / Time”, “Delete policy: none / own / any”). Show all descriptions at once — no tooltips. Pills: the labels are self-explanatory (Contributor / Viewer / Admin, Free / Pro / Team). No descriptions needed; density matters. Cargo-culting pills onto options that need descriptions is a common mistake — don't.
Token source. Import from @memaxlabs/ui/tokens/controls: NEUTRAL_INK, NEUTRAL_INK_INVERSE, NEUTRAL_TRACK_OFF, NEUTRAL_BORDER_OFF, NEUTRAL_THUMB_OFF. For the Intelligence variant, use var(--signature) directly.
Canonical production references. Toggle: settings-dialog.tsx ToggleRow + teams/shared.tsx ToggleRow. Radio: settings-dialog.tsx SelectionOption + hub-permissions-section.tsx. Pills: hub-invites-section.tsx:204-217. All four match — they are the reference.
Visible to all team members
This action cannot be undone
Pattern: label+description left, action button right. Separator: border-t border-border/20.
Pass brandMark as a slot when the explanation references brand vocabulary (dream, remember, recall, forget) — the ✦ visual ties copy to brand symbol in one teaching moment.
Skip brandMark for utility help (hub management, team settings, API docs).
Hover (desktop): 150 ms open delay, 100 ms close delay
Click / Enter / Space: instant open, click outside to close
Tap (touch): instant open, tap outside to close
Esc: close (auto-handled by @base-ui)
Position: side=bottom, align=start, sideOffset=8, max-w=320px
When to reach for this
✓ 1–3 sentence explanations of what a section does and how it works — section-header inline help.
✓ Brand voice teaching moments (use brandMark prop).
When NOT to use it
✗ Short hover-only labels (keyboard shortcuts, button names) — use Tooltip.
✗ Rich content with lists, buttons, links, long-form help — compose with Popover directly.
✗ Error messages or confirmations — use Dialog or the bar toast.
First consumer: packages/web/src/components/features/topic-card.tsx — "Your Topics" header.
17. Visual Vocabulary
Two symbols only: ● dot (content) and ✦ star (memax intelligence). Behavior modifiers signal state.
Star uses signature color: oklch(0.62 0.16 290)
Star font-size ≈ 1.5× dot diameter for equal visual weight (star glyphs have more whitespace than filled circles).
16. State Machine
Content states + activity indicators. No ambiguous states.
LOADING ──→ LOADED ──→ UPDATING ──→ LOADED
│ │ │
│ ├──→ DELETING ──→ (removed)
│ │
├──→ EMPTY ├──→ ERROR
│ │ │
└──→ ERROR └─────────┘ (retry → LOADING)
Separate track:
LOADED ──→ PROCESSING ──→ LOADED (auto-poll)Reusable liveness indicator for agents, services, connections. Derived from timestamps (last_used, last_seen) — no polling, no websocket. Green pulses to draw attention; others are static.
Usage
A page with N queries has N independent error zones. Collapsing them into a single page-level error throws away intact data from successful queries and makes retry ambiguous (“retry what?”). Every query renders into its own card; each card owns its own error UI via <DataSectionCard>.
❌ Wrong — AND-gate collapse
if (topicsError || memoriesError) {
return <ContentError retry={refetchAll} />
}
// → good topics data is thrown away
// → user sees red page instead of
// 3 loaded sections + 1 failed cardCurrent production: topic-grid.tsx:123-150, topic-detail.tsx (tree error silently swallowed).
✅ Right — per-card error
Rules
· Each query owns its visual region. No global useQueries reduce-to-error.
· Retry handlers target the specific query, never refetch the page.
· Partial success is the norm during hub switch / backend rollout — design for it.
· Error UI uses <ContentError> primitive — red pulsing dot + neutral retry link, no SIGNATURE color (design law 4).
A mutation's isPending is not the same signal as a query's isFetching. Showing both a successMessage toast and an inline visual confirmation is redundant — pick one. Production example: useDreamTriggershows a success toast AND the inbox morphs to a breathing “organizing…” state (use-dreams.ts:37, section 28).
Mutation state → Query state → Visual
───────────────────────────────────────────────
isPending → — → button dim + spinner
(per-row, not global)
(settled) → isFetching → NOTHING (silent refetch,
see section 35c)
isSuccess → data updates → ONE of:
a) row animates in
via state-memory-arrive
(see section 35e)
b) toast
(irreversible / batch only)
isError → data unchanged → surface-specific error
+ retry that names actionRule of thumb: if the surface visibly updates (row vanishes, count decrements, status chip changes, new row animates in), skip the toast. Toasts are for invisible or irreversible actions (forget, undo, batch copy). Mutation inventory in section 28 should flag every mutation that has both.
Activity status rules
active— last_used <24h. Green + animate-ping pulse.
idle — last_used 1-7d. Amber, static.
inactive— >7d or never. Muted fg-4, static.
error — connection failure or expired key. Destructive red, static.
Production: lib/agents.ts (StatusDot in agent-configs-section.tsx). Extend for service health, MCP status, sync state.
8. Loading States
Shape before content. Signature color only when memax is actively working. For composed card-level state machines see section 35 (Data Section Card).
Single signature dot that breathes — scale 0.85→1.0, opacity 0.4→1.0, soft glow halo (2.4s ease-in-out). Replaces the three-dot pulse.
Orb breathes → data arrives → dot flares outward (scale 2.8×, glow burst, 0.5s spring) → fades → content enters. Strong sense of "arriving."
Loading your memory...
MemaxLoader (signature-colored sequential pulse dots) is the brand. Centered on background, not card. Text at /40. Used on brain view during initial load.
Brief transition. Compact loader, no text — user already knows where they're going.
AI answer uses summary callout pattern (bg-surface-1 rounded-xl). Entry: animate-content-ready. ✦ breathes (state-slow-breathe) while streaming, static when complete. Text at /75.
Skeleton mirrors loaded card: dot+title, body, meta. Shimmer uses foreground opacity, not gray — adapts to dark mode.
Recall loading: send button shows spinner (no notification banner). AI synthesis loading: ✦ breathing + RecallingText variant="ai" crossfade below results ("memax is thinking" → "reading your memories" → "connecting the dots"...).
0.15s ease-out, opacity 0→1 + translateY 6px→0. Applied globally to all loading→loaded transitions. Click to replay.
When a mounted data card refetches (stale-while-revalidate, post-mutation invalidation, focus refetch), memax shows no visual signal at all. The rows the user sees are already accurate, and a pulsing indicator would only distract. This is a deliberate rule. For the full composed state machine and the cache pivot / memory arrival cases see section 35.
A mounted card that just got invalidated looks exactly like this. No pulse, no star, no spinner, no dim. Data on screen is already accurate — background re-sync should not interrupt the user. For the case where data IS about to change (hub / filter pivot), see section 35d. For the case where a NEW row arrives via SSE, see section 35e — the row animates in, the card chrome stays still.
Use multi-stage builds to keep production images small. Separate build dependencies from runtime...
Card appears immediately with content. ✦ replaces the neutral indicator during processing. "organizing..." breathes in signature color.
9. Empty States
Multiple tiers: first-time, filtered (centered + inside-card), transient, recall zero, and recall error. Every empty state is surface-specific — no generic fallback copy. For composed card-level empty handling see section 35 (Data Section Card).
No memories yet
Type anything and press Enter to remember
Remember something →No memories in code
No reviews pending
No matching memories found.
Try different words or rephrase your question.
i18n: recall.noResults, recall.noResultsHint. Shown inside bar expand slot when recall returns 0 results. No icon — text only.
Tier 2 above is the centered “No memories in code” variant — that's correct for a full-page filter (like the brain view's kind filter). But most filters live insidea SectionCard (Recent, Inbox, Topic detail) and the empty state must render inside the card's body — the header and filter chip stay visible so the user can clear the narrow filter without losing context.
No memories from Claude in the past 12 hours
Try widening the window or switching actor
i18n: copy MUST echo the active filter values (“in the past 12 hours”, “from Claude”) — not a generic “No results.” Clear-filter action uses the exact filter label so the user knows what they're undoing. Chinese translations must not repeat the card title (anti-pattern: card labeled 最近 showing “最近 12h 没有结果” — “最近” × 2).
The single most common empty-state bug in memax: a component does if (!data.length) return nulland the entire surface disappears. The result: layout jumps, users can't see where the section was, and they can't tell whether content was deleted or is just loading. Every such callsite should render a minimum viable shell instead.
❌ Wrong — return null
(component returns null —
section vanishes from the page)
✅ Right — mounted shell with empty body
Nothing related yet
memax will link similar memories as they arrive.
Same shape as any <DataSectionCard> empty body. Header stays so users know which section it is.
Historical audit note: keep this list current. Mounted detail sections like topic-siblings.tsx and related-memories.tsxshould use section 35's <DataSectionCard>. Headless providers such as dream-notification.tsx and processing-notification.tsx are not card targets.
The single biggest production risk in the audit: bar recall currently has zero error UI (expand-search-results.tsx:269-292) — a network failure falls through to “No matching memories found.” Users think their memories are gone. Recall is memax's core value prop. Fix: use <ContentError compact> inside the bar expand slot — red pulsing dot + specific message + named retry link. No RotateCw icon, no SIGNATURE color — both are reserved for memax AI working, not user retry (design law 4).
i18n (NEW): recall.errorTitle, recall.retry. Must differ from zero-results copy. Retry label is specific (“Retry recall,” not “Try again”). Error state must never fall through to the zero-results branch.
Desktop /home is intentionally zen: just the bar and a single floating badge under it. Not a section card, not a feed, not a summary — one line of status. Three states morph in place with a crossfade when data arrives. This is the only surface in the app that doesn't use DataSectionCard, because it isn't a section.
loading (first fetch)
has memories (returning user)
empty (first-time user)
Type anything to remember it.
memax will organize it into topics as you build.
Position: top: 46vh + 44px, centered horizontally. Badge sits above the bar at roughly the golden-ratio point of the viewport.
Transitions: AnimatePresencekeyed on state string. Crossfade with ~220ms duration, spring easing. No morph between states — they're distinct enough to deserve their own frames.
Why not use DataSectionCard here: desktop /home has no content list, no header, no trailing slot, no filter. It's a bar-centric surface. Forcing it through a section-card primitive would invent chrome that the surface doesn't want.
Mobile /home renders RecentSection instead of this badge — mobile has no /topics tab, so the feed lives on /home. That path goes through DataSectionCard like every other section.
Every file in the web package that ships return null when its data list is empty. Each row should migrate to <DataSectionCard> from section 35 with an appropriate empty body. The anti-pattern is documented in 9f above; this table tracks the cleanup.
| File | Surface | Empty copy suggestion | Priority | Status |
|---|---|---|---|---|
| topic-pills.tsx:27 | Memory detail → topic pills | Not assigned to any topic yet. | medium | pending |
| topic-siblings.tsx:34 | Topic detail → related topics | No sibling topics yet. | low | pending |
| related-memories.tsx:24 | Memory detail → related | memax will link similar memories as they arrive. | medium | pending |
| agent-configs-section.tsx:72 | Settings → agents | No agent configs synced yet. Run memax setup to link your agents. | high | pending |
| hub-management-view.tsx:75 | Settings → hub management | You're the only member so far. Invite teammates to collaborate. | high | pending |
| dream-notification.tsx:18 | Bar notification (dream) | (being deleted — dream flow rewrite) | n/a | delete |
| processing-notification.tsx:42 | Bar notification (processing) | (migrate to bar notification morph — section 10b) | medium | pending |
Migration pattern: replace the top-level if (!data.length) return null with a DataSectionCard wrapper whose phase prop transitions through loading → empty → loaded based on the same data source. The section header always renders; the empty body uses the suggested copy above.
Copy rule:never generic. Each empty copy should tell the user something specific about this surface — what would appear here, what they can do next, or what they're waiting on. “No data” or “Nothing yet” is not enough.
i18n: empty.firstTime.title, empty.firstTime.subtitle, empty.firstTime.cta, empty.filtered.title, empty.filtered.cta, empty.transient, recall.noResults, recall.noResultsHint, recall.errorTitle, recall.retry, brainView.empty.hint, brainView.empty.tagline, brainView.empty.setupHint.
35. Data Section Card
Universal 5-phase state machine for any list-inside-a-card surface. Never unmounts mid-fetch. Composes existing primitives (Skeleton, ContentError) — does not reinvent them. Refetch is silent; content changes animate.
Every data-bound card in memax has exactly these 5 phases. Header chrome stays identical across all of them — only the body changes. isPlaceholderData is a modifier on loaded, not a separate phase. There is no isFetching prop — background refetch is silent (see 35c).
loading (first load)
loaded
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
loaded + isPlaceholderData (body dims)
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
filtered-empty
No memories in the past 12 hours
Try widening the window or switching actor
empty (true — no filter)
Inbox is clear
New captures land here before the next dream cycle
error
Couldn't reach your recent memories
Connection timed out. Your memories are safe.
Recent-style cards do not auto-grow forever. Start with 5 rows, let users reveal 5 more at a time, and always allow the section to collapse back. This keeps the card bounded, preserves page hierarchy, and matches the product rule that data sections stay controllable instead of turning into infinite feeds. The footer only appears when there is more to reveal or when the section is already expanded.
Click a phase to switch the card below. Notice the header stays identical across every phase — only the body morphs. No layout shift.
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
When React Query re-fetches a card in the background (stale-while-revalidate, post-mutation invalidation, focus refetch), memax shows no visual signal. The rows on screen are already accurate — a pulsing indicator would only interrupt the user. This is a deliberate rule, not an omission.
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
React Query will re-fetch this card in the background on window focus, after mutations, on interval, etc. The card does not react visually to any of it — the rows on screen are already accurate.
Reserve motion for moments that change what the user sees. Background sync does not. For the case where data IS about to change (hub / filter pivot) see 35d. For the case where a NEW row arrives (SSE push, optimistic insert) see 35e — the row animates in, the card chrome stays still.
This IS a moment where the user should see something change: the user just switched hubs or filters, and the rows on screen are about to be replaced. React Query's placeholderData: (prev) => prev keeps the previous data visible while the new query resolves, and the card dims its body to 0.72 to signal “outgoing.” Never drops to a skeleton. The dim is the ONLY visual cue — no pulse, no star, no spinner.
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
Data is PLACEHOLDER — previous hub's rows held visible while the new hub's query resolves (placeholderData: (prev) => prev). Body dims to 0.72 signaling “outgoing,” rows swap in place when new data arrives. Never drops to a skeleton. The dim is the ONLY visual cue — no pulse, no star, no spinner.
The other legitimate motion moment. When an agent pushes a new memory (SSE event) or the user just captured something, the new row lands with a soft upward settle and a fleeting signature trace on its left edge. Card chrome does nothing — the motion lives entirely on the new row. This is memax's “memory appearing” signature: quiet, expensive-feeling, and still legible during silent refetch.
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
A new row lands softly with a brief left-edge signature trace and a smoky neutral lift, then settles into a normal row in under a second. The card chrome does not react — no pulsing header, no card dim, no skeleton. Only the new content moves.
This IS a legitimate motion moment: an agent just captured something, the user's surface gained content, memax signals “memax AI produced this” with a restrained signature trace instead of a tinted block (design law 4 — signature = intelligence working), while the rest of the effect stays in quiet neutral grays. The accent disappears quickly so the row becomes indistinguishable from existing rows once the eye registers the arrival.
Production: state-memory-arrive class in globals.css. Applied by the row component when the caller marks the memory as freshly inserted (SSE event handler in memax-event-bridge.tsx sets a flag on cache-inserted rows, row reads the flag, applies class, clears flag after 2.4s).
Left side: patterns we currently ship in production. Right side: the DataSectionCardequivalent. Scan production for these anti-patterns — they're the biggest source of UI state bugs in the audit.
❌ return null on refetch / empty
(component returns null —
section vanishes — layout jumps)
if (!data?.length) return null;
✅ mounted shell, rows stay, refetch is silent
Zero-downtime deploys using blue-green on Fly.io machines, automatic rollback on health check fail.
Rotate refresh tokens on every use with a 30-day expiry window and revoke on suspicious activity.
m=16, ef_construction=64 for 100k+ vectors. Recall ~96% at query ef=40.
Card stays mounted during background refetch. No pulsing ✦, no body dim. Data on screen is accurate — motion would only distract.
❌ generic fallback copy
暂无结果
Reuses t.states.empty.transient — no surface context, no brand voice, no echo of the active filter.
✅ surface-specific + filter echo
No memories in the past 12 hours
Try widening the window or switching actor
Title echoes the filter. Clear action names what will change.
❌ reinvented error visual
Something went wrong
Signature color on a retry button violates Law 4(Dream Violet = AI working, NEVER decorative). Generic “Something went wrong” also violates the no-fallback-copy rule.
✅ ContentError primitive + specific copy
Couldn't reach your recent memories
Connection timed out. Your memories are safe.
Uses <ContentError plain> — red pulsing dot, neutral retry link, specific action label.
The caller is responsible for deriving phasefrom React Query flags (see 35h). The component is dumb — it just renders what it's told. Row-level states (updating / deleting / processing) live in the row component, NOT in the card phase — a card can hold rows in mixed mutation states simultaneously.
Drop this shape into topic-grid.tsx RecentSection. Delete the existing if (!recentPages) return null (L548) and the hand-rolled card wrapper. All state branches flow through the primitive.
// Recent section — production wiring target
const RECENT_PREVIEW_LIMIT = 5;
const {
data, isPending, isError, isPlaceholderData, refetch, fetchNextPage,
hasNextPage, isFetchingNextPage,
} = useRecentMemories({
hubId,
window,
actor,
expanded: visibleCount > RECENT_PREVIEW_LIMIT,
});
const memories = flattenRecentMemories(data);
const visible = memories.slice(0, visibleCount);
const expanded = visibleCount > RECENT_PREVIEW_LIMIT;
const hasMore = visible.length < memories.length || hasNextPage;
const canLoadMore = visible.length < memories.length || hasNextPage;
const nextIncrement = Math.min(5, Math.max(memories.length - visible.length, 5));
const isFilterActive = window !== "7d" || actor !== "all";
const phase: CardPhase =
isPending && !data ? "loading"
: isError && !data ? "error"
: memories.length === 0 && isFilterActive ? "filtered-empty"
: memories.length === 0 ? "empty"
: "loaded";
// Note: we do NOT pass isFetching. Background refetch is silent by
// design — the rows on screen are already accurate (see 35c).
return (
<DataSectionCard
icon={Clock}
label={t.memoryView.freshMemory.other}
trailing={<RecentFilterTrigger window={window} actor={actor} />}
phase={phase}
isPlaceholderData={phase === "loaded" && isPlaceholderData}
emptyCopy={{
title: t.memoryView.recentEmptyTitle,
hint: t.memoryView.recentEmptyHint,
}}
filteredEmptyCopy={{
title: t.memoryView.recentFilteredTitle,
hint: t.memoryView.recentFilteredHint,
clearLabel: t.memoryView.clearFilters,
onClear: resetFilters,
}}
errorCopy={{
title: t.memoryView.recentErrorTitle,
detail: t.memoryView.recentErrorDetail,
retryLabel: t.memoryView.recentErrorRetry,
onRetry: () => refetch(),
}}
>
{visible.map((m, i) => (
<MemoryRow
key={getRecentRenderKey(m.id)}
memory={m}
surface="recent"
showDivider={i > 0}
isNew={recentlyArrived.has(m.id)}
/>
))}
{(hasMore || expanded) && (
<footer className="border-t border-border/30 px-4 py-2.5 flex items-center justify-center gap-4">
{expanded && (
<button onClick={() => setVisibleCount(RECENT_PREVIEW_LIMIT)}>
{t.memoryView.collapseRecent}
</button>
)}
{canLoadMore && (
<button
disabled={isFetchingNextPage}
onClick={async () => {
const target = visibleCount + 5;
if (target > memories.length && hasNextPage && !isFetchingNextPage) {
await fetchNextPage();
}
setVisibleCount((n) => n + 5);
}}
>
{isFetchingNextPage
? t.memoryView.loadingMore
: interpolate(t.memoryView.loadMoreRecent, { n: String(nextIncrement) })}
</button>
)}
</footer>
)}
</DataSectionCard>
);Files that should migrate to DataSectionCard:
- packages/web/src/components/features/topic-grid.tsx — RecentSection (L369+), InboxSection (L810+)
- packages/web/src/components/features/topic-detail.tsx — subtopic groups, ungrouped section
- packages/web/src/components/features/agent-configs-section.tsx — agents list, API keys list
- packages/web/src/components/features/hub-management-view.tsx — members, invites
- packages/web/src/components/features/related-memories.tsx, topic-siblings.tsx, topic-pills.tsx — delete their return null branches
- packages/web/src/components/features/dream-dialog.tsx — review list body
Non-migrations (intentional): the bar (different shell), full-page routes (use <MemaxLoader> at route level), and the memory detail page (single-record surface, not a list).
Also required in globals.css (memory arrival animation):
@keyframes memaxMemoryArrive {
0% { opacity: 0; transform: translateY(-4px);
box-shadow: inset 1.5px 0 0 oklch(from var(--signature) l c h / 0.12),
0 10px 24px color-mix(in oklch, var(--foreground) 6%, transparent); }
18% { opacity: 1; transform: translateY(0);
box-shadow: inset 1.5px 0 0 oklch(from var(--signature) l c h / 0.1),
0 6px 16px color-mix(in oklch, var(--foreground) 4%, transparent); }
60% { box-shadow: inset 1px 0 0 oklch(from var(--signature) l c h / 0.05),
0 2px 8px color-mix(in oklch, var(--foreground) 2%, transparent); }
100% { opacity: 1; transform: translateY(0);
box-shadow: inset 0 0 0 transparent, 0 0 0 transparent; }
}
.state-memory-arrive {
animation: memaxMemoryArrive 820ms var(--ease-spring) both;
}28. Async Action Pattern
Universal mutation lifecycle: Idle → Pending → Success/Error → Idle. MutationCache global handlers with meta-driven feedback. Zero per-callsite wiring.
MutationCache on QueryClient handles ALL mutation feedback globally.
meta.successMessage — static string or (data, vars) => string for dynamic messages.
meta.errorMessage — same signature. Shown for 5s with dismiss.
meta.skipGlobalToast — for mutations with custom UX (push flow with undo).
Bridge: module-level callback ref (mutation-toast.ts) wired by BarProvider on mount.
- Add
meta: { errorMessage: t.toast.* }to every mutation — even if optimistic rollback handles the UI. - Add
successMessageonly for irreversible or batch actions (disconnect, batch delete). - Use
skipGlobalToast: trueonly when the mutation has its own undo-capable feedback. - Callsite callbacks are for UI side-effects only (selection.exit(), navigation) — never for toasts.
- All messages go through i18n (
t.*). Dynamic messages useinterpolate(). useBarToast()is ONLY for non-mutation feedback (clipboard copy, DnD drop). Never use it inside mutation callbacks — MutationCache handles that.
| Hook | Success | Error |
|---|---|---|
| useBatchDelete | dynamic (count) | t.batch.forgetFailed |
| useBatchMoveToTopic | dynamic (count) | t.batch.moveFailed |
| useBatchMoveToHub | dynamic (count) | t.batch.moveFailed |
| useCreateMemory | silent | custom |
| useUpdateMemory | silent | t.toast.updateFailed |
| useDeleteMemory | silent | t.toast.deleteFailed |
| useShareMemory | t.toast.shared | t.toast.shareFailed |
| useDisconnectAgent | t.toast.disconnected | t.toast.disconnectFailed |
| useUpdateAgent | silent | t.toast.updateFailed |
| useDeleteAgentConfig | silent | t.toast.deleteFailed |
| useRevokeApiKey | silent | t.toast.revokeFailed |
| useCreateTopic | silent | t.toast.topicFailed |
| useUpdateTopic | silent | t.toast.topicFailed |
| useDeleteTopic | silent | t.toast.topicFailed |
| useMemoryMove (authoritative user-move path) | t.batch.moved / t.toast.moved / t.toast.movedTo | t.batch.moveFailed / targetNotFound / noWriteAccess |
| useResolveReview | silent | t.toast.reviewFailed |
| useUpdateSettings | silent | t.toast.settingsFailed |
| useDreamTrigger | silent | t.toast.organizeFailed |
26. Composition Recipes
Full-page assembly from tokens → components → layout. Copy the structure, swap the content. Every class is annotated with its source section.
Server components render on the server...
Blue-green with canary releases...
Moving to JWT with refresh rotation...
max-w-4xl · px-5 sm:px-8
pb-36 md:pb-32 (bar clearance)
bar: z-bar, fixed bottom center
Container: max-w-4xl mx-auto px-5 sm:px-8 pb-36 md:pb-32
Group header: text-[10px] text-fg-3 uppercase tracking-wider font-semibold
Memory row: px-4 py-2.5 border-t border-border/30 hover:bg-surface-1
Topic card: Surface variant="default" p-3
Bar: fixed bottom center, z-bar, rounded-2xl, bar-border + bar-shadow
React Server Components
Server components render on the server and send HTML. Client components hydrate on the client. The boundary is the "use client" directive.
borderless — no Surface card
provenance strip: agent + captured + age + recalled
summary: text-fg-1 hero, ✦ signature
raw content: collapsed disclosure, text-fg-2
dissolve into --background
Borderless — no Surface card. All on --background.
Provenance: agent icon + name + captured + project + age + recalled N×
Summary: text-fg-1 hero, ✦ in signature. Raw content: collapsed disclosure, text-fg-2.
Sections: border-t border-border/20 + mt-5 pt-5. All content pl-8.
Dissolve: linear-gradient(to top, var(--background), transparent)
General
Visible to team members
System, Light, or Dark
Nightly consolidation
z-modal: backdrop rgba(0,0,0,0.15)
z-modal: modal 880px centered
sidebar: 220px, border-r border-border/30
rows: divide-y divide-border/20
Modal: 880px centered, z-modal, rounded-2xl, bar-border + bar-shadow
Backdrop: z-modal, rgba(0,0,0,0.15)
Sidebar: 220px, border-r border-border/30
Active tab: bg-surface-2 text-fg-1 font-medium
Rows: divide-y divide-border/20, py-3
Structured code blocks for the most common patterns. Copy directly into new components.
<div className="w-full px-4 py-2.5 hover:bg-surface-1 transition-colors border-t border-border/30">
<div className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: dotColor }} />
<span className="text-[14px] font-medium text-foreground truncate flex-1">{title}</span>
<span className="text-[13px] text-fg-3 tabular-nums">{meta}</span>
</div>
<p className="text-[13px] text-fg-3 truncate mt-0.5 pl-5">{summary}</p>
</div><div>
<div className="flex items-center gap-1.5 mb-3">
<span className="text-[12px]" style={{ color: "var(--signature)" }}>✦</span>
<span className="text-[13px] text-fg-3">memax summary</span>
</div>
<div className="text-[16px] text-fg-1 leading-[1.65]">{summary}</div>
</div><div className="flex items-center justify-between py-3">
<div>
<span className="text-[14px] text-fg-1">{label}</span>
<p className="text-[12px] text-fg-3">{description}</p>
</div>
<Button variant="outline" size="sm">{action}</Button>
</div><div className="pb-36 md:pb-32 animate-content-ready" style={{ paddingTop: CONTENT_TOP }}>
<div className="mx-auto max-w-4xl px-5 sm:px-8">
{/* Page content */}
</div>
</div><span className="text-[10px] text-fg-3 uppercase tracking-wider font-semibold">
{groupLabel}
</span><div className="flex flex-col items-center justify-center text-center" style={{ minHeight: CENTERED_HEIGHT }}>
<p className="text-[16px] text-fg-2 mb-2">{message}</p>
<p className="text-[13px] text-fg-3">{hint}</p>
</div>Page layout
width: max-w-4xl (896px)
padding: px-5 sm:px-8
bottom: pb-36 md:pb-32
top: CONTENT_TOP (80px)
entrance: animate-content-ready
Surfaces
grid card: Surface variant="default"
detail: borderless (no Surface)
section sep: border-t border-border/20
row hover: hover:bg-surface-1
row divider: border-t border-border/30
Typography
H1: text-[21px] font-bold
H2: text-[16px] font-bold
H3: text-[14px] font-semibold
body: text-[14px] text-fg-1
meta: text-[12px] text-fg-3
Motion
easing: var(--ease-spring)
fast: 0.15s (modal, hover)
normal: 0.2s (content, layout)
enter: animate-content-ready
ai: state-slow-breathe
4. Memory Row Variants
Canonical spec for MemoryRow surfaces, attribution rules, and responsive behavior. This is the source of truth for row-level behavior across recent, topic, inbox, list, and recall.
Feed surfaces are attribution-rich. Content surfaces are attribution-light.
| Surface | Indicator | Text | Summary | Meta | Mobile |
|---|---|---|---|---|---|
| recent | avatar, agent icon, or content icon | team: `human pushed` / `human via agent`; personal: `agent captured`; plain personal recent: `You saved` | yes | content type, age, copy | hide the name, keep the verb |
| topic | content icon for explicit actors | none | yes | trailing actor, age | same as desktop |
| inbox | content icon only | none | no | trailing actor, age | same as desktop |
| recall | avatar or agent icon | same attribution rules as recent | yes | hub badge, age | hide the name, keep the verb |
| list | avatar or agent icon | same attribution rules as recent | yes | source, count, age | hide the name, keep the verb |
One scan anchor per row. In a feed or list, the title is the only thing the eye is allowed to latch onto. If the summary renders inline **bold** or `code`, every row sprouts a second anchor that fights the title, and scannability collapses. This is why Gmail, Linear, Notion, Apple Mail, and Readwise all render list previews as flat plain text and save rich markdown for the detail view.
Before → after (same AI summary, rendered two ways)
Inbox positioning decided: desktop places entry in top-right chrome between hub chip and avatar, opening anchored.
✗ current — MarkdownSnippet renders **…** as font-semibold text-fg-2. The bold lead-in is brighter AND heavier than the body, so the row now has three hierarchy levels (title / bold-lead / body). Stack ten of these and the eye has nowhere to rest.
Inbox positioning decided: desktop places entry in top-right chrome between hub chip and avatar, opening anchored.
✓ flat — flat prop strips inline **/*/` markers. The preview is one quiet glance in text-fg-3 regular, and the title is the only anchor. Full markdown rendering is still available in the detail view where emphasis serves a real purpose.
The rule
Row preview → <MarkdownSnippet text={m.summary} maxLen={120}flat />. Always flat. One scan anchor. Gmail / Linear / Notion convention.
Detail view → <MarkdownSnippet text={m.summary}/> (default, not flat). User opened the memory to read it; emphasis now serves a purpose and doesn't compete with anything.
Architecture — two render paths, one data shape
Flat is not a bandaid — it is the correct layering. memax distills memories into rich structured markdown on purpose, because the detail view is where users actually read the memory and structure (bold lead-ins, lists, headings, citations) earns its keep. Stripping that at the distillation layer would make the detail view worse to serve a presentation concern that belongs in the UI.
The right architecture is one data shape, two render paths — same memory.summary field, rendered differently by surface:
Detail view → <AISummary />
Full react-markdown + remarkGfm — bold, italic, lists, headings, inline code, paragraphs, citation badges.
Custom renderers for <p>, <strong>, <a> (citation [N] → inline button).
Source: packages/web/src/components/features/ai-summary.tsx
Structure serves reading — bold lead-ins land, TL;DR patterns land, the user gets what the AI actually wrote.
List preview → <MarkdownSnippet flat />
Strips inline **/*/` and collapses to plain text in text-fg-3, 120-char truncation.
Block-level stripping (headings / lists / code fences) is always on — previews are one-liners.
Source: packages/ui/src/components/markdown-snippet.tsx
Scannability serves the feed — one anchor per row, the title. Reading happens on click.
Do NOT constrain the distillation prompt. Rich markdown in the data shape is an asset, not a bug. Any new surface that renders summaries gets to pick its own layer — flat for feeds and list rows, full <AISummary /> for detail and reading contexts. This is standard Separation of Concerns: data stays rich, presentation adapts per surface.
Industry references
Regression tests — what's locked in
The rules on this card are pinned by packages/web/src/lib/markdown-snippet.test.ts. 25 cases, covering both modes + edge cases. If a future refactor tries to flatten detail-view emphasis or un-flatten row previews, CI fails before the change lands. Highlights:
Run locally: pnpm --filter @memaxlabs/web exec vitest run src/lib/markdown-snippet.test.ts
Team hub
`human pushed` when there is no agent
`human via agent` when a tool captured on their behalf
Example: `Ziyang via [agent icon] Claude Code`
Personal hub
`agent captured` when an agent is present
`You saved` on desktop recent when there is no agent
mobile plain recent rows collapse to compact artifact layout
no attribution text on quieter surfaces when there is no agent
Example: `Claude Code captured` / `You saved`
Recent is attribution-rich. Desktop keeps the actor name and, when a tool captured on their behalf, shows the agent icon inline before the agent name. Mobile drops the name for team and agent rows. Plain personal rows collapse to the compact artifact layout instead of showing `saved` next to a file icon.
Cohere rerank v3 at $1/1000 queries, 50ms p95...
OAuth2 refresh token rotation with 30-day expiry...
Moved chunking from handler to worker, added retry logic...
Zero-downtime deploys using blue-green with Fly.io machines...
Topic rows answer “what is in this topic?” Explicit non-self actors move to the trailing edge, so the left side stays content-led and the title reads first.
Cohere rerank v3 at $1/1000 queries, 50ms p95...
Moved chunking from handler to worker, added retry logic...
Quarterly audit process, control mapping, evidence...
External reference for trace and metric naming.
Inbox stays minimal, but still reserves the trailing slot for explicit actors.
Recall stays attribution-rich because it is cross-context and often cross-hub.
Cohere rerank v3 at $1/1000 queries, 50ms p95...
Moved chunking from handler to worker, added retry logic...
A · Strict identity axis
Team rows use avatars.
Agent rows use branded agent tiles.
Personal plain rows also use the user avatar.
Content type moves to trailing meta.
B · Identity-or-artifact hybrid
Team and agent rows stay identity-first.
Personal plain rows use neutral artifact icons.
No taxonomy color in the row-leading slot.
C · Surface-dependent
Recent behaves like a feed, but plain personal rows stay artifact-led.
Topic behaves like content inspection, so artifact can win.
This is the shipped production direction.
Team, agent, and personal plain all resolve to one identity lane. Content type is pushed into trailing meta.
Landing page image compression cut the first meaningful load.
Keep the speed improvement above the architecture slide.
Do not publish performance data before the embargo clears.
Quarterly audit controls and evidence requirements.
Personal plain notes resolve to a neutral FileText outline. PDF / link / image stay neutral, not kind-colored.
Landing page image compression cut the first meaningful load.
Keep the speed improvement above the architecture slide.
Quarterly audit controls and evidence requirements.
External semantic naming reference for traces and metrics.
Recent uses the chosen hybridized feed rule: team and agent rows are identity-first, plain personal rows keep artifact icons with `You saved`.
Landing page image compression cut the first meaningful load.
Keep the speed improvement above the architecture slide.
Do not publish performance data before the embargo clears.
Quarterly audit controls and evidence requirements.
Topic uses B: hybrid content inspection.
Landing page image compression cut the first meaningful load.
Keep the speed improvement above the architecture slide.
Do not publish performance data before the embargo clears.
Quarterly audit controls and evidence requirements.
Same primitive, four sizes. Decide at a glance before touching production.
sm
md18
md20
lg
Decide at feed length, not at single-row zoom. `md18` and `md20` rendered with the same 12-row dataset.
md18 — denser, less identity presence
Landing page image compression cut the first meaningful load.
Keep the speed improvement above the architecture slide.
Do not publish performance data before the embargo clears.
Quarterly audit controls and evidence requirements.
External semantic naming reference for traces and metrics.
New auth notes were clustered under the topic automatically.
Mention rollback guard in the implementation notes.
Callback validation tightened around tenant boundaries.
Incident timeline screenshot from the handoff deck.
Cohere rerank cost profile versus frequency buckets.
Zero-downtime deploy path with machine warmup.
Coordinate messaging and final legal review timing.
md20 — more identity presence, slightly more vertical cost
Landing page image compression cut the first meaningful load.
Keep the speed improvement above the architecture slide.
Do not publish performance data before the embargo clears.
Quarterly audit controls and evidence requirements.
External semantic naming reference for traces and metrics.
New auth notes were clustered under the topic automatically.
Mention rollback guard in the implementation notes.
Callback validation tightened around tenant boundaries.
Incident timeline screenshot from the handoff deck.
Cohere rerank cost profile versus frequency buckets.
Zero-downtime deploy path with machine warmup.
Coordinate messaging and final legal review timing.
Five-point checklist
1. Personal plain readability
2. Topic scan speed
3. Recall speed
4. Mobile compression
5. Team + agent compound rows
Decision locked so far
Dots stay on topic cards only.
Inbox keeps `sm`.
Processing state overrides identity.
Hero stays `lg`.
1. Topics
Topics page composition. Canonical row rules now live in section 04: Memory Row Variants. This section focuses on how recent, topic, and inbox rows fit together at the page level.
Baseline row anatomy for page-level topic layouts. The full attribution and responsive rules live in section 04. Use this section to validate page composition, spacing, and section hierarchy.
Zero-downtime deploys using blue-green with Fly.io machines...
If kind is fully removed, what anchors the left side? Comparing: title-only, content-type icon, and tags-inline. Agent/team attribution tiers are unaffected (they already don't use kind).
A. Title only — no indicator
Zero-downtime deploys using blue-green with Fly.io machines...
Push to staging branch triggers auto-deploy...
B. Content-type icon — visual anchor, no label
Zero-downtime deploys using blue-green with Fly.io machines...
Push to staging branch triggers auto-deploy...
Neutral dot for text, FileText for PDF, ImageIcon for image, LinkIcon for link. All foreground/20 with no taxonomy color.
C. First tag as context label — replaces subkind
Zero-downtime deploys using blue-green with Fly.io machines...
Uses first tag as context label. More specific than kind but varies wildly. Only works if tags are consistently short.
These examples are retained as visual references, but section 04 is the source of truth for the row system and attribution behavior.
Moved chunking from handler to dedicated worker, added retry logic...
OAuth2 refresh token rotation with 30-day expiry...
Cohere rerank v3 at $1/1000 queries, 50ms p95...
Zero-downtime deploys using blue-green with Fly.io machines...
The clean stacked attribution is the chosen direction for `recent`. Recall stays compact. Topic rows stay icon-only and quiet.
Compact — inline context, hub in meta
Zero-downtime deploys using blue-green with Fly.io machines...
OAuth2 refresh token rotation with 30-day expiry...
Moved chunking from handler to dedicated worker, added retry logic...
Legacy compact comparison only. We keep this here to show why the stacked treatment is better, not as an active recommendation.
Clean — attribution above, verbs, titles aligned
Chosen direction for `recent`: titles stay aligned, team rows use human-first attribution, and personal no-agent rows remain single-line.
Topic rows are intentionally quiet: icon or avatar only, then title and summary. No name, no `via`, no `captured` copy.
Zero-downtime deploys using blue-green with Fly.io machines...
Push to staging branch triggers auto-deploy...
System diagram from Monday standup...
Official guide for multi-region Postgres with read replicas...
Text → neutral dot · PDF → FileText icon · Image → ImageIcon · Link → LinkIcon. Personal indicators stay quiet and non-semantic.
Same memory rendered on each surface. The canonical rule matrix lives in section 04; this page-level demo keeps the topic layout grounded in those rules.
Recent — full meta + copy
Zero-downtime deploys using blue-green with Fly.io machines...
Inbox — minimal, just title + age
Topic — summary focus, no source
Zero-downtime deploys using blue-green with Fly.io machines...
List — source + count + age
Zero-downtime deploys using blue-green with Fly.io machines...
Recall — summary focus, no source/count
Zero-downtime deploys using blue-green with Fly.io machines...
Universal across all surfaces. ✦ breathes with signature color. No summary, no tags, no meta — just title + status.
Zero-downtime deploys using blue-green with Fly.io machines...
Full layout: page header → Recent section (Clock, dropdown time filter, Select) → topic cards grid → inbox (Inbox icon, count). Gap-3 consistent spacing.
Your Topics
147 memories · 6 topics
Zero-downtime deploys using blue-green with Fly.io machines...
Push to staging branch triggers auto-deploy...
Will be organized in the next dream cycle
Team-hub recent uses the same canonical `recent` row behavior from section 04: stacked attribution, human-first wording, and copy action in the row meta.
OAuth2 refresh token rotation with 30-day expiry...
Cohere rerank v3 at $1/1000 queries, 50ms p95...
Moved chunking from handler to dedicated worker, added retry logic...
Problem: current production uses an IntersectionObserver sentinel to auto-load more memories on scroll. Once expanded, Recent fills the entire page and never stops. Users feel trapped in a continuously growing list with no way back.
Principle: infinite scroll is for passive consumption feeds (TikTok, Twitter timeline). Progressive disclosure is for task-oriented lists (Linear issues, Gmail inbox, Slack messages). Recent is task-oriented — users are reviewing what got added, deciding if they need to fix anything, scanning for something specific.
Pattern:show 5 initial rows. Explicit “Show 5 more” button with remaining count. “Collapse” link appears in the section header trailing slot the moment the user expands past the initial 5 — always visible, always one tap away.
Interactive — tap “Show 5 more” to expand, “收起” to reset
Load batch: 5 rows per tap. The constant RECENT_BATCH = 5 can be tuned later but should stay small enough that each tap is a deliberate step, not a lazy expansion.
Initial limit:5 rows. Fresh visit always starts at 5, regardless of previous session state. No localStorage persistence of displayLimit — users don't want to inherit yesterday's expand state.
When remaining ≤ batch size: CTA copy changes to “Show last N · 显示剩余 N 条” so the final tap shows the exact truth.
When all loaded: CTA disappears entirely. Collapse affordance stays in the header as long as displayLimit > 5.
Server pagination:under the hood, cursor pagination still fetches in the background when the requested limit exceeds what's loaded. User sees one coherent “Show more” action regardless.
Same pattern applies to Inbox. Inbox is also a task-oriented list section — use the identical disclosure mechanic. The constants INBOX_INITIAL / INBOX_BATCH can share values with Recent unless product signal says otherwise.
| Surface type | User's job | Pattern | Examples |
|---|---|---|---|
| Passive consumption feed | Keep me entertained | Infinite scroll ✓ | TikTok, Instagram, Twitter timeline, Reddit home |
| Task-oriented list | Find / review / act | Progressive disclosure or pagination ✓ | Linear, GitHub, Gmail, Notion, Slack, Things |
| Long-form content | Read in order | Single page or TOC | Medium, Substack, Wikipedia |
| Dashboard / metrics | Scan at a glance | Fixed viewport, no scroll | Grafana, Datadog, Linear home |
Recent, Inbox, Topic detail memory list, and Recall results are all task-oriented lists → progressive disclosure wins for all four. The current production infinite-scroll fallback in RecentSection is the exception to fix.
Inbox behavior differs by plan. Free users wait for nightly dream cycle. Pro users can trigger organization immediately. During organization, ✦ breathes and rows show progress.
Free — wait for dream cycle
Will be organized in the next dream cycle
Pro — organize now
3 memories waiting — organize now or let the dream cycle handle it
Organizing — dream triggered
Inside a topic. Breadcrumb → header with AI description → subtopic sections → “Other” section for ungrouped memories. No kind tabs (kind deprecated from UI). Rows use surface="topic": summary visible, edge-to-edge (px-4, no wrapper padding).
Deployment
23 memories · 3 subtopics
Blue-green deploys, rollback procedures, staging automation, and CI/CD pipeline configuration for Fly.io.
Zero-downtime deploys using blue-green with Fly.io machines...
Push to staging branch triggers auto-deploy...
Official guide for multi-region Postgres with read replicas...
System diagram from Monday standup...
Leaf topic (no subtopics). Description shown under header. Memories in a single flat card. No kind tabs.
Go Patterns
31 memories
Error handling conventions, context propagation rules, interface design principles, and goroutine lifecycle patterns.
Zero-downtime deploys using blue-green with Fly.io machines...
Push to staging branch triggers auto-deploy...
Official guide for multi-region Postgres with read replicas...
System diagram from Monday standup...
Cards show AI description for leaf topics and subtopic names for parents. No extra taxonomy indicators. Description comes from dream engine. h-full + flex-col pins age to bottom for consistent card height.
Leaf + description
Parent + subtopics
Pinned leaf
Empty (new)
Cards are the right container. Problem: current cards look generic. Fix: AI description always visible, ✦ for dream signal, content preview for scent. Three options below.
A. Content-dense — description + memory preview
Blue-green deploys, rollback procedures, staging automation
Error handling, context propagation, interface design
React Server Components, Tailwind, Radix primitives
B. Compact — icon-led, description, sub-topics as · text
Blue-green deploys, rollback procedures
Staging · Production · Rollback
OAuth2 refresh flow, token rotation
Error handling, context propagation
Connection pooling, migration strategy
RSC, Tailwind, Radix primitives
Components · Styling
Onboarding, code review guide
memax identity signals:
• ✦ + dream-purple = AI organized this, not the user
• Memory preview = content scent (what's IN the topic)
• Description = dream engine output
• No timestamps — recent activity via ✦ preview
Pre-dream (waiting)
Topics appear after your first dream
memax will organize your memories by subject during the nightly dream cycle.
Inbox clean
Your memory is clean
Dreams disabled
Dreams are turned off
Enable dreams to organize your memories.
Unified MemoryRow: One component across all surfaces. Canonical behavior now lives in section 04. This page-level section demonstrates how those row rules compose inside Recent, Topics, and Inbox.
Kind deprecation: Subkind labels and color coding removed from rows and cards. Topics + tags provide better navigation. Kind tabs removed from topic detail.
Topic cards: Leaf topics show AI description (from dream engine). Parent topics show subtopic names with chevrons. No extra taxonomy indicators. h-full + flex-col pins age to bottom for consistent grid height.
Topic detail:Description under header (from dream engine). Subtopic groups + “Other” section for ungrouped memories. No kind tabs. Edge-to-edge rows (px-4, no px-1.5 wrapper).
Hub attribution: Hub name lives in the canonical row rules in section 04. Current chosen split: `recent` uses stacked attribution, `recall` stays compact, `topic` stays icon-only, and `inbox` stays minimal.
Rows: Edge-to-edge within card (px-4). No rounded-lg. Flat dividers (border-t border-border/30). hover:bg-surface-1.
Copy:Per-row copy (markdown) on Recent surface only. Group copy is explicit-intent only — enter Select mode → batch toolbar Copy (context block format). Section headers no longer host a group-level “Copy N for AI” button.
2. Memory Detail & Recall
Borderless reading surface. No card wrapping content — like Notion, Linear, Bear. Title → provenance → AI summary → collapsed raw content → meta footer → topic siblings. Whitespace and faint lines create structure.
React Server Components render on the server and send HTML to the client. Client Components hydrate and become interactive. The boundary is the "use client" directive.
This enables zero-JS for static content while maintaining interactivity where needed. Data fetching happens at the component level with async/await.
Entirely on --background. No Surface card. No border. No rounded container.
Sections separated by border-t border-border/20 + mt-5 pt-5 (whitespace + faint line)
All content pl-8 (aligned past back chevron)
Summary: text-fg-1 hero. Raw content: text-fg-2 collapsed. Tags/kind: bottom.
Met with Sarah about the Q3 roadmap. Key takeaways: focus on performance first, then features. Ship the caching layer before the new dashboard.
No summary → content at text-fg-1 directly. No ✦, no disclosure. No topic → no siblings. recalled 0× → hidden. Cleanest possible page.
Still remembering...
memax is organizing this memory
Processing: ✦ slow-breathe centered on --background. Provenance visible. No Surface card needed.
Skeleton mirrors the borderless layout exactly: back button area → provenance line → content lines → footer tags. All on --background. Uses bg-foreground/[0.07] + animate-pulse with staggered delays. Transitions to loaded via animate-content-ready.
Couldn't load this memory
Check your connection and try again
Error: back button visible (escape route). Centered message + retry on --background. Red dot (destructive), text-fg-2 message, text-fg-3 hint, ghost retry button.
Skeleton → content uses animate-content-ready on the content wrapper. 0.15s opacity 0→1 + translateY 6px→0.
// Pattern:
{isLoading ? (
<DetailSkeleton />
) : (
<div className="animate-content-ready">
// loaded content
</div>
)}
MANDATORY for every loading→loaded swap. No pop-in without animation. Skeleton shapes must match loaded layout exactly.
MemoryStickyHeader component. Fixed z-40, fades in on scroll via IntersectionObserver. mask-image dissolve at bottom. Shows recall count when ≥10 (activity signal in compact form). Extracted as reusable component.
Default
Confirming
Forget left (destructive, requires movement), Keep right (safe, cursor already there). Navigation cancels.
Server Components render on the server. Client Components hydrate on the client. The boundary is "use client".
Mobile: identical design, not "narrower desktop." Back above title. No pl-8 (no back button inline). Provenance wraps. Same borderless surface as desktop now.
0=hidden, 1-9=fg-3, 10-49=fg-2 medium, 50+=fg-1 medium. Typographic weight only, no color.
Borderless. No Surface card. Entire page is --background. Whitespace + faint border-border/20 lines separate sections. Same on desktop and mobile.
Provenance first. After title: WHO captured, FROM WHERE, how often recalled. This is memax, not a note app.
Summary = hero. AI distillation gets text-fg-1. Raw content collapses at text-fg-2.
No summary → no split. Content renders directly. No ✦, no disclosure. Simplest case.
Classification at bottom. Kind + tags + boundary in footer. Top = provenance. Middle = content. Bottom = organization.
Not a dead end. Topic pill → /topics/[id]. Siblings show 2-3 neighbors. Tags filterable.
3. Forget Experience
Container morphs from neutral to glassmorphism destructive tint. No modal — the surface transforms in place. Three states: normal, forgetting, forgotten.
Normal
Forgetting
Forgotten
Card — Normal
React Server Components render on the server and stream HTML to the client.
Card — Forgetting
React Server Components render on the server and stream HTML to the client.
Card — Forgotten
React Server Components render on the server and stream HTML to the client.
25. Batch Operations
Indicator-slot morph + glass toolbar. Entry via “Select” in section header trailing slot. Every action is end-to-end.
Each section header has a trailing slot (right side, after spacer). “Select” lives there as subtle text. Tapping it enters selection mode — indicators morph to rings, toolbar appears. “Done” exits. Same pattern on every surface. In production, the current row indicator stays in the same slot and morphs there; it should never hard-swap to a disconnected checkbox.
Alternative entries: long-press (mobile, 500ms), shift+click (desktop range select). Both auto-enter selection mode.
The h-3 w-3 indicator container morphs with spring easing. No checkbox slides in. The row's own indicator slot becomes the selection affordance, then returns to its native icon on exit.
End-to-end: “Select” enters mode → indicators morph to rings → tap rows to select → checks appear → “Done” exits back to the native row indicator.
“Move” opens a glass picker above the toolbar. Topics with emoji icons. Tap topic → memories move → rows animate out → toast “3 → Deployment” → selection clears.
Move to
Picker anchored to Move button, opens upward. Click outside or Esc closes. Same glass treatment as bar expand slot.
Same toolbar container. Actions collapse, destructive confirmation appears. Glassmorphism destructive treatment (kitchen 03). Forget LEFT, Keep RIGHT (cursor safety — cursor was on Forget, Keep appears where cursor is).
Before
After “Forget” tap
Long-press (500ms, haptic feedback) enters selection on mobile. “Select” text still available in header. Compact toolbar: icons only, same glass treatment. Fixed above safe area.
20 unassigned memories. Select by theme, batch move to topics. Primary batch use case.
Starting new project. Select all auth memories, copy as <memax-context> for agent.
After dreams merge duplicates. Forget stale memories the dream engine didn't catch.
Leaving project. Export topic memories as .md file for handoff.
Every batch action follows the same state machine. No dead states, no silent failures.
Normal rows. Native row indicators. No selection UI visible.
"Select" → "Done" in header. Indicators morph to rings. Click toggles.
≥1 selected. Glass toolbar at bottom: count + Move/Copy/Export/Forget.
Toolbar morphs in place: "Forget N memories? [Forget] [Keep]".
Toolbar shows spinner + count. Actions disabled. No user interaction.
Selection exits. Bar toast: "3 memories forgotten" / "3 memories moved" / "Copied".
Selection stays. Bar toast (error): "Failed to forget/move". User can retry.
Pending state (toolbar morphs to spinner)
Success toast (bar notification)
Architecture (for LLM agents)
SelectionProvider— section-scoped (not layout-level). Each section wraps its own provider from the parent (TopicGrid, TopicDetailPage). useSelection() inside the section reads the correct context. Esc exits globally.
BatchToolbar— portals to document.body via createPortal (escapes overflow-hidden + transform ancestors). z-60 (above bar z-50). Signals GlobalBar to hide via batch-active.ts bridge. Slides up (y 24→0, opacity, FAST spring). Uses bar tokens (--bar-bg, --bar-border).
batch-active.ts— module-level signal (same pattern as mutation-toast.ts). BatchToolbar signals active/inactive on mount/unmount. GlobalBar listens: bar slides down + fades (marginTop 0→24, avoids transform conflict with translateX(-50%)). Bar/toolbar share same bottom position — container morphing with animated swap.
MemoryRow— indicator slot morphs native indicator → ring → check → native indicator (spring easing, same slot). Click: selectionActive ? toggle : navigate. Long-press 500ms on mobile enters selection.
FilterDropdown— portalled to body (escapes card overflow-hidden). Positioned via anchorRef getBoundingClientRect.
Toasts— via MutationCache meta (universal async action pattern, kitchen 28). No per-callsite toast wiring. Callsite callbacks for UI side-effects only (selection.exit()).
Server: POST /v1/memories/batch-delete (single SQL), POST /v1/memories/batch-move (topic or hub). Atomic — zero partial state.
5. Loading Text
Time-progressive loading with animated star indicators. Each mode has a unique visual identity: sonar rings (recall), pulse dots (ask), settling glow (remember).
38. Bar — North Star (alt)
Second take on the bar redesign. 24m glass · recall default · AI slot always reserved · inline Remember feedback · hub badge · mobile mirror · slash minimal. Sibling to section 37 — read 38q for deltas.
Production moved to a cleaner engineering model after the initial port: one derived interaction state in bar-context.tsx, one explicit mobile compose state (docked | mirror | fullscreen), and thinner render portals that follow that state instead of re-deriving behavior locally.
- Plain / opens command discovery only. No chip yet.
- A command chip appears only after explicit commit: exact /topic or /hub, tapping a command row, or pressing ↵ on a highlighted command.
- Committed command + empty query shows the full target list. Typing filters that list. Backspace on empty query degrades the chip back to raw slash text.
- Staged files force remember mode and suppress recall / AI surfaces on both desktop and mobile.
- Remember feedback is owned inline by RememberRow. Global bar notifications are ambient-only, not remember lifecycle.
- Mobile keeps one persistent bottom thumb bar. Mirror/results fade in above it. Fullscreen compose is the higher tier, not a second bottom bar or a desktop-style expand card.
This take optimizes for continuity: the user should see the same surface evolve through every phase, not watch zones appear and disappear. The bar is memax’s one command palette — glass, downward, recall-default, layout-reserved AI, inline save.
- 24m glass chrome. oklch(card / 0.92) · blur(24) saturate(140) · layered shadow · 1px border /0.5. The whole bar feels lifted off the page.
- Recall is default. Nothing on the left in zone ① — no icon, no capsule. The placeholder (dump or ask…) is the affordance. A scope pill appears only for topic / command scope. Same lead-with-nothing pattern as Spotlight, ChatGPT, Claude, Notion, Linear ⌘K.
- Downward cascade. [1] at top, [N] at bottom. Natural ranked-list order.
- AI slot always reserved. Zone ② is present from the first keystroke (dim placeholder), through thinking / streaming / complete. Free users see an upgrade stub in the same slot — layout never jumps.
- Remember row is inline + transient. Zone ③ is always visible and hosts inline feedback for the save itself. Idle CTA reads Dump to memax (matches the placeholder voice). Transients use the action verb: Sending… → Sent · Undo → idle, or Couldn’t send · Retry. Two voices on purpose — dump invites, send commits. Remember-specific notification banners are deprecated; passive notifications remain for ambient events only.
- Hub badge on Remember row. Multi-hub + team hub → →{hub} badge persists through every state. Single hub / personal: no badge.
- Stay put, rise on engage. Bar REST position is route-aware: 42vh on /home, docked at 32px-from-bottom on /memories and overlay routes, above-dock on mobile. Real interaction lifts it to top 24vh with a 0.45s spring. Result surface caps at max-h-[60vh]+ “+ N more” in zone ④.
- Mobile is a mirror. Notion pattern: bar stays above keyboard (thumb zone), mirror input at top of screen (eye zone), results between. Edge-to-edge, no card. A 📷 camera icon in zone ① opens the native photo / file picker when the input is empty + no files staged. Three exits: X, swipe-down, tap scrim.
- Slash is minimal + route-aware. Three commands — /topic, /hub, /forget. Plain / is discovery only; partial prefixes like /h rank the closest command but do not commit the chip yet. /forget remains route-scoped: only appears in the command list when the user is on /memories/{id} and forgets thatmounted memory — never an abstract “highlighted row.” Hidden everywhere else.
- Real glyphs, 24m hint bar. ↵ ⇧ ⌘ ⌥ esc ↑ ↓ — literal Unicode in <kbd>. Hint bar is 24m style: plain text + single-glyph chips, not pill chains.
Deprecated patterns — do not reintroduce
Two old intent-routing mechanisms are gone and must not come back in the production port:
- packages/web/src/lib/detect-intent.ts — the EN/ZH regex sniffer (question words, 什么/怎么/为什么/吗/呢). Replaced by the content-shape rule: newline or files ⇒ dump, else ⇒ recall. Delete the file on port.
- ?prefix recall trigger (kitchen 24 proposal) — never shipped to production, and won’t. The user asks in natural language; the bar infers action from content shape, not magic characters.
- Mode capsule tap-to-toggle (kitchen 31 ModeExpandedCard). Already covered by §2 above, but worth repeating: the capsule in 38 is a read-only scope indicator, not a mode switch.
- ✦as a send-button CTA icon (kitchen 38 pre 2026-04-13). Conflated the “AI is working” signal with an action affordance and violated the design-system rule signature = intelligence, never decorative. The send button is now unified ↑ (color communicates mode), matching ChatGPT / Claude / Perplexity. ✦ stays sacred to zone ②.
Open question — dream notifications
Deleting BarNotificationCard also deletes today’s dream event surface (dream · dreaming · dream_complete). Three options to decide before porting:
- Dock badge on mobile + header chip on desktop (ambient).
- Reuse MemaxStatusStrip (already exists for section-level progress).
- Toast top-center — deviates from “no overlay when the surface can morph” but is the simplest port.
Flag for product decision. Dream is memax’s differentiator — the surface matters.
Glass container, five zones stacked top-to-bottom. Zones ②③ can be empty but never reorder. Zone ⑤ (hint bar) only appears when the result surface is open and on desktop.
anatomy reference — not a real state
The bar’s resting position is unchanged from production: 42vh centered on /home, 32px-from-bottom on /memories and overlay routes, above-dock on mobile. Same fade-in / fade-out animation as today. What changes is the engaged position — once real interaction exists, the bar animates to top 24vh (center-upper) with the existing 0.45s spring. Result surface caps at max-h-[60vh] with a “+ N more” affordance at the bottom of zone ④. On esc the bar returns to its rest position.
dump or ask…
rest · top 42vh · /home (hero moment, unchanged)
auth architecture
Auth Middleware
AuthJWT + API key dual path…
Security Rules
SecurityOwner isolation…
engaged · top 24vh · max-h-[60vh] · + Load more
First keystroke opens the result surface. Zone ② holds a dim AI placeholder (same for Pro/free — layout doesn’t jump). Zone ③ is the explicit save affordance. Zone ④ live-updates as keyword + FTS results arrive. No citation numbers yet.
ask memax or / for commands
engaged · empty · focused (no query)
auth architecture
Auth Middleware Design
AuthenticationJWT + API key dual path, agent_name from key…
Security Rules
Security·memax-teamOwner isolation at data layer, every query filters by owner_id…
MCP OAuth JWT Fix
AuthenticationAdded AgentName claim to JWT, MCP OAuth tokens carry identity…
typing · t = 0–200ms · keyword + FTS
Zone ① morphs when the user pastes or writes multiline text. CSS Grid two templates, instant switch, only gap animates (0.2s spring). Auto-grow via useLayoutEffect + scrollHeight, bounded at 200px before a thin scrollbar appears.
Our auth uses JWT + API key dual path
collapsed · single-line · [scope input send]
Meeting notes — bar redesign review: - 24m glass adopted, search icon default - Remember row replaces notification card - Mobile = Notion mirror
expanded · multiline · [input full] / [scope . send]
how do we handle JWT refresh when the token is close to expiry and the user is offline
Auth Middleware Design
AuthenticationRefresh rotation, offline grace window…
expanded + result surface visible
Compose on mobile is a two-tier experience. Short content stays in the thumb-zone bar (Tier 1, in-place growth). Long content escapes to a full-screen takeover (Tier 2, Apple Notes pattern) where the only thing on screen is the textarea, a back chevron, and a save button.
Tier 1 — bar textarea grows vertically from one line up to ~3 lines (~150px). The mirror at the top echoes the multi-line content in sync — they stay visually consistent. Source list shrinks to give the bar room but stays scrollable. Auto-grow via useLayoutEffect + scrollHeight, same JS pattern as desktop.
Tier 2 — full-screen compose takeover. Triggered automatically when the textarea would exceed 3 lines, OR manually by tapping the expand icon that appears next to the send button as soon as there’s content. In Tier 2 the mirror, source list, and remember row all hide — the bar is the screen. Back chevron (top-left) returns to the bar in Tier 1 state, preserving the content. Save button (top-right) commits and returns to the resting dock.
Inspiration: Apple Notes, Gmail compose, Notion’s mobile block editor. The user is either browsing (Tier 0 resting dock / Tier 1 recall-then-type) OR writing (Tier 2 fullscreen). The two modes feel distinct.
Send button — always ↑. Color says what fires.
The icon is always ↑ — same as ChatGPT, Claude, Perplexity, Gemini, Arc. Universal 2026 chat-input convention, zero learning curve. The background color tells the user what tapping will fire. One rule, four cases:
- ↑ empty → disabled, ghost outline
- ↑ single-line, no files → signature violet fill → tap fires recall + AI
- ↑ text contains \n (Return pressed) → dark foreground fill → tap fires dump
- ↑ files staged → dark foreground fill → tap fires dump (files lock the bar to remember)
Why explicit \nand not visual wrap: a long single line that just wraps isn’t multi-paragraph — it’s still a recall query. Only a deliberate Return signals composition. Robust and predictable.
Why unified ↑ instead of ✦ for recall: ✦is the memax design system’s “intelligence is working” glyph — reserved for zone ②’s AI thinking state. Using it as a CTA icon conflates two meanings and violates the “signature = intelligence, never decorative” rule. Unified ↑ lets ✦ stay sacred to the synthesis slot.
Desktop override: ⌘↵forces dump from any state (edge case: short single-line note worth saving). No recall-forcer — if you’re composing and want to search, delete the newlines. Edge case, acceptable friction. The Remember row in zone ③ never disappears, so users always see a second dump affordance.
dumping
Meeting notes from product review
bar redesign approved
mobile tier 2 takeover
Meeting notes from product review bar redesign approved mobile tier 2 takeover
tier 1 · bar grows in place, mirror syncs
Meeting notes from product review: - Bar redesign kitchen 38 approved - Mobile tier 2 = fullscreen takeover - Apple Notes pattern, back + save in top bar - Mirror + source list hidden in tier 2 - Auto-trigger at 4+ lines, manual via expand Follow-ups: - wire production ComposeInputRow - handle discard guard on back
tier 2 · fullscreen takeover, Apple Notes pattern
Transition rules:
- resting dock → tap bar → Tier 1 compose (single-line, mirror echoes query)
- Tier 1 → user keeps typing → textarea grows 2 → 3 lines, mirror grows with it
- Tier 1 → tap OR textarea reaches 4 lines → Tier 2 fullscreen
- Tier 2 → tap back → return to Tier 1 (content preserved)
- Tier 2 → tap Save → Remember flow fires → return to resting dock
- Tier 2 → tap esc (hardware keyboard if attached) → cancel (discard guard)
- In Tier 2, drag-drop doesn’t apply (mobile). Photo attachment via the icon in the toolbar above the keyboard.
Web drag-drop is orthogonal to compose state — the dashed signature chrome (see 38f) fires from the container regardless of whether the textarea is collapsed or expanded. Dropping files onto a compose-expanded bar stages chips in the usual place above the input row and locks the bar to remember. One less thing to worry about on desktop.
The full mobile journey as a filmstrip. This card exists because 38n + 38e2 show the component-level details but leave the end-to-end flow implicit. If you’re porting the mobile branch and piecing states together from two cards, read THIS card first. It is the single source of truth for the mobile state machine.
Production wiring: [(app)/layout.tsx](../../packages/web/src/app/(app)/layout.tsx) already branches on useIsMobile(). The current north-star branch is cleaner than the earliest port: mobile uses one explicit compose state (docked | mirror | fullscreen) and keeps one persistent thumb-zone bar at the bottom. Scrim + mirror + results render above it in mirror mode; fullscreen is the higher tier. Portal slots (#bar-input-slot, #bar-right-slot, #bar-logo-slot) still work — they just target the shared mobile row instead of a separate desktop card.
memax · /home
state 0 · resting dock
above dock, single-line, no scrim
asking
what do I know about auth architecture
state 2 · mirror compose
tap bar → scrim + mirror slide down
dumping
Meeting notes
bar redesign approved
mobile tier 2 takeover
Meeting notes bar redesign approved mobile tier 2 takeover
state 3 · mirror grow
multi-line write still keeps one persistent bottom bar
Meeting notes from product review: - Kitchen 38 approved - Mobile tier 2 fullscreen - Back preserves content Follow-ups: - wire production bar-context
state 4 · tier 2 fullscreen
explicit higher writing tier → Apple Notes takeover
Memax uses a dual auth architecture: API keys carry agent_name for CLI/MCP, OAuth JWTs embed it as a claim.
state 5 · post-↑ result
AI streams into mirror (recall)
asking
what do I know about auth architecture
state 6 · exit paths
X · swipe-down · tap scrim → state 0
Note: state 1 (compose entry transition) is not mocked as its own frame — it’s the animation between state 0 and state 2. See the transition table below for timing and mechanics.
| From | To | Trigger | Duration | What animates |
|---|---|---|---|---|
| dock | mirror | tap bar / focus | 0.35s spring | scrim fade-in · mirror slide down from top · dock stays mounted as the single editable bar · keyboard opens (native) |
| mirror | tier1 | newline / multi-line growth | instant template switch · gap 0.2s | same bottom bar grows in place · mirror tracks content · source list shrinks · send color flips violet → dark |
| tier1 | tier2 | explicit expand / compose takeover | 0.3s spring | mirror collapse to 0 · source list hide · remember row hide · top bar slide-in · textarea expand to 60vh |
| tier2 | tier1 | tap back | 0.3s spring | reverse of above · content preserved (no discard) |
| mirror | result | tap send (↑ violet) | 0.2s FAST | mirror content swaps from query echo → thinking → streaming AI · send becomes spinner · source list citations hydrate |
| tier1/2 | dock | tap send (↑ dark) OR Save | optimistic instant · 0.35s exit | fireRemember fires · scrim fade-out · mirror slide up · dock remains the same bar · Sent transient stays inline |
| any | dock | X button (layered) | 0.35s spring | peels back one layer per tap (edit → revert → clear → exit) |
| any | dock | swipe-down on source list | follows finger · 0.25s settle | iOS modal dismiss · content preserved · rubber-band if pulled past threshold |
| any | dock | tap scrim above mirror | 0.35s spring | reverse of entry · content preserved · scrim fade + mirror slide + dock fade |
Production wiring (what codex actually touches)
- useIsMobile() already exists. bar-context.tsx reads it and exposes isMobile on the context. The mobile branch of (app)/layout.tsx now also owns a single mobile compose enum state instead of competing booleans.
- Scrim — new. Mount a fixed-position overlay div when mobile enters mirror or fullscreen compose. Style: bg: oklch(from var(--background) l c h / 0.88); backdrop-filter: blur(8px); z-index: 40. Click handler on the scrim = tap-scrim exit (State 6).
- Mirror — new. Mount above the scrim using MOBILE_OVERLAY_CONTENT_TOP(defined in lib/layout.ts). Bind to the same valuefrom bar context. It’s a read-only <p>, NOT a second textarea. Line count echoes the real textarea below so they grow in sync during Tier 1.
- Tier 2 takeover — new. Mount a full-screen absolute layer when isMobile && tier === 2. Contains its own top bar, textarea, and bottom toolbar. The existing bar portal content is hidden (not unmounted — state preservation).
- Resting bar and dock — already exist. Don’t touch mobile-dock.tsx(kitchen 30). It’s orthogonal to the bar lifecycle.
- Keyboard avoidance — the viewport already has interactiveWidget: resizes-content set in app/layout.tsx. When keyboard opens, the viewport shrinks and the bar stays anchored to the bottom automatically. No manual offset math needed.
- Exit handler precedence — three paths (X tap, swipe-down, scrim tap) all route through the same exitCompose()function in bar-context. Don’t duplicate the logic in each handler — one source of truth.
State machine — the 7 names you need to know
- state 0 · dock — resting, single-line, above mobile dock
- state 1 · entry — 0.35s transition (not a standing state)
- state 2 · mirror — active compose, single-line, mirror echoes query
- state 3 · mirror grow — same bottom bar + mirror growing in sync, multi-line
- state 4 · tier2 — fullscreen takeover, Apple Notes
- state 5 · result — post-send, AI streaming (recall) OR sent transient (dump)
- state 6 · exit — 0.35s transition back to state 0
Explicit reducers + derived surface ownership. This is the actual source of truth for state flow.
The bar now has two reducers plus one derived surface model: a UI shell reducer (bar-machine), a domain reducer (bar-domain), and a pure surface derivation layer (bar-surface). Layout positions from the surface model; visual components read that model instead of inventing their own booleans.
BarMachine (UI shell)
state: value scope: recall | topic | command phase: input | loading | recall-result barFocused barOverlayOpen mobileComposeState: docked | mirror | fullscreen activeNavigationIndex isBarExpanded detectedUrl memorySearch selectedMemoryId isDragging peelBack order: restore-query reset-phase reset-remember clear-value close-mobile-compose collapse-expanded clear-scope hide-overlay blur
BarDomain (async + feedback)
state: recallQuery askAnswer aiLoading / aiStreaming rememberState / rememberedTitle stagedFiles barNotification / dreamNotification / statusNotification selectedMemory pushing resets: resetRecall resetRemember resetDomain
BarSurface (derived presentation)
derived:
visibilityState:
hidden | rest | engaged
expandSurfaceKind:
hidden
command
remember
recall-input
recall-loading
recall-result
rules:
layout positions only
child renderers read explicit surface kinds
remember is first-class; never inferred from raw text
recall UI never survives as a stale side effect
command / remember / recall are mutually ordered surface ownersFiles stage as chips above the input. Drag-over flips the chrome to a dashed signature border. URL paste auto-detects and offers capture. Eager upload (ChatGPT pattern) — files start uploading on drop. When files are staged the bar locks to remember (send = ↑).
Auth flow diagram
single image pasted
Design review materials
batch · 3 files · one still uploading
dump or ask…
URL pasted · Capture page offered
Drop files here…
drag active · dashed signature border
On ↵: query locks, send spins, semantic recall + AI synthesis fire in parallel. Zone ② flips placeholder → thinking → streaming → complete. Citations in zone ④ — [1] at top.
auth architecture
Agent Identity Architecture
Authentication98%Auth = identity. API key → agent_name, OAuth JWT → claim…
MCP OAuth JWT Fix
Authentication·memax-team91%Added AgentName claim to JWT…
Security Rules
Security74%Owner isolation at data layer…
searching · results hydrated, AI thinking
auth architecture
Agent Identity Architecture
Authentication98%Auth = identity. API key → agent_name, OAuth JWT → claim…
MCP OAuth JWT Fix
Authentication·memax-team91%Added AgentName claim to JWT…
Security Rules
Security74%Owner isolation at data layer…
streaming · tokens appending live
auth architecture
Agent Identity Architecture
Authentication98%Auth = identity. API key → agent_name, OAuth JWT → claim…
MCP OAuth JWT Fix
Authentication·memax-team91%Added AgentName claim to JWT…
Security Rules
Security74%Owner isolation at data layer…
complete · ✦ static · copy toolbar · query preserved
Layout identical. Only zone ② content differs: Pro streams the answer, free shows a dim upgrade stub in the exact same slot. Zones ③④ sit at the same vertical position on both.
deployment
Deployment Process
Infrastructure95%Fly.io two-process deploy…
CI/CD Pipeline
Infrastructure78%GitHub Actions workflow…
free · upgrade stub in zone ②
deployment
Deployment Process
Infrastructure95%Fly.io two-process deploy…
CI/CD Pipeline
Infrastructure78%GitHub Actions workflow…
pro · streamed answer in zone ②
↑↓ move across zones ③ and ④. ↵ opens a highlighted source row, or remembers if the Remember row is highlighted. Highlight tint = bg-surface-1, no border, no ring.
auth architecture
Auth Middleware Design
AuthenticationJWT + API key dual path…
Security Rules
SecurityOwner isolation…
remember row highlighted · ↵ = remember
auth architecture
Agent Identity Architecture
Authentication98%Auth = identity. API key → agent_name…
MCP OAuth JWT Fix
Authentication91%Added AgentName claim to JWT…
source row highlighted · ↵ = open · ⌘⌫ = forget
Zone ③ morphs through its transient states in place. No separate notification card, no push-down of the bar, no flicker. Hub badge persists through every state so the user always knows where the memory is going. Undo window = 4s; auto-dismiss after that reverts the row to idle.
Transition table (4s total, success path):
- idle → ⌘↵ or click → sending (value cleared optimistically)
- sending → API accepts → sent (✓ + Undo, 4s window)
- sent + 4s → idle (row returns to CTA for next query)
- sending → API fails → error (content restored, 5s auto-dismiss, retry available)
- Undo → row dismisses, created memory deleted, content restored
Typing / as the first character opens command discovery. Plain slash stays plain slash; no chip yet. After explicit command commit, zone ④ becomes the target list for that command and empty query shows the full set before the user narrows it.
- /topic {name} — scope recall to a topic. Always available.
- /hub {name} — switch active workspace. Always available.
- /forget remains deferred until the forget lifecycle is fully reworked inside the bar. It stays a route-scoped future command, not a half-wired shipped action.
Dropped: /import (drag-drop already imports), /share (no share, only Copy / Copy-for-AI), /remember (⌘↵ covers it), /settings (gear icon covers it).
/
/ · on /home · command discovery only
/
future reference · route-scoped /forget stays deferred
/topic pkce
/topic pkce · fuzzy-filtered topic jump
On /memories/topics/{id} the bar picks up an implicit topic scope. Scope indicator flips from a Search icon to a labeled pill. Queries bias to the active topic. Absorbs kitchen 24m — the bar IS the picker.
pkce
PKCE flow — native apps
Security › OAuthProof Key for Code Exchange, required for mobile OAuth…
OAuth flows reference
SecurityAuthorization code, client credentials, device flow…
inside a topic · bar scoped to Engineering
Recall errors render inline in zone ④. AI errors render inline in zone ② — sources below still show. No results = empty state pointing at the Remember row.
zimbabwe onboarding
No memories match this query yet.
Save it above to start remembering.
no results · Remember row is the primary affordance
auth architecture
Recall failed.
Network error — your query is still here.
recall failed · inline error · retry available
auth architecture
Agent Identity Architecture
Authentication98%Auth = identity…
Security Rules
Security74%Owner isolation at data layer…
ai failed · sources still usable · quiet fallback
Notion mobile pattern. Bar stays above the keyboard as the thumb zone. On focus, a mirror input slides down from the top of the screen (eye zone) — the user sees what they’re typing in large type while their thumbs stay comfortable. Result surface sits between them, scrollable. Edge-to-edge, no card chrome. One persistent bottom bar only — the top mirror is read-only context, not a second editable bar.
Photo capture — a 📷 icon lives in zone ① between the textarea and the send button. It appears whenever the input is empty AND no files are staged (matching production bar-right-portal behavior). Tapping opens the native iOS / Android picker → photo library, files, or live camera. Selected media stages as a chip and the bar locks to dump (recall hidden, send becomes ↑). Same eager-upload flow as desktop drag-drop.
Three exits:
- X button in the right-side action cluster of zone ① — layered (peels back one state), same role as desktop esc
- Swipe down on the source list when scrolled to top — native iOS modal dismiss gesture
- Tap the scrim above the mirror — returns to resting dock, query preserved
asking
what do I know about auth architecture
compose · mirror echoes query (top = eye zone)
Memax uses a dual auth architecture [1]: API keys carry agent_name for CLI/MCP, while OAuth JWTs embed it as a claim [2]. Owner isolation [3] at the data layer.
compose · AI streams into eye zone
asking
what do I know about auth architecture
exit · X · swipe-down · tap scrim
24m pattern: thin row, plain text + single-glyph ↵ chips, never chains of boxy pills. Left primary, right close. Desktop only — mobile has no keyboard hints.
deploy
typing
deploy
row highlighted
/
command mode
| State | Zone ② | Zone ③ | Zone ④ | Transition |
|---|---|---|---|---|
| rest | — | — | — | → engaged on focus/keystroke |
| engaged (empty) | — | — | — | → typing on first char |
| typing | placeholder | Remember “{query}” | keyword + FTS live | → searching on ↵ |
| compose | placeholder | Remember row | keyword + FTS live | zone ① morphs to stacked layout |
| staged | — | Remember N files | — | locks to remember (recall hidden) |
| searching | thinking (✦ breathe) | Remember row | semantic fades in over keyword | → streaming / complete / error |
| streaming (Pro) | AI tokens + ✦ breathe | Remember row | sources with [N] citations | → complete on stream end |
| complete | AI + copy toolbar | Remember row | sources + load more | → typing on new keystroke |
| remembering | — | ✦ Remembering… | — | → sent / error |
| sent | — | ✓ Sent · Undo (4s) | — | → idle (autodismiss) / undone |
| error | — | ✕ Couldn’t save · Retry | — | content restored, user retries |
| command | — | — | command discovery / topic+hub target list | → typing on backspace past / |
| topic scope | placeholder | Remember (in-topic) | topic-biased results | set by route, cleared by chip X |
Layered esc — peel back one layer per press:
- edited value ≠ recallQuery → restore value
- result surface open → dismiss results + clear recall
- textarea has text → clear it
- scope chip active → degrade chip back to raw slash or clear scope
- bar engaged on /home → return to rest
- otherwise → blur
Both sections are valid targets. This card is for the team reviewer — scan the deltas, pick a direction, then one of the two gets deleted during the production port.
| Aspect | Section 37 | Section 38 (this) | Why 38 |
|---|---|---|---|
| Chrome | flat bar-bg + bar-border | 24m glass · blur(24) · /0.92 | user explicitly said “I like 24m shadow/color/style” |
| Position | top 16vh everywhere | rest 42vh on /home · docked elsewhere · engaged 24vh | hero moment + middle-upper breathing room |
| Zones ②③ presence | mutually exclusive · ② typing, ③ after ↵ | ② always reserved · ③ always visible | continuity — user tracks the same surface evolving |
| Remember row lifecycle | disappears after Enter | stays visible, morphs through transient states | user can always save, feedback is inline |
| Save feedback | implied toast (not shown) | inline zone ③ transients (replaces notification card) | one fewer surface; container morphing |
| Hub badge | absent | on Remember row, all states | multi-hub users need the target visible at save time |
| Mobile | single surface, results cascade upward | Notion mirror — eye zone top, thumb zone bottom | user’s original ask; separates where you look from where you type |
| Slash commands | 7 (includes /import, /share, /remember, /settings) | 2 shipped (/topic, /hub) + /forget deferred | /import ≡ drag-drop, /share doesn’t exist, /remember ≡ ⌘↵ |
| Compose morph (24f) | absent | present · CSS Grid stacked layout | ChatGPT auto-grow is load-bearing for long pastes |
| File/URL capture (24g) | absent | chips, drag-active, URL capture | the only non-text push path — can’t drop it |
| Topic scope indicator | inline #chip in input row | labeled capsule on the left | matches the command-scope visual (same shape, different label) |
| Load more / max height | unspecified | max-h-[60vh] + “+ N more” row | user explicitly asked for a fixed max with pagination |
| Send button icon | Sparkle (lucide ✦) for recall, ↑ for command | unified ↑ · color changes by mode (violet ↔ dark) | ChatGPT / Claude / Perplexity convention · frees ✦ back to AI-only (design system rule) |
| Deprecated inputs | detect-intent.ts deleted | detect-intent.ts deleted · ? prefix deleted · mode capsule tap deleted | 38 is explicit about all three — see callout in 38a |
Things the two share: downward cascade, recall-default Enter, AI above sources, ⌘↵ for remember, slash as a command palette trigger, literal Unicode glyphs in <kbd>, layered esc, intent detector deleted, kitchen 24m absorbed. The disagreements are all about presence vs absence: whether zones stay visible across phases, whether mobile duplicates the input, whether the save target is visible.
32. Dev Debugger
North star for the live Memax event tray used to inspect recall-heavy flows.
Debugger is dev-only and hidden behind Dev Tools.
Recall is the primary trace: submit, fetch, results, panel open, AI answer.
Remember flows log both direct text saves and staged file uploads.
Tray stays global so the events remain visible while people interact with the bar.
6. Tree Panel
Topic tree — both navigator AND manipulation canvas. Three responsive modes (desktop pinned, desktop overlay, mobile bottom sheet). Supports memory → topic drops from every memory surface + topic → topic drag-to-reparent within the current hub. Kbd/touch/mobile accessibility via the ⋮ menu picker on each node (no dnd-kit KeyboardSensor). See manipulation rules block at the top of this file for cycle/depth/hub invariants.
Full-height sticky sidebar. Logo stays fixed (same position always). Tree header aligns with CONTENT_TOP. ChevronsLeft collapses. Content push animated via motion.div width 0↔280. Hover left edge reveals overlay. Persisted.
Content shifts right
Sidebar takes space in layout flow
No toggle button. Hover left edge (12px zone, 150ms delay) reveals overlay. Starts at HEADER_TOP (not full height). » pins to layout flow. Mouse-leave dismisses after 300ms. Backdrop at 8% opacity.
Content stays full width
Hover left edge to reveal
Code icon next to “Your Topics” page header (right side). Opens bottom sheet overlay with drag handle. Toggle is contextual, not in fixed header.
Your Topics
10 memories · 5 topics
Tree IS the page content. Tap a topic → detail slides in from right. Back arrow returns. No separate toggle button. No overlay. This is the target pattern for mobile topic navigation.
Your Topics
147 memories · 6 topics
Topic detail
Memory list here
Collapsed (has children)
Active (selected)
Leaf (no children)
Drop target (DnD hover)
Waiting for first dream
Topics appear after your first dream
memax organizes your memories during the nightly dream cycle.
Dreams disabled
Dreams are turned off
Enable dreams to organize your knowledge.
Production files: topic-tree-panel.tsx (provider + pinned/peek), topic-tree-content.tsx (data + empty states + root drop row), topic-tree-node.tsx (recursive node + grip + ⋮ menu + invalid-drop ring), topic-dnd-provider.tsx (shared DndContext, memory + topic discriminator, dropTargetsActive gate), topic-dnd-hooks.tsx (useDraggableMemory, useDraggableTopic, useDroppableTopic, TopicDragContext), use-topic-move.ts (mover hook), topic-move-picker.tsx (kbd/touch a11y path). layout.tsx (FloatingTreeToggle, SidebarSlot, BrandLogo).
Desktop (Notion/Linear-style floating-toggle model): no permanent rail — main content goes full width when the tree is closed. FloatingTreeToggle renders a small ChevronsRight button to the right of the fixed BrandLogo when `!isPinned && !isDragSessionOpen`, opening the tree on click. SidebarSlot animates width 0↔280 based on `isPinned || isDragSessionOpen`; the close button lives inside PinnedTreeSidebar's top row (`marginTop: 32 + h-12 items-center`) on the SAME vertical band as the fixed BrandLogo, so open→close has no vertical jump. No redundant “Topic Explorer” header label — the content and close button already identify the sidebar. Tree is always mounted in SidebarSlot so droppables register from page load; dropTargetsActive on TopicDragContext (`isPinned || isDragSessionOpen`) gates collision detection so collapsed tree nodes don't match drops in main content area. isPinned persisted under `memax_tree_pinned` localStorage key (legacy name, semantics are now "is the tree open"). No hover-peek.
Typography (kitchen row-title rule, memory `b1f8fa7a` / §29 rules 32-34): 14px for ALL row titles — hub row and topic rows share the same size. Hierarchy via opacity + weight, never size. Hub row is the strongest entry: `text-[14px] font-medium text-fg-1` — reads as the tree root anchor. Topic rows fade as scaffolding: inactive `font-normal text-fg-2`, active `font-medium text-fg-1`. Trailing memory counts are 12px `text-fg-4` metadata. No 15px bumps, no semibold, no size-based hierarchy.
Mobile: Code icon in page header (topic-grid.tsx) opens bottom sheet via TopicTreePanelOverlayHost. No rail, no SidebarSlot — mobile uses its own drawer surface. Future target: master/detail push (22d).
7. Hub Experience
Team hub: ambient workspace switching from the top-right chrome beside the avatar, plus attribution, management, and invite. Personal by default, team by intent.
Anchored beside the avatar in the top-right chrome. Single-hub users see no chip. Multi-hub users get a persistent current-hub chip; the avatar dropdown stays account-only. Active hub shown with checkmark. Users icon for team hubs. “Create team hub” link at bottom.
Single hub user (no hub chip)
Derek
52 memories
Multi-hub user (chip beside avatar, not inside dropdown)
Derek
backend-team · 31 memories
Team memories show lucide Users + hub name (text-[12px] text-fg-4). Personal memories show no badge (default = no label). Only visible when user has 2+ hubs. Processing row unchanged. Flat dividers, icon containers match production.
OAuth2 refresh flow with token rotation strategy...
Push toast: “Sent — memax will organize.” (personal) or “Sent → hub-name — memax will organize.” (team). Recall results show hub attribution in cross-hub mode. Bar clears immediately after submit (chat-style, no blocking confirmation).
Push to personal hub (toast, no hub label)
Push to team hub (toast with target)
Recall results (cross-hub, shows attribution)
Inline form in settings dialog Account tab, below existing hub list. Name input → auto-generated slug → Create. Hub appears in pill dropdown immediately. Also accessible via dropdown “Create team hub” link.
Team hubs
Open a hub to manage members, roles, invites, permissions, and ownership.
Create team hub
Your team will share a knowledge base. Invite members after creation.
Click team hub in settings → expands to show members + invite lifecycle. Rename hub (Pencil icon, inline edit). Members sorted by role with (you) marker. Owner/admin: hover-to-reveal X for member removal with destructive confirmation. Role badge becomes an inline editor for Admin/Contributor/Viewer. Invite management: list active invites with role badge, expiry, copy, regenerate, revoke. Ownership transfer is a dedicated owner-only section with pending state.
5 members · 31 memories
Your role
Owner
Members
5
Shared memories
31
Members
Ownership
Transfer pending
Sarah needs to accept ownership before the role changes.
Expires Apr 14, 2026
Member removal confirmation
Invite links
https://memax.app/invite/a8f3c2e9...https://memax.app/invite/b7d4e1f0...Revoke invite confirmation
Contributor permissions
Memory deletion
Contributors can delete memories they contributed
Manage topics
Contributors can create and organize topics
Danger zone
Permanently delete backend-team and all 31 memories. 4 members will lose access.
Personal memories are not affected.
Centered card, same visual language as login. Shows hub name, memory count, member count, inviter. Single CTA. Expired tokens show error state.
Join backend-team
31 shared memories
5 members
Invited by Derek
Search and contribute to
your team's shared knowledge.
Expires Apr 12, 2026
Inbox is hub-scoped. Shows unassigned memories for active hub. Clean state uses DREAM_PURPLE ✦. All states match production patterns (Clock/Inbox icons, flat dividers, icon containers).
Team inbox (has items)
Will be organized in the next dream cycle
Will be organized in the next dream cycle
Inbox clean
Your memory is clean
All states for hub creation: loading (breathing ✦), error (inline message), success (auto-switch + open management). Maps to teams-section.tsx HubCreateForm.
Creating
Error
Hub name already taken
Success → management
When hub has 1 member (owner) and 0 memories, replace member list with prominent invite CTA. This is the first thing an owner sees after creating a hub.
1 member · 0 memories · just now
Invite your first teammate
Share a link to start building shared knowledge.
Four states: not generated (CTA button), generating (breathing ✦), generated (code + copy + expiry), error (inline message). Plus role picker shown before generating — select Contributor/Viewer/Admin. Maps to teams-section.tsx InviteCreateButton.
Not generated
Generating
Generated
https://memax.app/invite/a8f3c2e9...Expires Apr 14, 2026
Error
Could not generate invite link.
Role picker (before generate)
Invite as
Logged in: single CTA. Not logged in: “Sign in to join” → OAuth → return to page → auto-accept. Expired/used show error. Accepted shows redirect.
Logged in
Join backend-team
5 members · Invited by Derek
Not logged in
Join backend-team
5 members · Invited by Derek
→ OAuth → returnTo in localStorage → auto-accept on return
Expired
Invite expired
Ask the hub owner for a new link.
Accepted
Joined!
Redirecting to your team's memory hub...
Two-step: button → destructive confirmation with memory count + member impact. “Personal memories are not affected.” After delete: return to list, auto-switch to personal.
Default
Confirmation
Permanently delete backend-team and all 31 memories. 4 members will lose access.
Personal memories are not affected.
Owner/Admin: full management — rename hub, remove members (hover-to-reveal X), invite lifecycle (list/revoke/regenerate/role picker), delete hub. Contributor/Viewer: read-only member list + leave only. Roles affect ACTIONS not VIEWS — all roles see the same memories.
Owner / Admin
Full control: rename, remove members, manage invites (list/revoke/regenerate), delete hub.
Contributor / Viewer
No invite button. No rename. No delete. No remove. Read-only member list + leave only.
Two-step transfer only. Owner selects an existing member, the target explicitly accepts, and the previous owner is automatically demoted to Admin. No same-screen instant owner swap.
Owner view
Transfer pending to Sarah. Derek stays owner until Sarah accepts.
Sarah
Admin · Expires Apr 18, 2026
Target member view
Sarah sees one focused decision: accept ownership or decline.
Take ownership of backend-team
Derek will become Admin after you accept.
Hub switcher = scope filter. Selection controls BOTH what you see (read) AND where you push (write). “All” is read-only aggregation — push defaults to personal. Single-hub users see no filter UI.
Hub scope selector (in dropdown)
Derek
All · 95 memories
Scope model
Recall always crosses all hubs.
Regardless of view scope, recall searches everything accessible. Hub badges on results show origin.
The “you are here” anchor for multi-hub browsing. Hub identity now lives in the persistent top-right chrome beside the avatar, not above each page h2. h2 forks on hub kind: personal → “Your Topics” (warm), team → “Topics” (neutral).
Single-hub user (no chip)
Your Topics
52 memories · 5 topics
Zero noise when hubs.length < 2. h2 is warm “Your Topics”.
Multi-hub — personal active
Your Topics
52 memories · 5 topics
Hub context lives in persistent top chrome, while h2 keeps the warm possessive “Your Topics.”
Multi-hub — team active
Topics
31 memories · 3 topics
Team h2 drops to neutral “Topics” — “Your Topics” inside a shared team hub is semantically off. Role tag stays inline on the chip (admin / owner only).
Interactive — click top-right chip to open the switcher popover
Topics
31 memories · 3 topics
Popover from the global top-right anchor
Contract
· Chip renders in the top-right chrome beside the avatar, not inside page bodies. The page h2 stays responsible only for page identity.
· Chip is hidden entirely when hubs.length < 2. Single-hub users see nothing — zero chrome.
· Click opens the hub switcher popover from the global top-right anchor. The avatar dropdown no longer repeats the hub list.
· Inside the switcher list, both personal and team keep the leading icon. Only personal keeps the text tag, so users can spot the default home quickly without duplicating team identity.
· h2 forks on hub kind: personal → “Your Topics” (warm, possessive), team → “Topics” (neutral, shared). Chip carries hub identity, h2 carries page identity. No more “{name}'s Topics” interpolation fork in i18n.
· Role tag shown inline only for Owner / Admin. Contributors & Viewers see just the hub name — keeps the chip quiet for readers.
· Production mapping: app/(app)/layout.tsx (top-right chrome anchor), settings-panel.tsx (remove duplicated hub list), topic-grid.tsx (keep t.topics.titlePersonal vs t.topics.titleTeam fork only).
Bar shows where push goes. Personal scope: no indicator (default). Team scope: hub name pill right of input. All scope: “→ Personal” default, user can tap to change. Push toast always confirms target.
Bar — team scope (push target shown)
Bar — personal scope (no indicator needed)
Bar — “All” scope (push defaults to personal)
Push toasts by scope
Push target rules:
• Personal scope → push to personal hub (no indicator)
• Team scope → push to that team (arrow pill indicator)
• All scope → push to personal (muted indicator, tappable to switch)
• Single-hub users → no scope UI, no push indicator
Uses existing bar notification system. Success type, 2s auto-dismiss. Avatar subtitle updates immediately. No new component.
After switching hub in dropdown, bar shows confirmation notification. Avatar subtitle updates immediately.
Success type, 2s auto-dismiss. Uses existing bar notification system. No new component needed.
In settings Account tab. Click name → inline edit → save on blur/Enter. display_name is the single source of truth (seeded from OAuth, editable here). Used in attribution rows, settings panel, avatar dropdown.
yejiahaoderek@gmail.com
Inline edit: no modal, no save button. Bottom border appears on hover, solidifies on focus. Save on blur or Enter. PATCH /v1/auth/me with display_name. Optimistic update in useAuth.
Industry pattern: Slack, Notion, Linear, Vercel, GitHub, Figma all use top-left scope selector. 2-click standard. Full context swap on switch.
Routing: switched hub sets read context only. Push writes require explicit targeting (for example X-Hub-ID) or fall back to personal. Recall reads across all accessible hubs.
New user invite flow: Click link → see hub info → “Sign in to join” → OAuth with returnTo → auto-accept on return → switch to hub → land in /home.
What it is.A page-level identity header that renders on /home, /topics, and any hub-scoped landing. Replaces the old “你的主题 · 55 条记忆” heading that floated between section cards — that was hub metadata mislabelled as a section title.
One component. HubHeaderBanner with an aurora prop. Full-bleed banner bleeds under the app top bar (logo + hub dropdown + user avatar). Avatar hangs across the banner edge, title/greeting /stats sit on --background.
Three modes. signature (default) — static memax-branded gradient, stable across team members and time of day. time — drifts with time of day, personal-hub opt-in only, team hubs ignore. none — plain typographic fallback, no banner, no aurora. See 20p-f for the Settings › Appearance switcher.
Rules honored: no left-colored-borders, no toolbar in the header, no mode change, no decorative chrome, no content in the banner top-right (that area is reserved for the app shell). Greeting is contextual — dream deltas, inbox overflow, review-needed, quiet-night. Never generic time-only.
One component, HubHeaderBanner, with three background treatments via the aurora prop. Shipping default is signature.
- Plain (aurora="none") — no banner layer. Pure typography on --background. Same treatment as 20p-f. Minimal. For users who find any ambient background noisy, or as a fallback if the browser doesn't support gradients/blur.
- Signature (aurora="signature", default) — static memax-branded signature gradient. Violet + soft magenta. Stable across time, stable across team members. This is the production default.
- Time (aurora="time") — time-of-day driven gradient. Personal hub opt-in only (Settings › Appearance › “Match time of day”). Team hubs cannot enable this — the time signal is a personal concept, not a shared one.
Plain — no aurora, pure typography
早上好,Derek。memax 昨晚整理了 4 个主题。
Signature — static memax gradient (recommended default)
早上好,Derek。memax 昨晚整理了 4 个主题。
Time — shifts with time of day (personal opt-in)
早上好,Derek。memax 昨晚整理了 4 个主题。
Direct Notion translation. Avatar centered, hanging 50/50 across the banner edge. Title/greeting/stats stack below left-aligned. Title bumps to 28px for the hero feel.
Use for hero states (onboarding, return-after-absence) where the hub identity should take a full-width moment. Daily workspace uses the inline-right variant from 20p-k.
欢迎回来,Derek。最近都想些什么?
Mobile: banner drops to 140px, avatar to 56px, title to 20px, greeting to 13px. Plain mode has no banner, just a small top padding and the same typographic content.
Plain
晚上好,Derek。收件箱里有 28 条等你整理。
Signature
晚上好,Derek。收件箱里有 28 条等你整理。
Time
晚上好,Derek。收件箱里有 28 条等你整理。
The same HubHeaderBanner across every data state: morning-dream-delta, afternoon-clean, evening-inbox-overflow, deep-night-quiet, first-time, return-after-absence, team-morning, team-review-needed. All on the signature aurora (the shipping default), so you can see how the same surface adapts content without changing its skin.
Notice: team-morning and team-review-needed render a square avatar + member cluster beside the hub name. Personal hubs render a circle avatar and skip the cluster. Stats row adapts: first-time collapses to a setup CTA, team states include member count, inbox-heavy states include an inbox number, review-needed includes a pending-review count.
Morning — dream deltas
早上好,Derek。memax 昨晚整理了 4 个主题。
Afternoon — clean
下午好,Derek。一切井井有条。
Evening — inbox overflow
晚上好,Derek。收件箱里有 28 条等你整理。
Deep night — contemplative
深夜了,Derek。深夜的想法最清澈。
First-time / empty
欢迎使用 memax,Derek。
开始记录你的第一条吧Return after absence
欢迎回来,Derek。最近都想些什么?
Team — morning + activity
早上好,Derek。团队今天多了 6 条新记忆。
Team — review needed
Derek,有 3 条合并冲突等你决定。
End-to-end. The real app has a top bar: memax logo top-left, hub dropdown + user avatar top-right. The banner bleeds under this top bar — the aurora is the ambient backdrop for the app chrome, not something that starts below it.
On signature/time modes, top-bar elements get frosted backdrop-blur pills so they stay legible on the gradient. On plain mode, the pills drop away — elements sit on --background normally. Compare the three modes below using the same mock state (team hub, morning).
Plain — no aurora under top bar
早上好,Derek。团队今天多了 6 条新记忆。
Signature — default production treatment
早上好,Derek。团队今天多了 6 条新记忆。
Time — personal opt-in (morning bucket)
早上好,Derek。团队今天多了 6 条新记忆。
How users change aurora mode. The control lives in Settings › Appearance, not on the banner itself (the banner has no chrome). Three radio options + live preview. Preference is user-global (one setting per user across all hubs, not per-hub).
Team hubs auto-downgrade time → signature at render time. The setting stays at timefor the user — they'll see the time drift again whenever they view a personal hub. No feature flags, no hub-level override in the data model: a simple two-line rule in the render path.
Appearance
Hub header style
How the banner at the top of each hub page renders.
Preview
早上好,Derek。memax 昨晚整理了 4 个主题。
Greeting priority when multiple states apply: first-time → review-needed → dream-deltas → inbox-overflow → clean → time-only. Lower-priority states roll into the stats line instead. Variety within a state: 2–3 alternates, deterministically rotated per day.
| Situation | Greeting (zh) | Greeting (en) |
|---|---|---|
| Morning + dream deltas | 早上好,Derek。memax 昨晚整理了 4 个主题。 | Good morning, Derek. Dreams reorganized 4 topics overnight. |
| Morning + clean | 早上好,Derek。一切井井有条。 | Good morning, Derek. Everything's in order. |
| Afternoon + inbox overflow | 下午好,Derek。收件箱里有 28 条等你整理。 | Good afternoon, Derek. 28 items waiting in your inbox. |
| Evening + review needed | 晚上好,Derek。有 3 条合并冲突等你决定。 | Good evening, Derek. 3 merge conflicts need your call. |
| Deep night + clean | 深夜了,Derek。深夜的想法最清澈。 | Late night, Derek. Quiet thoughts travel far. |
| Return after 7d absence | 欢迎回来,Derek。最近都想些什么? | Welcome back, Derek. What have you been thinking about? |
| First-time / empty | 欢迎使用 memax,Derek。 | Welcome to memax, Derek. |
| Team + team activity | 早上好,Derek。团队今天多了 6 条新记忆。 | Good morning, Derek. Team added 6 memories today. |
| Team + review needed | Derek,有 3 条合并冲突等你决定。 | Derek, 3 merge conflicts need your call. |
Rules:warm but not cheesy · acknowledge state, don't hype · comma after name in English · never prefix with Hi / Hello · never include hub name in the greeting (it's already above) · bilingual parity (not translations — each language gets its own copy pass).
i18n namespace (new): hub.greeting.* with ~15 keys covering the situation matrix plus 2–3 alternates each.
One component, three modes. HubHeaderBanner owns all three aurora treatments via the aurora prop: "none" (plain typography), "signature" (static memax gradient, default), and "time" (opt-in time-of-day drift). No separate components, no feature-flag branches in callers.
Signature is the default. Stable, memax-branded, consistent across time and team members. Time-drift is a personal-hub-only opt-in (Settings › Appearance › “Match time of day”). Team hubs cannot enable time-drift — time-of-day is a personal concept and makes no sense for a shared workspace.
Plain is the escape hatch. aurora="none" renders pure typography on --background — identical to the 20p-f borderless treatment. No banner layer, no halo on the avatar, no negative margin. For users who prefer minimal ambient, or as a fallback when gradients/blur don 't render (e.g. reduced-motion, old browsers).
Banner bleeds under the app top bar. The app shell's top bar (memax logo left + hub dropdown and user avatar right) is absolutely positioned on top of the banner. The aurora is the backdrop for the top bar elements — they are NOT in a separate bar above it. On signature/time modes, top-bar elements wear frosted backdrop-blur pills to stay legible. On plain mode, the pills drop.
No content in the banner's top-right. That area is reserved for the app shell (hub dropdown + user avatar). The banner itself renders no time icon, no Change button, no Pin button. Aurora is ambient — the user doesn 't customize it and doesn't need controls.
Shorter than Notion. 180px desktop default (200px for hero states like onboarding), 140px mobile. Notion's 280px assumes a rarely-visited project page. memax /hub is a daily workspace — every pixel above the fold is a budget.
Fade, don't cut. Bottom 25% of the banner fades to --background via a gradient overlay (not mask-image — the mask interferes with the avatar halo). No hard horizontal line between banner and content.
Avatar crosses the edge. Inline-right: 25% above / 75% below (title lands on clean background). Centered-notion: 50/50 (dramatic hang). Negative margin-top on the avatar wrapper; everything else in natural flow. Plain mode: no negative margin, no halo.
Title never touches the banner. In both layouts the title sits entirely below the banner bottom edge, on --background. Aurora is ambient context, not a backdrop for typography — placing the title on the aurora costs legibility and reduces the semantic weight of the name.
HubHeaderAurora (the card) is deprecated. The rounded glass card variant remains in the kitchen for comparison only. In production: delete it, use HubHeaderBanner with the default aurora="signature" and layout="inline-right".
The decision. Topics are hub-scoped, memory detail is hub-scoped, and even a plain Recent row is still content from the old hub. Leaving the user on whatever route happened to be open creates loopholes: some switches re-scope in place, others navigate, and memory detail can point at the wrong hub context.
The fix.Switching hub always navigates to the new hub's /memories view. You were looking at memories before; now you're looking at the new hub's memories. One destination, one rule, no route-specific exceptions.
JWT tokens, OAuth flows, API key management…
23 memories · 3 subtopics
Frame 1 is the fade transition (actual production is an opacity crossfade over ~220ms, not a topic remap and not a loading spinner). Frame 2 lands the user on the new hub's memories view so hub switches always have one destination and one mental model.
Subtle inline hint in the hub switcher dropdown when the user is currently inside a topic. Tells them what they'll see, not what they'll lose.
Without hint (default state)
Inside a topic — shows what you'll see
23. Agent Management
Unified agent identity, API keys, config sync, activity stats. Settings > Agents tab northstar.
Each connected agent gets a unified card. Identity icon with accent color, status dot (active/idle/inactive), stats summary. Expand for API keys, config files, rename, revoke.
API Keys
Synced Configs· 30 extracted
User can customize the display name shown in memory attribution and across the app. The agent slug (claude-code) is immutable — set during memax setup. Priority: user custom → AGENT_NAMES map → raw slug.
Display mode
claude-code
Editing mode
claude-code
Display name is user-customizable. Slug (claude-code) is immutable, auto-set from agent type during memax setup. Display name flows to memory attribution.
Three levels of removal: revoke single key, revoke all keys for an agent, or full disconnect (keys + configs). Memories are always preserved — they belong to the user, not the agent.
Normal state
Revoke confirmation
Disconnect agent (revoke all keys + delete configs)
Two-stage confirmation pattern. Revoking a single key is granular. “Disconnect agent” revokes all keys + deletes all configs for that agent. Memories pushed by the agent are always preserved (they belong to the user, not the agent).
Status derived from last_usedon API keys. No MCP polling needed — every API call updates the timestamp.
Orphan keys stay visible and explain why pushes look human until the user reissues them as an agent key.
These authenticate as you. Memories pushed with them appear as your own.
Unknown-slug OAuth grants stay usable, but the card becomes clearly remediable instead of pretending it has a stable identity.
Each agent type gets a unique icon + accent color. Consistent across attribution, settings, and management surfaces. Icon is mapped from agent_name slug, not user-configurable (keeps visual consistency).
claude-code
claude-ai
cursor
codex
gemini
copilot
windsurf
openclaw
opencode
generic
memax
Loading
No agents
Connect your first agent
Claude Code, Cursor, Windsurf, Copilot, Codex, Gemini
Data model
Agent identity is derived from api_keys.agent_name — set during memax setup. Each agent type can have multiple keys (per-hub, per-device). Activity status from api_keys.last_used.
Display name priority: user custom → AGENT_NAMES map → raw slug. Stored as agent_display_name on api_keys (future migration). Unified constant shared across memory-row, settings, and attribution.
New endpoint needed: GET /v1/agents/summary — aggregates api_keys + configs + memory counts per agent_name.
Production files: agent-configs-section.tsx, settings-dialog.tsx, memory-row.tsx. API: GET /v1/auth/api-keys, GET /v1/configs.
10. Dream Experience
Dreams show up across memax — while they are happening, after they finish, and anywhere they leave something worth your attention.
dreams haven't run yet
They run nightly to organize your memory
Scanning 247 memories
Your memory is clean
Processing timed out. Your memories are safe.
dreams are turned off
dreams never live on a dedicated page. You open memax the morning after an overnight dream and the signal is already where it matters: one ephemeral line near the bar, one row-accent on the items that moved, one N new pill on the topic cards that grew.
No aggregate “what changed” card, no review queue, no dream panel. Three cooperating surfaces, each doing one job.
1. Bar-adjacent notification — ephemeral, single line
memax is dreaming
scanning 247 memories
while running
dreams updated 4 topics · view
Auth · Deployment · Frontend · Docs
completed overnight
dreams are clean — nothing to review
last night · 247 memories
clean run
Auto-dismisses ~6s after complete or on first interaction. Tap completed state → scrolls to Recent with a 12h filter applied. That's the entire “review entry point.”
2. Recent — global review feed with row accents on moved items
Each dream-touched row gets a 2px signature-muted left accent and an inline topic destination. The accent is the onlydream signal — no header, no banner, no separate “what changed” card. Select any wrong ones → existing global batch toolbar → move.
3. Topic grid — N new pills on cards that grew
JWT tokens, OAuth, API keys
23 memories
Blue-green, rollback, staging
47 memories
RSC, Tailwind, Radix
22 memories
Connection pooling, migrations
15 memories
Error handling, context
31 memories
Onboarding, code review
9 memories
The N newpill is the spatial announcement — it says “this topic is where to look.” Inside the topic, delta becomes row-accent only (section 29d). The count never re-appears.
When the dream engine finds a contradiction it can't resolve, it lands in the inbox as a conflict row. Tap → expands inline into a simple decision surface (two notes, four buttons). No separate review queue, no card stack, no ceremony.
The inbox already exists. The dream engine just writes into it. Each conflict is its own inbox row — resolve one, it disappears, next conflict arrives as the next row.
Tap the ✦ conflict row to expand inline. Two notes, four buttons — no card stack, no ceremony.
i18n keys: dreams.noRuns, dreams.running, dreams.completed,
dreams.clean, dreams.failed, dreams.disabled, dreams.merge,
dreams.archive, dreams.contradiction, dreams.review.*
dreams should feel gentle but clear. Let them glow while they are happening, settle when they are done, and only ask for attention when you really need to decide something.
29. Topic Redesign
Topics are the spatial browse surface. Three-tier topic cards, recall→topic bridge, 5-level drill navigation with inline subtopic groups, ⌘P picker, and orphan-memory handling at every depth. Dream activity lives in §36 Inbox — topic cards and topic detail are silent (no ✦ badges, no row accents, no delta headers). When the dream engine flags something that needs a decision, it surfaces as a review in Inbox, never here.
One component. The Topics page renders a single scale-aware view that picks its own density based on topicCount. The user never switches manually unless they want to override.
Recursive at every drilled level. The same A/B/C mode logic applies inside a topic too — if a topic has 200 direct subtopics, that level renders in Mode B (dense list), not Mode A (rich grid). Whatever scope you're in (hub root or 5 levels deep), the renderer asks the same question: how many children at this level?
- Mode A — Personal (≤20 topics) — 3-col rich cards with description + 2-3 subtopic chips
- Mode B — Team (20-80) — 4-col dense cards, name + last-touched only, search bar at top + Pinned section
- Mode C — Enterprise (80+) — virtualized list, single row per topic, search-first + Pinned + Recently visited at top
Thresholds 20 / 80 are starting values — tune from real usage. Each demo below is the same component fed a different topic count.
3-col rich cards. Each card carries icon + name + description (2-line clamp) + 2-3 subtopic chips + last-touched timestamp. Description and chips give “what's in there” before you click. No memory count. No subtopic count. Just ambient temporal context.
Backend, frontend, infra — Go services, RSC patterns, Fly.io, GitHub Actions.
JWT tokens, OAuth flows, API key rotation, boundary enforcement, secret scanning.
React Server Components, Tailwind, Radix primitives, design tokens.
PostgreSQL, pgvector, migrations, connection pooling, replication strategies.
Blue-green, rollback procedures, staging automation, Fly.io machine health.
Content ingestion, chunking, embedding, deduplication.
Onboarding, code review, release process, incident runbooks.
Error handling, context propagation, interface design, testing patterns.
4-col compact cards. Description and chips drop. Each card = icon + name + last-touched. Top of view: search input + sort dropdown + Pinned section. Default sort: by last-touched.
Pinned
All Topics
Single-row virtualized list. No card chrome. Each row = icon + name + last-touched (right-aligned). Header row shows count + ⌘K hint + sort. No local search input — the global command bar handles topic filtering (rule 25). Pinned cards + Recently visited cards sit above the virtualized list, which mounts only visible rows so 1000+ topics scroll smoothly.
Pinned
Recently visited
All Topics · 247
Top-right of the topics view: a 3-icon toggle that lets the user override the auto-mode. Choice persists in localStorage per hub. Default = auto. Useful for power users who want dense list even on a small personal hub.
Backend, frontend, infra — Go services, RSC patterns, Fly.io, GitHub Actions.
JWT tokens, OAuth flows, API key rotation, boundary enforcement, secret scanning.
React Server Components, Tailwind, Radix primitives, design tokens.
PostgreSQL, pgvector, migrations, connection pooling, replication strategies.
Blue-green, rollback procedures, staging automation, Fly.io machine health.
Content ingestion, chunking, embedding, deduplication.
Onboarding, code review, release process, incident runbooks.
Error handling, context propagation, interface design, testing patterns.
Every topic card / row carries a Pin icon in its top-right corner. Empty state is text-fg-4 outlined, revealed only on hover / focus (desktop) or always visible (mobile). Filled state is text-fg-1with a 45° rotation — the icon looks “pushed in” when active.
Clicking the icon toggles pinned state with a shared-layout morph: the card animates from the All section up to the Pinned section (or back down on unpin) using framer-motion layoutId. Duration NORMAL (0.2s), easing EASE spring from @memaxlabs/ui/tokens/motion. The Pinned section height animates with AnimatePresence — when the last pin is removed it collapses to 0 cleanly (no empty-section flash).
Pinned
All Topics
Hover a card → the pin icon appears in the top-right. Click it → the card animates up to the Pinned section (or back down on unpin). Shared layoutId + the NORMAL spring transition from @memaxlabs/ui/tokens/motion.
Industry references: Linear's favorites, Notion's star, Apple Reminders. All three use a persistent icon affordance + a shared-layout animation rather than a dropdown action. We match that pattern because it keeps the surface quiet — no menu to open, no confirmation to dismiss.
Pin is fast but limited. For rename + forget, the topic card exposes a context menu. Desktop: right-click anywhere on the card. Mobile: long-press (hold 400ms). Menu appears as a glass panel anchored to the click point. Three items: Pin / Unpin (toggles, matches corner icon state), Rename, Forget (destructive, red, opens confirm dialog in prod).
Keyboard: focus a card row and press Space or Enter to drill in; P to toggle pin; Menu key / Shift+F10 to open the context menu at the focused row. All three map to the same handlers behind the scenes.
Right-click any row to open the context menu.Long-press (hold 400ms) any row to open the context menu.
Prod wiring: wrap the menu in Radix DropdownMenu with modal=falseso it doesn't trap focus (the user is still in the topic list). Right-click uses onContextMenu; long-press uses a 400ms timer cleared on touch-move / touch-end. Mobile haptic: fire navigator.vibrate(10) when the menu opens.
The scale-aware view is recursive (topic system rule 2). When a topic has >20 direct subtopics, its detail page switches from inline-groups to the same dense-grid / list view that the top-level topics page uses. Same component, different root.
Example: Engineering → Backendhas 80 direct subtopics. Instead of rendering 80 inline collapsible groups in one scroll, the Backend detail page renders them as a Mode-B dense grid with search. The user picks one, drills in, and that subtopic's detail page again decides its own mode based on directChildCount. Scale applies locally; never globally.
Go services, database, queues, cache. Owned by platform team.
The L2 pagination boundary(topic system rule 18): subtopics within a level. Initial render shows the first batch; “Show 20 more” appends the next group in place with a staggered fade-in (framer-motion AnimatePresence, NORMAL (0.2s) EASE, per-item delay 20ms). No layout jump — the container grows downward and existing rows don't move.
Independent cursor from L1 (topics) and L3 (memories-within- subtopic). Over 80 subtopics at a level, the section auto-switches to the Mode-B dense grid with search — see §29-scale-nested for the recursed-Mode-B treatment. Reduced-motion: instant, no stagger, no height animation.
Orphan memories (direct children of a topic, not inside any subtopic) render unlabeled at the top of the level (rule 4). At scale — e.g. 527 captures that never got assigned — the orphan section becomes its own paginated block with its own Show 20 more boundary, independent of the subtopic list below it.
Escape hatch for orphans is different from subtopics. Orphans have no drill target — they belong to the topic itself, not a subtopic. So instead of “Open full view →”, the orphan section uses search-first: an inline filter input that matches against all 527 titles locally. Type in the field below — the list filters in place; clear it to restore pagination. Rule 23.
All the things that don't fit anywhere else yet.
Direct memories
527 unassignedSubtopics
Lazy-load-by-drill (rule 19) means every boundary has a loading state. The kitchen shows the skeletons so Codex doesn't have to guess shapes.
- L1 — top-level topics page first paint: 6 card skeletons that match Mode A card shape (icon circle + 2 lines + chip row). Mode B/C use row skeletons of the same row height.
- L2 — subtopics-within-a-level Show-more click: 4 new subtopic-header skeletons append to the list while the fetch is in flight. No layout jump.
- L3 — memories-within-subtopic Show-more click: 8 memory-row skeletons append at the end of the list. Last-touched and topicLabel chips fade in once real data arrives.
- Drill rebase — in-place morph: during the 29n cross-fade, the outgoing content fades to skeletons of the incoming shape at 0.5 opacity, then the real content replaces them. Prevents empty-container flash.
L1 · topic card skeletons
L2 · subtopic header skeletons
L3 · memory row skeletons
The prod screenshot that triggered this pass had three real problems compounding: memory rows showed inconsistent summary lines; memory titles used font-medium which flattened the hierarchy against topic rows; every memory row carried a FileText icon that visually competed with the topic Folder icon. Users couldn't tell what was anchor vs content at a glance.
Three coordinated rules fix it — no added chrome, just removing the prod divergences and applying memax DNA (content-led, chrome recedes). See rules 32–34 below.
- Rule 32 — Typography: topic rows fade when closed (text-[14px] text-fg-2) and commit when open (text-[14px] font-medium text-fg-1). Memory rows stay text-[14px] text-fg-1 regular always. Same 14px size for both — content is king, chrome (structure) recedes. Industry reference: Linear / Notion / GitHub all use same-size list items with weight-via-state hierarchy.
- Rule 33 — Content icon (doc vs non-doc): the split is doc vs non-doc, not text vs everything. Doc-like (no badge): text, pdf, markdown, code — all reading content, scan-identical. Non-doc (trailing badge at text-fg-4): image, link. These behave differently from reading content. Topic row always has its TopicIcon anchor; memory row usually has nothing on the left. Icon presence is the strongest anchor-vs-content signal in the list and pairs with rule 32 to carry hierarchy without extra chrome.
- Rule 34 — No summary on list rows: memory rows in topic view are single-line (title + optional trailing badge + age). Summary lives in memory detail. Topic description lives in the topic focus header + expanded subtopic header only. Predictable rhythm; no "some rows have description, some don't" jaggedness.
Why no vertical rail: memax DNA is "no dividers, content-led". Indentation (16px per level, rule 5 caps inline at 2 levels desktop / 1 mobile) + rule 32 open/close contrast + rule 33 icon presence together carry the hierarchy. A rail would duplicate signals already in place. Notion uses rails because Notion pages nest arbitrarily — memax doesn't (rule 5 cap). If hierarchy still feels unclear after all three rules land in prod, revisit as a follow-up; don't add chrome preemptively.
Every §29h demo renders these rules — see 29h-large below for OAuth memories with mixed content types. The orphan row is a PDF ("Security review Q1 decisions") — correctly renders nothing because PDFs are doc-like. Inside the OAuth subtopic, two non-doc rows render trailing badges: the RFC 7636 link icon and the OAuth consent flow image icon. Codex just needs to drop three prod divergences: memory-row.tsx:548 font-medium on memory title, memory-row.tsx:340 FileText fallback in renderLeadingIdentity, and memory-row-presentation.ts:66-71 showSummary including "topic" in its surface whitelist.
JWT tokens, OAuth flows, API key management, boundary enforcement.
Recall results grouped by topic. Topics with >1 match get a clickable header row. ✦ prefix on dream-discovered topics.
Topic rows: icon + name + match count + → chevron. ✦ prefix for dream-discovered. Individual results indented (px-6). Uses topic_id + topic_name from RecalledMemory.
LLM RULES — Recall → Topic Bridge
• Production file: expand-search-results.tsx. Groups POST-Enter semantic results by topic_id.
• Only groups when 2+ distinct topics. Single topic = flat list (no noise).
• Topic icon resolved client-side from useTopics() cache. No server changes.
• NO ✦ on topic headers in recall. Topics are inherently dream-created — marking them adds noise. ✦ is only for the topic grid (first-visit discovery signal).
• Pre-Enter (keyword/FTS) results stay flat — no topic data available.
• Indented rows: px-6 (vs px-4). Topic name suppressed in row metadata when grouped.
• Touch targets: header min-h-11, rows min-h-11 via content + padding.
Mobile collapses every grid to 1 column. Mode A keeps the rich card (icon + name + description + chips + last-touched). Mode B keeps the dense card (icon + name + last-touched). Mode C keeps the list row. Same content density, just stacked. The search-first patterns in B and C still apply at the top.
Backend, frontend, infra — Go services, RSC patterns, Fly.io, GitHub Actions.
JWT tokens, OAuth flows, API key rotation, boundary enforcement, secret scanning.
React Server Components, Tailwind, Radix primitives, design tokens.
Topics page / inbox counts
Couldn't load your topics right now.
Topic grouping and inbox counts are temporarily unavailable.
Topic detail shell
Couldn't open this topic right now.
The topic header or structure didn't come through. Try again.
Topic memory list / recent section / tree panel
Couldn't load memories for this topic.
The topic is here, but its memory list didn't load this time.
Reuses the existing memory-detail error treatment: no border, no tinted card, just centered copy + ghost retry on background. Never silent empty fallback. Production files: topic-grid.tsx, topic-detail.tsx, topic-tree-content.tsx.
Core model.Inside a topic, you see one memory list container. Subtopics render as inline collapsible groups — not as separate pages. Drill-down-as-page forces users to lose context on every tap; inline groups let them skim the whole structure in one pass and collapse what they don't care about.
Size class drives default collapse state, not page shape. Small and medium topics open all subtopics by default (still scannable). Large and huge topics start collapsed — the user expands what they need. There is no auto-expand-on-delta and no delta counts on headers. Topic navigation is silent about dreams (see rule 1 of the topic system rules).
One container (no separate “since your last visit” card), no ✦ N new pills, no row accents, indent grows with depth (capped at 3 levels). Orphan memories render unlabeled at the top of every level.
Trivial case. No grouping chrome. No headers. Just the memory list rendered top-to-bottom. Last-touched timestamps on the right of each row (rule 17); no delta accents.
Onboarding, code review, release process.
Medium topic. All three subtopics render open on arrival because the total is still scannable without collapse. The first two memories render unlabeled at the top — those belong directly to the topic, not to any subtopic. No dream-delta treatment — rows are neutral.
React Server Components, Tailwind, Radix primitives.
Large topic. All subtopics start collapsed; the user expands what they need. Subtopic headers carry just icon · name · description — no counts, no delta pills, no ✦ badges. The surface is pure spatial browse (rule 1 + rule 9).
JWT tokens, OAuth flows, API key management, boundary enforcement.
Huge topic. All 12 subtopics render collapsed — visually compact, one scroll of headers tells the whole structure. The + N more row at the bottom represents the virtualized rows — in production, only visible rows render; this demo simulates the cap. Mode C of the recursive scale-aware view (rule 2) would auto-select here if the count exceeds 80.
Historical engineering notes — 3 years of decisions, migrations, and post-mortems.
Subtopics can themselves contain subtopics. Indent grows with depth (16px per level, capped at 3). No delta rollup, no ✦ counts — nested groups are just nested groups. Desktop inline cap is 2 (topic + one nested), anything deeper becomes a drill-in chip. See §29j-* for the drill flow.
Content ingestion, chunking, embedding, deduplication.
Type in the search box (top-right of the topic header). Only memories with titles matching the query render. Matching memories inside collapsed subtopics force the parent subtopic open so results aren't hidden. Clear the query → prior collapse state restores. In production this is triggered by the / keyboard shortcut.
JWT tokens, OAuth flows, API key management, boundary enforcement.
One page per topic. Subtopics are inline collapsible groups, not separate pages. No drill-down-as-route. Breadcrumb stays at Your Topics > Topic Name regardless of which groups are expanded.
Direct memories render first, unlabeled. Memories that belong to the topic but not to any subtopic render at the top of the list with no group header. Section ends when the first subtopic header appears.
Default collapse by size class. Small (≤20) / medium (20–100): all subtopics open. Large (100–500) / huge (500+): all closed. The user expands what they need. No auto-expand-on-delta — topic navigation is silent about dreams (see topic system rule 1).
No delta, no counts, no accents. Subtopic headers carry icon · name · description only. No newCount, no ✦ pills, no row accents on “new” memories, no rollups. If something needs the user's call, it surfaces in §36 Inbox. Topic navigation stays pure spatial browse.
Indent capped at 3 levels. Deeper nesting flattens — deeper children render at the same indent as level 3 with their parent name prefixed in the title if disambiguation is needed. Prevents runaway indentation in pathological trees.
Expand state persists per topic per user. LocalStorage keyed by memax-topic-expanded-{hubId}-{topicId}. Manual toggles are the only source of truth. No auto-expand on any signal.
Search overrides expand state. Typing in / search force-expands any subtopic containing a match. Clearing the search restores the prior expand state.
Virtualization for huge topics. Topics over 500 memories use virtualized row rendering (only visible rows mount). Subtopic headers always render — they're cheap and they're navigation. Memory rows inside expanded subtopics virtualize.
An inline subtopic row carries two tap targets. The chevron on the left expands / collapses the row in place; the row body (icon · name · description) drills into that subtopic, rebasing the container via the §29n morph. Same pattern as Notion toggles, Linear nested projects, Finder list view.
- Chevron click → onToggle(sub.id), stays on current page. Chevron rotates 0° → 90° FAST (0.15s) EASE. Content region animates { height: 0, opacity: 0 } → { height: 'auto', opacity: 1 } via framer-motion AnimatePresence over NORMAL (0.2s). overflow: hiddenclips during animation so child padding doesn't leak.
- Body click → onDrill(sub.id), rebases the container (breadcrumb gains a segment, content region slides +16px + cross-fades via §29n). Container stays put; only the content inside morphs.
- Keyboard — Tab goes chevron → body → next row. Space / Enter on chevron toggles; Enter on body drills. Both buttons carry aria-label (“Expand X” / “Open X”) and the chevron also sets aria-expanded.
- Reduced motion — useReducedMotion()from framer-motion returns true → both the chevron rotation and the height animation are skipped. Opacity crossfades stay (they don't trigger vestibular issues). Matches Apple HIG guidance for Reduce Motion.
Industry references. Notion: same dual-target pattern — chevron toggles, title navigates. Linear nested projects: identical. Apple Finder list view: click disclosure triangle to expand, tap folder name to navigate. Radix Accordion primitive: chevron is the trigger, content is the panel; we're borrowing the animation shape but keeping our own data-layer so the row body can act as a second affordance.
Team hubs are Memax's focus. They accumulate far more memory, far more subtopics, and deeper trees than personal hubs. A 50-person engineering team's Engineering topic legitimately grows to depth 3-5 — Engineering › Backend › Database › Postgres › Migrations › Zero-downtime patterns.
The inline-2 reading limit still holds (human working memory doesn 't scale with team size), but drill-in is now a primary flow, not an edge case. This section demonstrates the full e2e team navigation: sticky breadcrumb, drill-in chips beyond the cap, in-topic search, mobile flow, hub overview mode, and row-level author attribution. Dream reviews (merge / stale / low-confidence) surface in §36 Inbox only — never in the topic view.
Starting point. Breadcrumb is sticky at top (scroll to confirm). Depth 0 (Backend, Frontend, …) + depth 1 (Database, Queue, Cache under Backend) render inline. Depth 2+ becomes drill-in rows: Postgres, MySQL, Redis show as clickable chips with affordance. Fuzzy search across all depths lives in the global bar (§24m); no separate picker hotkey.
All engineering knowledge — backend, frontend, infra, data, security.
User clicks Backend → depth 2 drill chip, or clicks a drill chip directly. Breadcrumb gains a segment. List rebases in place— no page transition. Backend's depth 0 children (Database, Queue system, Cache layers) become the new inline roots. Recent activity strip under the header shows +31 in 7 days by 4 contributors. Click any earlier breadcrumb segment to walk back.
Go services, database, queues, cache. Owned by platform team.
Three segments deep. Postgres / MySQL / Redis are now the new depth 0, all inline. Their children (Migrations, Replication, …) stay inline as depth 1. Everything deeper is drill-in chips. Activity strip shows tighter subtree context (+18, 3 people).
Postgres primary, MySQL legacy, Redis cache — schemas, migrations, replication.
Four segments deep. This is still fluid — each rebase is a cheap list re-render, not a route push. Migrations, Replication, Query optimization are new depth 0. Zero-downtime / Rollback / Version-control sit at depth 1, inline. Anything deeper is a drill chip again.
Our primary OLTP store. Migrations, replication, query tuning, HA.
Five segments deep — this is the maximum the Topic model allows. Zero-downtime patterns, Rollback strategies, Version control are the new depth 0. Notice the list is still readable — the breadcrumb carries the full path, the header carries the narrow subject, and the list shows only what matters at this level. No IDE-style 5-level indent tower.
Zero-downtime patterns, rollback, version control, backfills.
Mobile has 32px less horizontal space per indent level, so the cap tightens to 0 (only top level inlines). Breadcrumb collapses to ← Backend with the full path compressed to the right. Every depth-1 subtopic becomes a drill chip. Activity strip still shows under the header.
Go services, database, queues, cache. Owned by platform team.
User tapped Database → now at Backend › Database. Breadcrumb compact shows ← Database for fast single-tap back. Depth 1 children (Postgres, MySQL, Redis) again become drill chips.
Postgres primary, MySQL legacy, Redis cache — schemas, migrations, replication.
Inline cap stays at 2 (desktop) / 1 (mobile). Team hub depth grows, reading capacity does not. Subtopics beyond the cap render as drill-in chips — single-row, no toggle chevron, right-side → affordance.
Drill-in is in-place rebase, not route push. Clicking a drill chip re-parents the list without a page transition. The container, header, and list animate as one content replace (container morph principle). Back/forward is via the breadcrumb; browser history pushes as a secondary effect so deep-links still work.
Breadcrumb is first-class. Sticky at top on desktop, ← parent compact on mobile. Every segment is a click target. ⌘← keyboard shortcut navigates up one level. Typing Esc resets to topic root.
The global bar is the depth escape hatch. 100+ subtopic team hubs need fuzzy search across all depths. That's the command bar's job (§24), not a separate picker modal. When the bar opens from inside a topic view it should bias toward in-topic matches first, then widen to global. See §24m “Topic-scoped recall — conceptual northstar”. Mobile uses the prod MobileTreeSheet ( topic-tree-panel.tsx) for hierarchical jump + the bar for search; no extra picker sheet.
/ search crosses all depth. In-topic search always queries every descendant, not just the currently-visible level. Matching memories force-expand their containing subtopic. Clearing the query restores prior drill and expand state.
Row-level author attribution. Every memory row in a team hub shows an 18px avatar of the pusher to the right of the title. Hover = full name. Personal hubs keep that slot empty for You, but reserve it for agent actors so the row still stays content-led.
Recent activity strip under every header. Team-hub-only. Last 7 days · +18 · @A @B @C below the subtopic header. Anchors “where's the team working right now” intuition.
Hub overview is a side mode. Separate view for onboarding / orientation. Flat-2 grid of depth 0 cards with depth 1 children as preview chips. Does not replace drill-in — drill is for working, overview is for orienting.
Dream merge suggestions live in §36 Inbox, not here. The dream engine may detect redundant sibling subtopics and propose a merge, but that surfaces as a Review in the Inbox (see §36 “Low-confidence / structural proposal” row pattern), never as a banner or card inside the topic detail view. Topic navigation stays silent.
Drill state persists per user per topic. LocalStorage key: memax-drill-{hubId}-{topicId}. A team member's drill state is their own — other members seeing the same topic start at their own last-visited subtopic, not yours.
Every Topic row in the data model carries an icon field (Lucide icon name). Render size varies by context:
- 28px — focus header (the topic / subtopic currently being viewed)
- 16px — inline subtopic header row
- 14px — drill-in chip (subtopic beyond inline cap)
- Memory rows do not get the topic icon — they already carry their own content-type icon via MemoryRow.
- Unknown icon name → fallback to FileText. Handled by TopicIcon helper.
See NAV_MOCK_LARGE(Auth & Security with Shield) — every inline subtopic header, drill chip, and focus title picks up its own icon. Unknown values fall through to FileText silently.
JWT tokens, OAuth flows, API key management, boundary enforcement.
A subtopic can hold hundreds of memories. Rendering all of them inline breaks scroll and DOM weight. The canonical pattern:
- Initial load 20 memories (DEFAULT_SUBTOPIC_PAGE_SIZE)
- “Show N more” at the bottom of the list loads the next 20
- Once expanded, show “Collapse” to return to the first 20
- Search results render all matches (pagination disabled)
- Production endpoint (gap): GET /v1/topics/:id/memories?subtopic_id=X&cursor=Y&limit=20
Drilling into a subtopic (or popping back) animates the content inside the container, not the container itself. Forward = new content slides in from +16px with cross-fade. Backward = from −16px. Breadcrumb segments animate with layout; focus header cross-fades.
- Duration: NORMAL (0.2s) from @memaxlabs/ui/tokens/motion
- Easing: EASE — cubic-bezier spring [0.16, 1, 0.3, 1]
- Transform-only: translateX + opacity. No layout properties animate.
- Reduced-motion fallback: wrap motion values in useReducedMotion() → skip translate, keep instant opacity swap.
- Same rules on desktop and mobile. Mobile does NOT use iOS-style route stack — it's still a container morph.
Engineering
All engineering knowledge — backend, frontend, infra, data, security.
This card documents what production already does. Desktop and mobile use different metaphors because opening a memory is a new surface, not a container morph (29n covers the morph case — same topic, different subtopic).
- Desktop — centered glass modal on top of the topic view. Topic stays visible behind at reduced opacity so the user keeps their place. Close = Esc / click outside / ✕. This already works; no kitchen animation spec needed.
- Mobile — push-in / push-out. The incoming surface pushes from the right edge, the outgoing topic view is pushed off to the left as a single coordinated motion (not a slide-over on top). Back = iOS edge swipe or back button, which runs the same motion in reverse. This is the transition Memax mobile already uses for topic→memory, hub→hub, and settings drill-down.
- Why document instead of design: the animation is already shipped and tuned. Earlier drafts of this card proposed a “slide-from-right” modal which conflicted with the existing push-in/out metaphor. Codex should not reimplement it — wire the existing prod transition into the new topic navigation surface.
Production references: the mobile page transition lives in the app shell (see packages/web/src/app/ layout + its page wrapper), and the desktop memory modal uses Radix's dialog primitives with our glass tokens.
Topics are silent about dreams. No “✦ N new” badges on cards, no row accents in detail, no delta headers, no “since your last visit” banners. Dream activity and decisions live in §36 Inbox. The topic surface is pure spatial browse.
Scale-aware main view, recursive at every level. One component renders the topics list. Three modes by directChildCount: A (≤20, rich grid), B (20-80, dense grid + search + Pinned), C (80+, virtualized list + search-first + Pinned/Recent). Same logic recurses inside any topic — a subtopic with 200 children renders Mode B at THAT level. Thresholds 20/80 are starting values; tune with usage. See §29a.
Detail is a focused layer, not a page. Breadcrumb → icon + name + full description → meta → memory list in one scrolling container. When the user drills into a subtopic, the header rebases to show THAT subtopic's own icon + description (see §29j focus rebase).
Orphan memories render at every depth. A topic can have direct memories not assigned to any subtopic. Same for subtopics — they can have direct memories AND children. Orphans render unlabeled at the top of each level, above the first subtopic group. The subtopic header row is the visual boundary — no “General” divider.
Inline cap at 2, drill past. Desktop shows 2 depth levels inline (top + one nested); anything deeper becomes a drill-in chip. Mobile caps at 1. Max depth is 5 (Topic model constraint) — all 5 levels are reachable through drill, not inline expansion.
Recall → topic bridge. Recall results group by topic_id when there are 2+ distinct topics. Clickable header row with match count. ✦ prefix only in the topic grid for first-visit discovery — NOT on the recall bridge row (topics are inherently dream-created, the marker is noise).
Borderless detail surface. Topic detail content sits on --background without a framing card. Matches memory detail treatment.
Mobile is structural. Single column grid. Large = full card. Medium = compact. Small = horizontal scroll pills. Tree trigger as discoverable row at top. Drill cap=1.
No counts on topic cards / subtopic headers / drill chips / picker results. No “47 memories”, no “3 subtopics”, no “5 subs · 142” on chips. The only number a topic surface carries is the last-touched timestamp— ambient temporal context, not metadata repetition. Sort, picker filtering, and pagination still use counts at the data layer; the UI just doesn't render them.
The global bar is the depth escape hatch. 100+ subtopic team hubs need fuzzy search across all depths. That belongs to the command bar's recall mode (§24m), not a separate picker modal. Inside a topic view, the bar biases toward topic-local matches first, then widens to global. No ⌘P subtopic picker modal; no mobile picker bottom sheet.
Icons at every depth. Topic.Icon (Lucide icon name) renders at 28px in the focus header, 16px in inline subtopic headers, 14px in drill-in chips. Unknown names fall back to FileText. Memory rows do not carry the topic icon — they have their own content-type icon. See §29k.
Memory lists inside subtopics paginate. Initial load 20 (DEFAULT_SUBTOPIC_PAGE_SIZE), “Show N more” loads next 20, “Collapse ” returns to 20. Search results render all matches. Production: GET /v1/topics/:id/memories?subtopic_id=X&cursor=Y&limit=20. See §29l.
No stats noise in focus headers. Do NOT render “{memoryCount} memories · {subtopicCount} direct subtopics · {levels}levels deep”. The list below IS the signal. The only header meta allowed is the team-hub recent activity strip (see §29j rule 7), because that's collaboration context, not metadata repetition.
Mobile hierarchy = prod MobileTreeSheet. Mobile has no ⌘P. The existing MobileTreeSheet in topic-tree-panel.tsx handles drill-down tree + any-level jump. Topic-aware fuzzy search lives in the command bar (§24m). The compact ← parent breadcrumb stays for single-step back. No separate picker or path sheet.
Drill animation is container morph. Content inside the container slides ±16px + cross-fades. Duration NORMAL (0.2s), easing EASE spring. Transform-only — no layout properties. Same rules on desktop and mobile. NOT an iOS-style route stack. Breadcrumb segments animate with layout. Reduced-motion: skip translate, keep instant opacity. See §29n.
Memory row → detail uses the existing prod transitions. Desktop: Radix dialog in a centered glass container; topic view stays behind at reduced opacity. Mobile: push-in / push-out (incoming surface pushes from the right, topic view shifts left as one coordinated motion). Both are already shipped in prod — kitchen 29 does NOT spec a new animation, it just documents which existing transition to wire. See §29o.
Last-touched timestamp on every topic card / row. Neutral fg-3 small text, NOT a badge. Encompasses both human push and dream organization — no source attribution. The default sort key. Source-agnostic by design: users don't care who touched it, they care it's recent.
Three pagination boundaries. L1: top-level topics — cursor pagination on /topics page; Mode C virtualizes. L2: subtopics within a level— inline render first 20, “Show more subtopics” progressive disclosure; auto-switch to search-first when count > 80. L3: memories within a subtopic — DEFAULT_SUBTOPIC_PAGE_SIZE = 20, “Show N more” loads next 20, “Collapse” returns. Each boundary is independent and uses its own cursor.
Lazy load by drill — never recurse. Loading a topic detail fetches that topic's direct subtopics + first 20 orphan memories. Drilling into a subtopic fetches THAT subtopic's direct children + 20 orphans. Never preload nested grandchildren. A 5-level tree with 100 things at each level = 5 fetches as the user drills, not 10 billion items upfront. Counts come from denormalized columns on Topic, never from counting child rows.
View toggle override. Top-right of the topics view: 3-icon toggle (grid / dense / list) that lets the user override the auto-mode. Choice persists in localStorage per hub. Default = auto (mode selected by directChildCount). For power users who want dense list on a small personal hub. See §29a-toggle.
Topic card chips drill directly. In Mode A (rich grid), each topSubtopics chip is its own tap target. Card body tap → drill to the topic root. Chip tap → drill straight to that subtopic, skipping the topic root view. Both use the SAME §29n container morph. Chip click must stopPropagationso the parent card click doesn't also fire. See §29a-mode-a.
Scale is recursive, locally. Drilling into a subtopic doesn't just swap content — the new root re-computes its own Mode A/B/C from its own directChildCount. A topic with 12 children renders Mode A at the root AND Mode B inside a subtopic that has 80 grandchildren. See §29-scale-nested.
Orphans paginate independently. Orphan memories (rule 4) get their own L3 boundary with its own Show-more cursor. When orphan count > 80, the orphan section switches to dense-list-with-search — a local Mode C independent of the subtopics list below. See §29-orphans-mega.
Every boundary has a skeleton. L1 first paint renders topic card skeletons matching Mode A/B/C row shapes. L2 Show-more appends 4 subtopic-header skeletons in place. L3 Show-more appends 8 memory-row skeletons. The §29n drill morph cross-fades outgoing content into skeletons of the incoming shape at 0.5 opacity, then swaps in real data — prevents empty-container flash. See §29-loading.
Search lives in the command bar, not in the topics view. Mode B and Mode C do NOT render a local search input. Filtering the topic list is the §24 command bar's job — when the user is on /topics and hits ⌘K, the bar's recall mode is scoped to topic matches across the current hub. Mode B/C header rows show a quiet “⌘K to search” hint (desktop) beside the sort dropdown; mobile relies on the already-visible bar. One surface per job, no duplicate search affordances. Two search boxes on one screen force the user to guess which one filters which scope — don't do it.
Pin = corner icon + shared-layout morph. Every topic card / row carries a Pin icon top-right. Empty: text-fg-4 outlined, revealed on hover/focus (desktop), always visible (mobile). Filled: text-fg-1, rotated 45°. Toggle click → stopPropagation + framer-motion layoutId morph from the All section to the Pinned section (or back), NORMAL (0.2s) EASE spring. Pinned section collapses via AnimatePresence when empty. Per-hub localStorage persistence. Matches Linear/Notion/Apple Reminders pattern. See §29-pin.
Long-press / right-click → topic context menu. Three items: Pin/Unpin (toggles, matches corner icon state), Rename, Forget (destructive, oklch(0.55 0.2 25), opens confirm dialog in prod). Desktop trigger: onContextMenu. Mobile trigger: touchstart + 400ms timer, cleared on touchmove / touchend. Mobile fires navigator.vibrate(10) on menu open. Keyboard: focus row + P for pin, Shift+F10 / Menu key for context menu. Prod wraps in Radix DropdownMenu with modal=false so focus stays on the topic list. See §29-context-menu.
Subtopic row has two tap targets: chevron and body. Chevron click → onToggle(id), expand / collapse in place, stays on current page. Row body click (icon + name + description) → onDrill(id), rebases the container to that subtopic via the §29n morph. Two separate <button>s in one flex row with items-start so the chevron stays top-aligned when the body grows (otherwise items-stretch + items-center shifts the chevron down on expand). Both buttons carry aria-label; chevron sets aria-expanded. Tab order: chevron → body → next row. Matches Notion toggles, Linear nested projects, Finder list view, Radix Accordion. See §29-expand.
Expand / collapse animates chevron + content height. Chevron rotates rotate(0deg → 90deg) over FAST (0.15s) EASE. Content region is wrapped in framer-motion <AnimatePresence> + <motion.div> with { height: 0, opacity: 0 } → { height: 'auto', opacity: 1 }, duration NORMAL (0.2s), easing EASE. overflow: hidden on the wrapper clips child padding during animation. Nested subtopics compose their own AnimatePresence inside — no special handling needed. useReducedMotion()from framer-motion gates both the chevron rotation and the height animation: when true, both are instant — opacity crossfade stays (doesn't trigger vestibular issues, matches Apple HIG Reduce Motion). See §29-expand.
Inline expand has an escape hatch at INLINE_EXPAND_THRESHOLD = 100. Expanding a subtopic inline is for quick peek, not sustained reading. While its memory count is ≤ 100, SubtopicMemoryListrenders “Show 20 more” + “Open full view →” side-by-side at the bottom of the list (L3 pagination). Past 100, “Show more” disappears and “Open full view → (N memories)” is the only footer action — nudging the user to drill via §29n instead of paginating endlessly in place. The escape hatch wires to the same onDrill(subtopicId) handler as the row body button (rule 28), so chevron-expand + body- drill + escape-hatch-drill all compose cleanly. Orphans don't get this hatch — they have no drill target; instead they switch to search-first local filter when count > 80 (rule 23). See §29l + §29-expand.
LEGACY — delete topic-tree-panel.tsx during migration. The desktop sidebar tree (packages/web/src/components/features/topic-tree-panel.tsx) and its MobileTreeSheet duplicate what the new kitchen model already gives us: hierarchy rendering → chevron expand (rule 28); any-level jump → command bar §24m; breadcrumb anchor → topic detail header. Codex should delete both files after the migration, but not before §24m is wired (the bar must be topic-aware with activeTopicId / scope="hub" scopes — otherwise users lose any-level search with no replacement). Ordering constraint: 1) wire §24m → 2) delete topic-tree-panel.tsx + MobileTreeSheet → 3) update all call sites (hub page, topic page, mobile dock). Until then, keep the legacy tree running in parallel — it still works, it's just redundant with the new model.
Topic fades as scaffolding, memory stays content. Topic / subtopic header rows render as text-[14px] with state-driven contrast: text-fg-2 regular when collapsed (fades as scaffolding — "this is a signpost, keep scanning"), font-medium text-fg-1 when open ("you've committed to this branch"). Memory rows stay text-[14px] text-fg-1 regular always — content is king, chrome (structure) is background. Memax design DNA: the reading target should always be prominent; the scaffolding should recede until you ask it to commit. Same font size for both (14px) — no size delta, industry reference is Linear / Notion / GitHub where list items share the same size and differentiate via weight + state. Prod memory-row.tsx:548 has font-mediumon memory titles — that's the divergence to fix (drop the font-medium, match the kitchen). Differentiation between topic-open and memory comes from the open-state icon + weight combination, plus rule 33 (icon presence). See §29-hierarchy.
Content icon: doc-like types render nothing, non-doc types render a trailing badge. The split is doc vs non-doc, not text vs everything else. Doc-like (no badge): text, pdf, markdown, code — all reading content, behaviorally the same when scanning. Non-doc (trailing h-3 w-3 text-fg-4 badge via MemoryContentTypeBadge): image, link. These are behaviorally different — scanning for "that screenshot" or "that URL I saved" is a different task than scanning for "that decision I wrote down". The rarity of non-doc rows in a typical topic is exactly what makes the badge valuable — it marks "this is not reading content" without shouting. Topic row always has its leading TopicIcon; memory row usually has nothing on the left. The presence/absence of leading icon is the strongest anchor-vs-content signal in the list — pairs with rule 32 to carry hierarchy without extra chrome. For team hubs, author avatar / agent icon still occupies the leading slot (attribution > content type in team context). Matches GitHub issues, Notion pages, Linear tasks — all of which drop "default type" icons. Prod divergences to fix: renderLeadingIdentity() fallback at memory-row.tsx:340 should return null for doc-like types, and the trailing isRichContentType check at memory-row-presentation.ts:144 should drop pdf from its whitelist (keep only image + link). See §29-hierarchy.
Memory rows never show summary in list view. Descriptions are a topic-header affordance (focus header shows full description; expanded subtopic header shows description via descriptionStrategy). Memory rows in the topic surface are single-line: title + optional trailing content badge + age. No summary, no second line, no truncated preview. Summary content lives in the memory detail view where the user has opted into reading it. Prod showSummary currently includes "topic" in its surface whitelist at memory-row-presentation.ts:66-71 — that's the divergence to fix. Recent feed keeps the summary (different rhythm, different surface).
42. Topic Icons
Proposal — how auto-generated topic identity would look end-to-end. Today every topic renders as Folder; this preview shows the full system live so you can decide whether to greenlight.
Scan cost: O(n) — every label must be read
Scan cost: O(1) — color lookup before label is read
Signature violet (290°) is reserved for Memax chrome — never assigned to topics. Tile = 14% chroma mixed into background; stroke = full accent at the icon.
You can find the blue ones (code), green ones (health/money), amber ones (food/triage) without reading a single label. That's the whole point.
LLM receives category list + icon names in its prompt, routes by semantic match. Unknown returns fall back to Folder or emoji.
{ icon: "🍜", accent: "coral" }{ icon: "🐕", accent: "honey" }{ icon: "🚀", accent: "indigo" }{ icon: "🔮", accent: "plum" }Frontend detection: icon.length ≤ 4 && !ICON_MAP[icon] → render as emoji span inside the same tile chrome. Keeps niche topics playful without exploding the curated set.
| Topic name | Preview | LLM output |
|---|---|---|
| Caching Strategy | { icon: "cpu", accent: "sky" } | |
| Recipe ideas | { icon: "utensils", accent: "amber" } | |
| Investor calls | { icon: "handshake", accent: "moss" } | |
| Auth refactor | { icon: "shield", accent: "indigo" } | |
| Ramen spots in Tokyo | 🍜 | { icon: "🍜", accent: "coral" } |
| Dream engine notes | { icon: "sparkles", accent: "plum" } |
Left: dense list variant with 32px tiles. Right: topic cloud showing how accents alone carry category identity even without reading labels — blue cluster = code, amber cluster = food / triage, moss = money / ops.
43. Shell v2 — three-pane desktop, drawer + push-stack mobile
Plan 19+ frontend architecture. Left rail (Brain / Memories / Agents / Inbox) + contextual secondary panel (280px, matches existing TopicTreePanel) + main view. Glass surfaces, scarce signature accent, MemaxLogo for brand. Onboarding plan 18 nests as pinned region in Memories Overview.
Three zones: 56–196px LeftRail (glass) + 280px contextual SecondaryPanel (glass-tinted; hidden when tab has none — Brain) + flex main view. Signature is scarce: only the hub avatar (◐), the active tab, the Save CTA, and system_notice accent. Brand mark uses <MemaxLogo /> in neutral foreground per kitchen 11 canonical rule.
Personal hub
47 memories · 12 topics · 3 dreams this week
Hi, Jiahao. Drop something below — or connect an agent and let it dump for you.
Topic folder cards
plan 20 previewOn the topic detail page (`/h/<slug>/topics/<id>`), folder cards sit at top with subtopics; memory cards below. Folder card is topic icon + title + description. UI iterates; data shape (memoryCount, subtopicCount, preview, accentColor) stays so we can layer in moves without re-plumbing data.
Fresh memory
7 days · all actorsHatch Jarvis PR stack
Foundation: PR #3370 (p1, metering crate) → PR #3374 (p2, credit-watcher daemon). Gating PRs stacked on foundation: PR #3126, PR #3127, PR #3128.
Topic explorer design north star
Floating glass panel with 12px insets, 296px width, GLASS_ENTER animation. Containers are chrome; brand color goes on affordances inside.
How recall works
Type a question, hit ↵. memax pulls from everything you've dumped — across hubs, agents, file types — ranked by semantic match plus recency.
What dreams do
Every night memax connects the dots — surfaces contradictions, merges duplicates, organizes loose memories into topics. You wake up to a tidier brain.
Connect Claude Code in 30s
npx memax-cli setup --agent claude-code. One command syncs your settings + memories. Memory flows both ways.
6 seed memories. Admin-configurable in production (plan 23).
New surface="card" preset on the existing MemoryRow component (plan 20). Halo on hover is foreground-tinted, NOT signature — depth without color cast (Manus library #e7ebc0ff). Borges (isQuote: true) is the only card that wears signature. The first cell is the + Compose entry with ⌘⇧↵.
⌘⇧↵
Memory as Mirror
"We are our memory, we are that chimerical museum of shifting shapes, that pile of broken mirrors." — Jorge Luis Borges
Hatch Jarvis PR stack
Foundation: PR #3370 (p1, metering crate) → PR #3374 (p2, credit-watcher daemon). Gating PRs stacked on foundation: PR #3126, PR #3127, PR #3128.
Topic explorer design north star
Floating glass panel with 12px insets, 296px width, GLASS_ENTER animation. Containers are chrome; brand color goes on affordances inside.
How recall works
Type a question, hit ↵. memax pulls from everything you've dumped — across hubs, agents, file types — ranked by semantic match plus recency.
What dreams do
Every night memax connects the dots — surfaces contradictions, merges duplicates, organizes loose memories into topics. You wake up to a tidier brain.
Connect Claude Code in 30s
npx memax-cli setup --agent claude-code. One command syncs your settings + memories. Memory flows both ways.
No bottom dock. Hamburger summons the rail (slides in from edge, ~75% width, dim backdrop closes). Tapping a topic / memory pushes a full-screen route; ← or swipe-back pops. Floating ⌘K scan stays bottom-right thumb-zone. Refero: Notion mobile (apps/64), Linear mobile (apps/204).
Hi, Jiahao.
Hub slug is unique only within (hub_type, owner_id) in packages/server/internal/model/hub.go, so URLs need the owner namespace to disambiguate team hubs across owners.
/h/<owner-slug>/<hub-slug>/memories ← Memories Overview (default) /h/<owner-slug>/<hub-slug>/topics/<topic-id> ← Topic detail /h/<owner-slug>/<hub-slug>/memories/<memory-id> ← Memory detail v2 (route, not modal) /brain ← BrainView (existing) → agentic v2 later /brain/sessions/<session-id> ← Future agentic conversation thread /agents ← ConnectedAgent grid /agents/<id> ← Agent detail / config /inbox ← InboxControl (existing renderer, new route)
Adds users.slug column (unique, indexed). Migration shipped in plan 19 alongside the route shell.
Tokens reused. Glass surfaces (--glass-start/end/border), surface-1/2/3 (calibrated foreground opacities), fg-2/3/4 (text opacities), --signature-muted for tinted accents. Brand mark via <MemaxLogo /> from @memaxlabs/ui. Curve scale: rounded-3xl outer shells, rounded-[20px] cards, rounded-lg rows.
Migration. Cookie-flag hybrid via memax_shell=v2. Route by route, flag flips when stable. Old layout removed once all routes migrated.
Plans this informs. 19 (shell foundation), 20 (memory card preset + Compose), 21 (memory detail v2 + recall-time bug), 22 (mobile push-stack), 23 (seed memories admin), 24 (agents + inbox routes). Each ships in 2–3 codex rounds.
40. Borderless Redesign (proposal)
Two phases. Phase 1 = visual frame only (borderless shells, unified headers, loading consistency) — ships first, no backend work. Phase 2 = lifecycle signals (age-based halo, dream-delta on breadcrumb) — pending product-semantics + data-flow read before design locks. Row internals are unchanged in both phases.
1. Recent feed · desktop
Phase 1 · ships firstOuter card disappears; header grammar and row internals stay identical.
Server-side eval harness stays green on the recall threshold…
Memax email invites + admin notifications, auth deferred to Ziyang…
Worker cache eviction on redeploy caused 800ms cold starts…
Server-side eval harness stays green on the recall threshold…
Memax email invites + admin notifications, auth deferred to Ziyang…
Worker cache eviction on redeploy caused 800ms cold starts…
2. Mobile topic list · mode C
Phase 1 · ships first~32px horizontal reclaimed. Pin moves to leading position for pinned rows only; unpinned look as today.
5. Loading skeletons
Phase 1 · ships firstSkeleton geometry mirrors final row geometry within ±2px. No card-shaped skeleton on borderless surfaces. Single animate-content-ready fade; no per-row pop-in.
3. Lifecycle signals on a memory row (preview)
Phase 2 · previewHalo = pure freshness (age-based, < 5min breathes, < 24h static, else off). Dream-touched signal rides on the existing topic breadcrumb chip: signature tint + leading ✦ replaces a separate chip. Processing star is the existing memax state.
rowAccent left-border — never again—4. Topic cards with dream delta (preview)
Phase 2 · previewChip aggregates adds + reorganizations since viewer's last topic visit. Clears when viewer opens the topic. Server-owned; requires topic_visits infrastructure (see Phase 2 brief).
Server-side recall harness, 59 queries, graded metrics.
Team hubs, access model, push routing.
Server-side recall harness, 59 queries, graded metrics.
Team hubs, access model, push routing.
What stays unchanged across both phases: row padding, title / summary typography, three-tier attribution, multi-select, desktop drag grip, long-press on mobile, processing star, search highlight, hub pills, batch toolbar, memory detail page, desktop topic grid modes A/B.
Phase 1 → Phase 2: Phase 1 is purely presentational; it leaves memory-row-presentation.ts as the integration point so Phase 2 can resolve halo + breadcrumb tone without touching row internals. No lifecycle state leaks into Phase 1.
41. Lifecycle + Dream Delta — E2E Flow
What a user actually sees as memax captures, organizes, and shows their memories over time. Maps to shipped behavior in commit 11f83406. Two distinct signal families — notifications (attention/review events) and lifecycle (passive browse-context changes) — never overlap.
created_at: <5min breathing → <24h static → off.pending_dream_action, clears on topic visit.delta_since_visit, clears on topic open.A memory arrives — the halo lifecycle
User pushes or an agent captures a memory. A soft signature halo blooms behind the indicator so the row stands out while it's still fresh. Pure time decay from created_at — no server state, no mutations.
state-slow-breathe.Dreams run overnight — delta appears
Between visits, the dream engine organizes memories. When the user returns: topic card gains an aggregate chip; moved memory's breadcrumb tints signature color. Both resolved server-side, scoped to last topic visit.
Server-side recall harness, 59 queries, graded metrics.
Server-side recall harness, 59 queries, graded metrics.
memory.lifecycle.pending_dream_action is non-null. Topic card chip counts are non-overlapping: +3 inbound from unassigned, 2 inter-topic moves.User taps the tinted chip — reason
Curious about what changed, user taps the signature-tinted breadcrumb. Popover anchored to the chip explains what dream did, why. Popover reads from the memory row's already-loaded data — no extra API call.
run_id: "3f4a…",
action_type: "organize",
at: "2026-04-16T23:14:02Z",
from_topic: { id, name: "Eval infra" },
to_topic: { id, name: "Retrieval eval" },
reason: "Closer semantic fit…"
}
from_topic: null and the popover renders verb + reason only.User visits the topic — everything clears
User taps the topic name. POST /v1/topics/:id/visit fires after 300ms dwell (never prefetch). Client invalidates caches; next fetch returns pending_dream_action null and delta_since_visit null. Both signals gone.
Server-side recall harness, 59 queries, graded metrics.
markVisit(topicId) after 300ms dwell.Server-side recall harness, 59 queries, graded metrics.
topic_visits.last_visited_at; visit timestamp is now past the action's created_at, so the scoped lookups return null + empty summary. No client mutation needed beyond cache invalidation.Memory detail — durable dream history
Even after scan-surface signals clear, user can learn what dream did. Detail page's provenance strip carries dream_history (last 10 actions, unscoped by visit). Reads like quiet context — not a callout.
TopicChip primitive — same component as the row breadcrumb, so topic identity stays consistent. Organize-from-unassigned uses a CircleOff pseudo-chip.- Scoped to viewer's last visit of the memory's current topic.
- Shows: row breadcrumb tint + tap popover.
- Clears: on
POST /v1/topics/:id/visit.
- Unscoped by visit. Last 10 actions, reverse-chronological.
- Shows: low-emphasis provenance lines on memory detail.
- Clears: never. Durable audit for "when did this move?"
- Per-topic aggregate since viewer's last visit.
- Shows: topic card trailing chip
✦ +N · M reorganized. - Clears: on topic open (same visit write).
Notifications ≠ lifecycle — the split, visually
Contradictions, dream-run completion, topic-review suggestions, hub invites — all fire as notifications (attention events). Lifecycle only surfaces the four passive actions that don't need user review.
seen_at tracks per-user. Lifecycle doesn't touch this surface.contradiction, dream_run_completed, topic-merge reviews stay notification-only. Lifecycle only renders organize / merge / archive / restructure — the server query enforces it.- Halo on every fresh memory (no API — pure age math).
- Breadcrumb tint on any memory dream moved, until you visit the topic.
- Topic card delta chip showing aggregate activity since last visit.
- Durable dream history on memory detail — always answers "when did this move?"
- No per-memory acknowledgement table — no client mutation on scroll.
- No ping/toast/inbox entry for passive dream organization.
- No halo beyond 24h — no indefinite shimmer.
- No cross-tenant topic name leak — out-of-scope topics render verb-only.
- No contradiction verb in lifecycle UI — contradictions are a notification.
36. Inbox
Current shipped notification surfaces in prod. This section mirrors today’s bar and inbox states so you can inspect the real kind coverage without the older north-star mock.
The current system separates three things cleanly: live dream running in the bar, durable notification receipts, and durable decision rows in inbox.
The visuals below are intentionally the shipped ones: current bar card, current inbox row shell, current per-kind bodies, current scaffold/fallback treatment.
dreaming
dream_complete
update
info
success
error
Decision rows
These are the actionable inbox kinds shipping today.
Both memories discuss PKCE but give conflicting guidance. Keep one, keep both, or dismiss.
These topics overlap heavily and should likely be consolidated into a single topic.
The child topic fits better under Postgres than its current parent.
Annie invited you to memax-test (role: contributor)
Receipt rows
These render with dismiss-only affordances in the inbox. dream_run_completed also appears as a bar push first.
Lina joined memax-test (role: viewer)
The API will restart during the maintenance window tonight.
View statusChris sent you a Memax invite for Growth.
Open invite linkThis is the durable inbox receipt after the bar push.
Scaffold / fallback rows
These kinds are typed and rendered, but still shipped as scaffold/fallback states rather than fully produced polished flows.
Stale review kind exists in the current system, but only as a scaffolded/fallback row.
Shipped today as a scaffolded stale-review row only.
Low-confidence review kind exists in the current system, but only as a scaffolded/fallback row.
Shipped today as a scaffolded low-confidence row only.
33. Memory Metadata
Topic location, classification framing, tags, and move flows. North star for memory detail page metadata.
Assigned — shows topic name, click to move
Unassigned — dashed border, invite to assign
Starts from hubs. Click a hub → see its topics. Topics with children drill deeper. Topic rows are the confirm target; hub rows are browse only in the single-memory move flow. Same slide pattern as 33c. Try: Your Topics → Architecture → Backend.
Classification framed as AI output: "✦ memax classified this as". Plain-language sentence only. Boundary (private) hidden — only shown when shared.
Topic location always visible (dashed CTA). Same "memax classified" framing. No "private" noise.
Mobile tree panel as bottom sheet. Same drill-down as move picker. Tap a parent → drill into children. Shows memory counts. Try: Your Topics → Architecture → Backend.
Same component, mode="select". Hub rows drill into that hub's topics. Topic rows confirm the destination (checkmark). Same bottom sheet container. Identical navigation, different action on tap.
1:1 Topic Relationship: Each memory belongs to exactly one topic. memories.batchMove is the authoritative user-move contract — atomic DELETE + INSERT on memory_topics, not confidence-gated. All user surfaces (picker, batch toolbar, detail route, drag-and-drop, CLI) route through it via useMemoryMove on React Query useMutation.
AssignMemoryToTopic: confidence-gated auto-assignment used only by ingest + dreams workers. Replaying at equal confidence is a no-op by design so earlier user intent sticks.
Unified DrillDownTree:One component, two modes. mode="browse": tap navigates to topic page (tree panel). mode="select": tap picks destination (move picker). Container varies: bottom sheet (mobile), popover (desktop move), pinned sidebar (desktop browse uses expand/collapse instead). Drill-down everywhere except pinned desktop sidebar.
"memax classified" framing: Keep the label, but the body is a plain-language sentence rather than a taxonomy control. Tags remain editable because they are directly useful to the user.
Boundary (private/shared): Hidden when private (the default ~95% case). Only shown as a badge when shared. Moved to provenance strip context where it semantically belongs.
34. Landing + Onboarding
Landing page refresh (headlines, demo, CTAs) and post-login onboarding flow design.
Landing Page
Your memory. Every AI.
Stop re-explaining yourself every time you switch tools.
One memory. Every agent.
Your context follows you — across Claude, Cursor, ChatGPT, and every AI you use.
Your AI forgets you. memax doesn't.
Shared memory for humans and AI agents.
Based on a real moment (memax memory aa92bcea): Claude discovered a memax API gap while helping Jiahao, self-flagged as unverified, and pushed a structured issue directly to the engineering hub for Ziyang — who verified and shipped the fix 43 minutes later. No Jira. No Slack DM.
Why this is the killer demo: (1) it's real; (2) it shows AI as a responsible teammate, not just a retrieval tool; (3) source_agent: claude-aiis the trust mechanism — memax makes AI contributions traceable and auditable, not a black box; (4) cross-agent + cross-person + zero-friction handoff is a product-driven story, not generic “AI good”.
API gap: memax_recall missing hub_id + author filters
Can't scope recall to a single hub or author. Suggested fix: add filters to /v1/recall query params.
Claude pushed an API gap yesterday — recall is missing hub_id + author filters. Tagged p1, needs verification. Open in memax →
Your AI found a bug. Your teammate got the fix request. No one opened Jira.
Below the handoff demo, above the CTAs. Four distinct value props the demo alone doesn't communicate explicitly: dump-from-anywhere (surface coverage), ask-from-anywhere (the headline made literal), self-organizing (Dreams, memax-unique), team memory (the handoff payoff stated plainly).
Quiet treatment — no card chrome, no borders, no shadows, just a tiny icon (h-4 w-4 text-fg-3) + 14px medium title + 13px fg-3 description. 2×2 on mobile, 4-col on desktop. Reinforcement strip, not a feature wall. The Claude→Ziyang demo above does the storytelling; this row covers the audiences (non-tech, team buyers) the demo doesn't serve as well. We deliberately do NOT add a competing tagline below the headline — the current sublineFull( “Stop re-explaining yourself every time you switch tools.”) is sharper because it has a single emotional hook (the pain). Two sublines fight each other; pick the pain hook over the feature list.
Dump from anywhere
Every surface is an entry point. CLI, web, every AI you use.
Ask from anywhere
Your context follows you. Any agent, instant recall.
Self-organizing
You dump. Memax organizes.
Team memory
Your teammate’s AI knows what yours knows.
Post-Login Onboarding
Inline cards replace the empty dashboard. Steps animate away as completed — container morphing, not overlays.
Dump
Throw anything in — text, files, or let your agents capture it automatically. No organizing needed.
Ask
Ask from anywhere — CLI, web, or your AI agent. Get one clear answer, not a list of results.
Discover
Come back tomorrow. memax organizes your knowledge into topics and surfaces what changed.
How do you work? (click to select)
I live in the terminal
CLI-first setup
I work through AI agents
MCP-first setup
I prefer the web app
Start right here
One command configures all your agents.
Push a preference, a fact, a decision your agents should know.
This works from any agent, any device.
Claude Code
Connected via MCP
Remember that I prefer dark mode and use vim bindings
Claude Code
Got it — saved to your memax. All your agents will know this now.
What are my editor preferences?
Claude Code
You prefer dark mode and use vim bindings.
This works from Cursor, Copilot, or any connected agent.
The bar is your entry point. Just type.
Press Enter. Remembered.
You deploy to Vercel, using pnpm and TypeScript.
Or share a join link — one click to join.
Ziyang's Cursor asked
“Where do we deploy staging?”
Staging is at staging.memax.dev. Deploy via fly deploy.— from Jiahao, engineering hub
Your teammate's agent just recalled your knowledge. No Slack DM, no wiki search.
End-to-end walkthrough
Click through the full inline journey so you can see the state machine in motion. Welcome → preference → track → team prompt → completed. Each phase morphs in via AnimatePresence with the memax NORMAL + EASE spring.
The full first-run journey as a single connected demo. Stepper at the top tracks the 5 phases; each phase morphs in via framer-motion. Click “Get started” → pick a preference → see the matching track → optional team prompt → completion. Reset button replays from the top.
Reuses the existing track demos (CLITrackDemo, AgentTrackDemo, WebTrackDemo) inside the track phase, so this card is the connective tissue not a duplicate of those flows. Stepped modals (§34-modal-agents + §34-modal-hub) are demoed separately — in prod the “Create a team hub” button on the team prompt phase launches §34-modal-hub; here the demo advances directly to completed for clarity. Codex should wire the actual modal handoff at production implementation time. State machine columns: onboarding_current_screen tracks the active phase, onboarding_completed_at gets stamped at the completed phase, and onboarding_dismissed_at allows resume from any cursor (see §34-state).
Welcome to memax
Your memory layer across every AI. Dump anything in, ask from anywhere, come back tomorrow to find it organized.
Stepped modals — 2 only
Memax design DNA: container morph over overlays, inline wins by default. Modals are reserved for two flows that are settings-heavy + one-off + high-stakes + sequential: connecting agents, and creating/joining a team hub. Everything else is inline.
Invoked from the inline Agent-track prompt OR from Settings → Integrations. Four steps: Detect installed agents → Run one command with live terminal progress → Verify MCP handshake per agent → Done.
Why modal: 5–6 agents to configure in sequence, user needs focus on terminal progress, one-time flow, high information density. Reuses the settings-dialogpattern already in memax (same chrome, Esc to close, same glass treatment). Click “Copy & run” below to see the progress animation.
Connect your agents
memax works across the AI tools you already use.
Detected on this machine:
Claude Code
found
Cursor
found
GitHub Copilot
found
Windsurf
not installed
ChatGPT Desktop
found
Gemini CLI
not installed
Invoked from the inline team prompt, the hub switcher → “Create hub”, OR from an invite link landing. Five steps: Intent (create vs join) → Details (name + description + visibility; or paste invite link) → Invite teammates (skippable) → First push to prime the hub (skippable) → Done.
Why modal: creating an org boundary is high-stakes (name is public, members have memory access), multi-field form needs focus, the first-push priming step needs user attention without dashboard distraction. Steps 3 and 4 are explicitly skippable — invites and first push can happen anytime.
Team hub
Shared memory between people and agents. One place your team remembers from.
Create a new hub
Start fresh. You can invite teammates next.
Join an existing hub
Paste an invite link from your teammate.
Fast path — no tutorial
The best onboarding for users who live in agents isn't a tutorial. It's the product working for them before they visit the web app. This is a visual reference for Codex — not a card the user ever sees.
For developers who run npx memax-cli setup and close the terminal, the aha moment happens ambiently the next time they open an AI agent and the hook injects context. No guided tour required.
The first web app visit is voluntary and curious, not instructed. The empty dashboard isn't empty — Dreams have already organized imported memories into topics. Web users still get the inline onboarding cards; CLI users skip them entirely (first-run detection checks if the user already has memories on login and hides the welcome card).
npx memax-cli login
0:00GitHub OAuth. 10 seconds.
memax setup
0:10Auto-detects installed agents. Configures MCP + hooks. Imports existing config files: MEMORY.md, .cursorrules, CLAUDE.md, AGENTS.md.
✓ Imported 23 memories from 4 agents
0:45User closes the terminal and goes back to work.
Dreams organize imported memories into topics
— silent period —User is doing something else. The product is working for them.
User opens Claude Code on a new task
Next morningMemax hook injects context. Claude says: “based on your preferences, you use pnpm + TypeScript + Vercel.”
“wait, how does it know that?”
+5 secondsThe aha moment. No tutorial. No guided tour. The product just remembered. User opens the memax web app — curious, not instructed.
State machine — first-run detection + recovery
Backend tracks two timestamps on the user row: onboarding_completed_at and onboarding_dismissed_at, plus a resume cursor onboarding_current_screen. Frontend renders the appropriate surface based on which state the user is in.
Key recovery rule: dismissing is never permanent. The dashboard always renders real state, but a small “Finish setup” pill appears in the top bar whenever dismissed_at IS NOT NULL AND completed_at IS NULL. Clicking it resumes at the saved cursor. Completed users see “Onboarding tour” in Settings to replay.
not_started
onboarding_completed_at IS NULL AND dismissed_at IS NULL
Welcome card (screen 1) inline on empty dashboard
User clicks [Get started] → in_progress
in_progress
current_screen IN (preference, track, team)
Current inline card morphs in place (§29n container morph)
Track complete OR user dismisses → completed / dismissed
modal_agents
Agent track triggered agents modal
ModalShell with 4-step connect flow
Close / Open memax → back to previous inline state
modal_hub
Team prompt OR hub switcher → create hub
ModalShell with 5-step create/join flow
Close / Open hub → back to previous inline state
completed
onboarding_completed_at IS NOT NULL
Dashboard renders real state. No onboarding cards.
Re-entry via Settings → Onboarding tour
dismissed
dismissed_at IS NOT NULL AND completed_at IS NULL
Dashboard real state + small persistent 'Finish setup' pill in top bar
Click pill → resume at saved current_screen
CLI, MCP, and web are equal-class surfaces. Landing copy never frames memax as “developer-first. ” The web app is a legitimate primary surface for non-technical users, not a viewer for the CLI. Lead with the universal value prop (“Your memory. Every AI.”) and let CLI / Agent / Web appear as equal choices in the onboarding preference picker. See memax memory 18d58b0e.
Landing hero is the validated positioning. Headline: “Your memory. Every AI.” Subline: “Stop re-explaining yourself every time you switch tools.” CTA: “Give your AI a memory” + the one-line install command. Single-screen, static, Linear/Vercel restraint. No purple, no gradients, no glow.
Landing demo is the Claude → Ziyang handoff story. 3-panel horizontal strip (stacked on mobile): (1) Jiahao asks Claude about team hub activity, Claude discovers an API gap; (2) Claude self-flags as source_agent: claude-ai + needs-human-verificationand pushes a structured report to the hub; (3) Ziyang's Cursor surfaces it next morning, ships the fix 43 min later. Tagline: “Your AI found a bug. Your teammate got the fix request. No one opened Jira.” Shows AI as a responsible teammate + source_agent as the trust mechanism. See §34-demo.
Two-speed onboarding: fast path for CLI, inline for web. CLI users who complete npx memax-cli setup skip the welcome/preference/track cards entirely — first-run detection hides them if the user already has imported memories on first login. Their aha moment is ambient (the next agent session shows context injected by the hook). Web-first users get the inline cards: welcome → preference picker → track steps → optional team prompt. See §34-fast-path + memax memory 00e29042.
Inline cards by default, modals for settings-heavy flows only. Memax design DNA: container morph over overlays. Inline wins by default because it lets users experiment alongside the guidance without feeling interrupted. Modals are reserved for flows that are settings-heavy + one-off + high-stakes + sequential. By those criteria, exactly two modalsqualify: “Connect your agents” (§34-modal-agents) and “Create or join a team hub” (§34-modal-hub). Everything else — preference picker, track steps, dreams explanation, feature tooltips, “connect one more agent” nudges — stays inline.
Modals are invoked from multiple entry points. The agents modal is reachable from the Agent track inline card AND from Settings → Integrations AND from a top-bar “Connect more agents” notification. The hub modal is reachable from the team prompt inline card AND from the hub switcher “Create hub” option AND from an invite link landing page. Both modals are full-flows each time — they don't depend on onboarding state.
Onboarding is replayable, not one-shot. After completion, users can replay the inline sequence from Settings → Onboarding tour. Dismissing mid-flow is not permanent: a small “Finish setup” pill appears in the top bar until the user either resumes or explicitly marks setup complete. State machine: see §34-state.
Modal chrome matches settings-dialog pattern. Both modals use ModalShell: glass panel (oklch 0.96 opacity + 24px blur + 140% saturate + border), max-height 640px, overflow-y: auto on body, sticky header with step dots + step count, sticky footer with Back/Skip/Continue. Step transitions use framer-motion slide + fade (NORMAL + EASE), reduced-motion fallback to opacity-only. Esc closes; click-outside closes. Matches the existing prod settings-dialog.tsx treatment.
i18n: All strings need translation keys before production implementation. Kitchen has // i18n: TODO markers next to literal strings — Codex must replace them.
Landing: packages/web/src/components/landing/landing-full.tsx
Onboarding inline cards: new component in brain-view.tsx empty state (container morph, not overlay).
Modals: new OnboardingAgentsModal and OnboardingHubModal built on the existing settings-dialogpattern. Reuse don't recreate the modal chrome.
State machine: add onboarding_completed_at, onboarding_dismissed_at, onboarding_current_screen columns on the user table.
Super-notif approach — plan 18 RFC
Alternative to the preference-based track flow above. Onboarding is two notifications (founder note + checklist), rendered on /memoriesvia a pinned region. Reuses plan 17's notifications framework — no new table, no onboarding-specific SDK surface. See docs/plans/18-onboarding-journey.md.
Pinned on /memories via payload.pin_context=memories_hero. One-shot per user — not versioned on restart ( notifications_source_unique intentionally blocks replay). Dismissal is a single POST /v1/notifications/{id}/dismiss.
Hi, I'm Jiahao.
Your AI forgets you every session. You re-explain the same context, re-paste the same link, start from scratch every time you switch tools. It's exhausting.
memax is a shared brain that makes it stop. Dump anything — your agents pull what they need, your team sees what you saw, and a dream engine connects the dots while you sleep.
We ship every Friday. Things will break; tell us when they do.
— Jiahao
Come say hi in our community — coming soon
Each state at rest. Required items (connect_agent, first_memory, first_ask, five_memories) gate auto-resolve. Weakly-required items (first_hub_invite, first_dream) are discovery hooks — auto-resolve fires at 4/7 required.
State A — first visit · 0/7
Your first week
0 of 7 done · 0/4 required
Read the welcome note
90 seconds. Worth it.
Connect your first agent
Claude Code, Cursor, Codex — one command, memory flows both ways.
Dump your first memory
Type anything in the bar, hit ⌘↵. A wiki, a link, a half-thought.
Ask memax a question
Press ↵ instead of ⌘↵ — memax answers from everything you've dumped.
Dump 5 memories
memax needs a critical mass to start seeing patterns. Unlocks dreams.
Join or start a team hub
Shared brain with people you work with. Jump into an existing hub or start your own.
Let memax dream
Unlocks after you dump 5 memories.
State B — mid-flow · 3/7 (welcome + connect + memory done, 3/5 progress)
Your first week
3 of 7 done · 2/4 required
Read the welcome note
Connected your first agent
First memory saved
Ask memax a question
Press ↵ instead of ⌘↵ — memax answers from everything you've dumped.
Dump 5 memories
memax needs a critical mass to start seeing patterns. Unlocks dreams.
Join or start a team hub
Shared brain with people you work with. Jump into an existing hub or start your own.
Let memax dream
Unlocks after you dump 5 memories.
State C — collapsed strip · 3/7
State D — auto-resolve celebration (all required done)
Your first week
All set — memax dreams nightly now.
Read the welcome note
90 seconds. Worth it.
Connected your first agent
First memory saved
Asked your first question
5 memories — dreams unlocked
Join or start a team hub
Shared brain with people you work with. Jump into an existing hub or start your own.
Let memax dream
Dreams run nightly to connect the dots. Trigger one now to see it live.
You're all set up
memax dreams nightly now. See you tomorrow.
State E — all 7 done (pre-unmount)
Your first week
All set — memax dreams nightly now.
Read the welcome note
Connected your first agent
First memory saved
Asked your first question
5 memories — dreams unlocked
You're on a team hub
First dream complete
Single mount of <PinnedNotifications context="memories_hero" /> between <HubHeader /> and <RecentSection /> in topic-grid.tsx:219. Renders zero, one, or both notifications depending on state. Page rhythm is identical to today once the region is empty.
/memories
Your brain
Personal hub · 0 memories · 0 topics
Hi, I'm Jiahao.
Your AI forgets you every session. You re-explain the same context, re-paste the same link, start from scratch every time you switch tools. It's exhausting.
memax is a shared brain that makes it stop. Dump anything — your agents pull what they need, your team sees what you saw, and a dream engine connects the dots while you sleep.
We ship every Friday. Things will break; tell us when they do.
— Jiahao
Come say hi in our community — coming soon
Your first week
0 of 7 done · 0/4 required
Read the welcome note
90 seconds. Worth it.
Connect your first agent
Claude Code, Cursor, Codex — one command, memory flows both ways.
Dump your first memory
Type anything in the bar, hit ⌘↵. A wiki, a link, a half-thought.
Ask memax a question
Press ↵ instead of ⌘↵ — memax answers from everything you've dumped.
Dump 5 memories
memax needs a critical mass to start seeing patterns. Unlocks dreams.
Join or start a team hub
Shared brain with people you work with. Jump into an existing hub or start your own.
Let memax dream
Unlocks after you dump 5 memories.
Topics
Topics will appear after your first dream run.
Same component, different variant prop. Compact row sits in the Needs action bucket (checklist is a decision kind). Click the chevron to expand inside the existing InboxRow shell — no modal. Dismiss here or on /memories hits the same row; SSE propagates.
Compact row (Needs action bucket)
Your first week
3/7 · keep going to unlock dreams
Sits among other inbox rows. Click chevron → expand in place.
Expand-in-place (same InboxRow shell)
Your first week
3/7 · keep going to unlock dreams
Full journey from signup to auto-resolve. Each scene maps to a notification state transition. Transitions use memax NORMAL + EASE motion tokens. Celebration pulse = scene 8; the page returns to its normal rhythm at scene 9 after unmount.
signup → emit note + checklist
/memories
Your brain
Personal hub · 0 memories · 0 topics
Hi, I'm Jiahao.
Your AI forgets you every session. You re-explain the same context, re-paste the same link, start from scratch every time you switch tools. It's exhausting.
memax is a shared brain that makes it stop. Dump anything — your agents pull what they need, your team sees what you saw, and a dream engine connects the dots while you sleep.
We ship every Friday. Things will break; tell us when they do.
— Jiahao
Come say hi in our community — coming soon
Your first week
0 of 7 done · 0/4 required
Read the welcome note
90 seconds. Worth it.
Connect your first agent
Claude Code, Cursor, Codex — one command, memory flows both ways.
Dump your first memory
Type anything in the bar, hit ⌘↵. A wiki, a link, a half-thought.
Ask memax a question
Press ↵ instead of ⌘↵ — memax answers from everything you've dumped.
Dump 5 memories
memax needs a critical mass to start seeing patterns. Unlocks dreams.
Join or start a team hub
Shared brain with people you work with. Jump into an existing hub or start your own.
Let memax dream
Unlocks after you dump 5 memories.
Items stack vertically; progress bar spans full width; CTA chips wrap below the description on narrow rows. Same card chrome as desktop — the container-morphing principle holds across breakpoints.
Your brain
Personal hub · 0 memories · 0 topics
Hi, I'm Jiahao.
Your AI forgets you every session. You re-explain the same context, re-paste the same link, start from scratch every time you switch tools. It's exhausting.
memax is a shared brain that makes it stop. Dump anything — your agents pull what they need, your team sees what you saw, and a dream engine connects the dots while you sleep.
We ship every Friday. Things will break; tell us when they do.
— Jiahao
Come say hi in our community — coming soon
Your first week
2 of 7 done · 1/4 required
Read the welcome note
Connect your first agent
Claude Code, Cursor, Codex — one command, memory flows both ways.
First memory saved
Ask memax a question
Press ↵ instead of ⌘↵ — memax answers from everything you've dumped.
Dump 5 memories
memax needs a critical mass to start seeing patterns. Unlocks dreams.
Join or start a team hub
Shared brain with people you work with. Jump into an existing hub or start your own.
Let memax dream
Unlocks after you dump 5 memories.
Copy lives in i18n. All strings under onboarding.welcome.* and onboarding.checklist.items.*. See RFC §5.5 for the full key list.
Motion. All transitions use NORMAL + EASE; the celebration pulse uses a soft overshoot ([0.34, 1.56, 0.64, 1]) over 500ms.
Design. No dividers: the hairline between the card header and item list is a 5% foreground tone shift, not a <hr />. Glass card surface, signature-accented dots, shadow-premium.
39. OAuth Consent
MCP client connecting to memax — agents request capabilities and hub access; user grants. Audit of current prod + redesign aligned with §29/§34/§36 DNA (glass + state animation + grouping + trust signals + post-approval preview).
An MCP client is asking to act on your behalf. This is the highest-trust moment in the entire memax flow — the user is deciding what Claude / Cursor / ChatGPT desktop can read and write. Everything about the screen should feel premium, predictable, and quietly confident.
Three jobs the screen does: (1) tell the user who is asking with high confidence (agent identity hero), (2) let the user grant the minimum needed quickly via safe defaults, (3) close the trust loop by showing what won't happen + how to revoke.
Wired to: packages/web/src/app/(auth)/oauth/consent/ + packages/server/internal/handler/mcp_oauth.go + memax-sdk types (OAuthConsentRequest).
Audit of packages/web/src/app/(auth)/oauth/consent/ against memax kitchen DNA. Five strengths + eight gaps. The form is structurally sound and uses memax tokens correctly — the gaps are about premium feel, hierarchy, and trust-loop completion.
Click capabilities and hubs to toggle. Approve / Deny resolves to the post-approval state. All eight gap fixes from the audit are wired in:
- Glass treatment — same chrome as settings-dialog (oklch 0.95 + 24px blur + saturate 140% + 1px border + 25px shadow).
- State animation on selection — checkbox spring-scales on toggle, Check icon morphs in via AnimatePresence.
- Permission grouping — read is marked essential ( pre-checked, locked); write is opt-in. Visual hierarchy makes "what's the minimum" obvious.
- Hub grouping — Personal hub renders first, default-checked. Team hubs collapse under a "Team hubs (N)" expander to keep the form tight; opens on demand. Read-only hubs get a warning chip explaining scope limits.
- Capability chips — replaces the bullet-string "Personal hub · Owner · 47 memories · Read-write" with a row of small chips (role, memory count, optional warning chip).
- Won't-access trust signal — reframed from "Not Requested" and marked with muted Minus glyphs (not green Check — green check next to "Delete memories" inverts the semantics). Reads as absence of scope, not approval.
- Post-approval preview— a small footer line tells the user exactly where they'll find this connection later (Settings → Integrations) and that it's revocable. Closes the trust loop.
- Safe-default nudge— sparkle chip under the title says “Safe defaults selected — adjust as needed” so the user knows they can hit Approve immediately without reading every checkbox.
Connect Claude Code to memax
Claude Code can only use the capabilities and hubs you approve here.
Capabilities
Search and read the memories you give it access to.
Push new captures (text, links, decisions) into selected hubs.
Personal hub
Personal
Team hubs (3)
0 selectedClaude Code won't access
After you approve, Claude Code appears in Settings → Integrations. You can revoke or change scope anytime.
Glass shell, not a flat form. Consent is a high-trust moment. The container uses the same glass treatment as settings-dialog + dropdown panels (oklch 0.95 card + 24px blur + saturate 140% + 1px border + 25px shadow). Premium = restraint, not decoration.
Agent identity hero at the top. 48–56px agent icon with the agent's color tint, agent name in the title (Connect {client} to memax). The user must know who is asking before they read anything else.
Safe defaults, pre-selected. The form lands with a working set already chosen: read is checked + locked (essentialchip), write is checked but unlockable, personal hub is checked. A quiet text pill below the title (no icon, kitchen restraint) says “Safe defaults selected — adjust as needed” so the user knows they can hit Approve immediately. Reduces decision friction without removing control.
Permissions show essentiality. Required permissions render with an essentialchip and are not togglable. Optional permissions are clearly opt-in. Visual hierarchy makes “what's the minimum grant” obvious in one glance.
Hubs grouped: personal first, team collapsible. Personal hub is its own section, default-checked. Team hubs collapse under a Team hubs (N) expander with chevron rotation animation. Keeps the form compact for the common case (most users grant personal only), opens on demand for team-hub power users.
Capability metadata as chip row, not bullet string. Each hub row has a row of small chips below the name (role, memory count, optional warning chip for read-only or unavailable). Easier to scan than "Personal hub · Owner · 47 memories · Read-write".
State animation on every selection. Checkbox toggles spring-scale via framer-motion (FAST 0.15s EASE). Check icon morphs in via AnimatePresence. Row background transitions on hover and select. Reduced-motion gates all of it.
Won't-access uses the universal “not in scope” glyph. Reframed as “{client}won't access:” with muted Minus icons at text-fg-4. A green Checknext to “Delete memories” would invert the semantics (reads as “granted”); a red X over-alarms. Minus reads as absence of scope — quiet, accurate, industry-standard (GitHub Apps, Google OAuth, Slack all handle this with neutral typographic signals, not affirmative checks). Items: delete memories, manage topics or run dreams, manage hub settings or members.
Post-approval preview closes the trust loop. Small footer line above the action buttons tells the user what happens next: After you approve, Claude Code appears in Settings → Integrations. You can revoke or change scope anytime.Reduces fear of "what did I just agree to".
Approve is the primary action; Deny is quiet. Approve uses bg-foreground text-background font-medium; Deny is a ghost button at text-fg-3 with no background. Approve is disabled (40% opacity) when no capabilities or hubs are selected — paired with the safe defaults rule above, this state is rare.
Resolved states are confident, not chatty. Approve → "Claude Code is connected" + count of capabilities + hubs + revoke pointer. Deny → "Connection declined" + re-authorize hint. Both render in the same glass shell so the surface morphs in place — no overlay, no second screen.
i18n + brand voice. Every string goes through t.auth.oauthConsent.* with {client}interpolation for the agent name. Brand voice: warm, precise, quiet. No demanding phrasing (“You must”), no fear language (“Risk of data loss”). Bilingual parity: each locale gets its own pass.
Same data shape: no SDK / backend changes required. The existing OAuthConsentRequest / OAuthConsentPermission / OAuthConsentHub types in memax-sdk types have everything needed. The redesign is purely UI.
Files to refactor: consent-form.tsx (rewrite with the redesigned shape), consent-check.tsx (replace with the framer-motion checkbox in this section), consent-labels.ts (extend with essential flag detection and capability chip helpers).
i18n keys to add: approvedTitle, approvedBody, deniedTitle, deniedBody, safeDefaultsHint, postApprovalNote, hubsPersonalTitle, hubsTeamTitle (interpolated with hub count), essentialChip, readOnlyChip, roleCannotUseChip, notRequestedTitleAgent (interpolated with agent name).
Backend tag: packages/server/internal/handler/mcp_oauth.go consentPermissions()needs to flag essential permissions (currently it doesn't — all requested permissions are returned as togglable). The frontend uses the new essential: boolean field to lock the checkbox. Tiny SDK type addition.
21. Explorations
Proposed / Not Yet Implemented
Proposed / Not Yet Implemented
Option A
#e8956a warm peach
Option B
#d4845e terra
Option C
#c9916e sand
Current (emerald)
#10b981 emerald
22. Coverage Gaps
State coverage matrix — what's implemented, partial, or missing across every surface.
| Location | Loading | Empty | Error | Processing |
|---|---|---|---|---|
| Bar recall | ✅ | ✅ | ✅ | — |
| Bar remember | ✅ | — | ✅ | — |
| Bar AI synthesis | ✅ | — | ✅ | — |
| Memory grid | ✅ | ✅ | ✅ | ✅ |
| Note detail | ✅ | ✅ | ✅ | ✅ |
| Settings | ⚠️ | ❌ | ✅ | — |
| Brain view | ✅ | ✅ | ✅ | — |
| Topics page | ✅ | ✅ | ✅ | — |
| Topic detail | ✅ | ✅ | ✅ | — |
| Topic tree panel | ✅ | ✅ | ✅ | — |
| Recent section | ✅ | ✅ | ✅ | — |
✅ implemented · ⚠️ partial · ❌ missing · — N/A
Current focus: loading and async-state consistency, not baseline error coverage
memax design system · Foundations → Components → Patterns · docs/design/memax-design-system.md