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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,16 @@ Adapted in our own words from Andrej Karpathy's observations on LLM coding pitfa

## UI fidelity (TJ, 2026-07-12)

The Agent Inbox UI's pixel source of truth is the Claude Design prototype — `Helpthread App.dc.html` in the "Helpthread Agent Inbox Design" project (the "Helpthread" design-system project carries the same components). **The dogfood site must match it exactly — the whole designed surface, not a subset.** Design-system files under `web/src/components/ds/` stay verbatim copies; improvements go upstream in the design project first. Any deviation — visual, copy, or interaction — requires TJ's explicit sign-off. Remaining gaps are tracked as the fidelity checklist on [HT-23](https://resonantiq.atlassian.net/browse/HT-23); the ticket is not done until the checklist is.
The Agent Inbox UI's pixel source of truth is the Claude Design prototype — `Helpthread App.dc.html` in the "Helpthread Agent Inbox Design" project (the "Helpthread" design-system project carries the same components). **The dogfood site must match it exactly — the whole designed surface, not a subset.** Any deviation — visual, copy, or interaction — requires TJ's explicit sign-off. Remaining gaps are tracked as the fidelity checklist on [HT-23](https://resonantiq.atlassian.net/browse/HT-23); the ticket is not done until the checklist is.

**Design and app reconcile in both directions (TJ, 2026-07-20).** The two are one system, and neither is allowed to silently drift from the other:

- **Design → app.** Files under `web/src/components/ds/` are verbatim copies of the design project's components. Byte-for-byte: its quotes, its semicolons, its import order, its line wrapping. Biome is disabled for that path (`biome.json` override) precisely so a formatter pass can't quietly break the correspondence — without that, byte comparison stops working as a drift detector. New or changed design work comes down via DesignSync; it is not hand-edited in the app.
- **App → design.** If TJ approved something into the dev app, it is approved, and it goes back up into the design project. App-first work is normal — it just isn't finished until the design system has it. `.tsx` screens with API wiring convert to presentational `.jsx` with fixture data on the way up; every style value survives the conversion unchanged.

**Where things live upstream (TJ, 2026-07-20).** The design project has three component folders: `components/core/` for primitives (reusable, context-free, no knowledge of a screen), `components/inbox/` for inbox-specific composition, and `components/app/` for app-level surface — whole screens plus the chrome that frames every screen. The test for `app/` is that the thing owns a route or wraps all of them; anything smaller and reusable is `core/`. Screens upstream are presentational: fixture data, callback props, no API. The app keeps the wiring, the design project keeps the pixels. `web/src/components/ds/` mirrors `core/` and `inbox/`; the `app/` screens live at `web/src/components/` as `.tsx` and are *converted* on the way up, not copied — so they are the one part of the surface where the two sides are deliberately not byte-identical.

The invariant underneath both directions is that **a difference between the two is always a bug in one of them** — so when a re-pull surfaces a semantic difference (a changed style value, prop, or logic, as opposed to formatting), that is a finding, not a merge conflict to resolve in passing. Stop and get TJ's call on which side wins.

## Ecosystem

Expand Down
13 changes: 10 additions & 3 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,21 @@
"assist": { "actions": { "source": { "organizeImports": "off" } } },
"linter": {
"rules": {
"correctness": { "noUnusedImports": "off" },
"correctness": {
"noUnusedImports": "off",
"useExhaustiveDependencies": "off"
},
"complexity": { "useOptionalChain": "off" },
"style": { "useTemplate": "off" },
"suspicious": { "noExplicitAny": "off" },
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"a11y": {
"noSvgWithoutTitle": "off",
"useKeyWithClickEvents": "off",
"noStaticElementInteractions": "off"
"noStaticElementInteractions": "off",
"noAutofocus": "off"
}
}
}
Expand Down
14 changes: 7 additions & 7 deletions web/src/components/ds/core/Avatar.d.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
export interface AvatarProps {
/** initials + tone derive from this */
email?: string
email?: string;
/** explicit initials override */
initials?: string
size?: number
initials?: string;
size?: number;
/** the Agent avatar: accent fill, "S" */
agent?: boolean
agent?: boolean;
/** white ring + shadow (threads, context panel) */
ring?: boolean
style?: React.CSSProperties
ring?: boolean;
style?: React.CSSProperties;
}
export declare function Avatar(props: AvatarProps): JSX.Element
export declare function Avatar(props: AvatarProps): JSX.Element;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
if rg -n '\bJSX\.Element\b' web/src/components/ds/core --glob '*.d.ts'; then
  echo "Replace global JSX.Element declarations with React.JSX.Element."
  exit 1
fi

Repository: Helpthread/helpthread

Length of output: 3135


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '\n## target declarations\n'
sed -n '1,80p' web/src/components/ds/core/Avatar.d.ts
printf '\n---\n'
sed -n '1,80p' web/src/components/ds/core/DropdownMenu.d.ts
printf '\n---\n'
sed -n '1,80p' web/src/components/ds/core/EmptyState.d.ts
printf '\n---\n'
sed -n '1,80p' web/src/components/ds/core/MenuItem.d.ts
printf '\n---\n'
sed -n '1,80p' web/src/components/ds/core/Skeleton.d.ts

printf '\n## similar uses of React.JSX or import type React\n'
rg -n 'React\.JSX|import type \* as React|import type React' web/src/components/ds/core web/src/components -g '*.d.ts' -g '*.tsx' -g '*.ts' || true

printf '\n## files that already use React.JSX.Element in declarations\n'
rg -n '\bReact\.JSX\.Element\b' web/src/components/ds/core -g '*.d.ts' || true

Repository: Helpthread/helpthread

Length of output: 1652


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '\n## DropdownMenu source and declaration context\n'
sed -n '1,220p' web/src/components/ds/core/DropdownMenu.tsx
printf '\n---\n'
sed -n '1,120p' web/src/components/ds/core/DropdownMenu.d.ts

printf '\n## package / tsconfig hints\n'
sed -n '1,220p' package.json
printf '\n---\n'
find web -maxdepth 2 \( -name 'tsconfig*.json' -o -name 'vite.config.*' -o -name 'biome.json' \) -print | sort | while read -r f; do
  echo "### $f"
  sed -n '1,220p' "$f"
  echo '---'
done

printf '\n## react type/version references\n'
rg -n '"react"|`@types/react`|jsxImportSource|jsx:' package.json web -g 'package.json' -g 'tsconfig*.json' -g 'vite.config.*' -g '*.json' || true

Repository: Helpthread/helpthread

Length of output: 294


🌐 Web query:

React 19 upgrade guide JSX.Element React.JSX global JSX namespace declaration files

💡 Result:

In React 19, the global JSX namespace has been removed to prevent type pollution and conflicts with other UI libraries [1][2][3]. Developers must now use the scoped React.JSX namespace instead [1][4][3]. Key Changes and Migration Steps: 1. Update Code References: Replace all instances of JSX.Element (or other JSX types) with React.JSX.Element by importing the namespace from the react package [4][5]: import { JSX } from 'react'; // Use JSX.Element as before, now correctly scoped 2. Automated Migration: You can use the types-react-codemod tool to automatically update your codebase. The scoped-jsx preset is specifically designed to migrate deprecated global JSX references to React.JSX [1][6][2]: npx types-react-codemod scoped-jsx. 3. Module Augmentation: If your project relies on module augmentation to extend the JSX namespace (e.g., for custom elements), you must update your declaration files to augment the react module instead of the global scope [1][2][7]. Old way (Global): declare global { namespace JSX { interface IntrinsicElements { "my-element": { myProp: string }; } } } New way (Scoped): declare module "react" { namespace JSX { interface IntrinsicElements { "my-element": { myProp: string }; } } } The exact module specifier (e.g., "react", "react/jsx-runtime", or "react/jsx-dev-runtime") in your declare module block depends on the "jsx" compiler option set in your tsconfig.json [1][7]. Most standard configurations use "react" [1]. If you encounter "Cannot find namespace 'JSX'" errors in third-party dependencies after upgrading, those packages likely still reference the deprecated global namespace and may require updates or patch-package resolutions [8][9].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "## package.json"
sed -n '1,220p' package.json

echo
echo "## tsconfig files"
find . -maxdepth 3 \( -name 'tsconfig*.json' -o -name 'package.json' \) | sort | while read -r f; do
  echo "### $f"
  sed -n '1,220p' "$f"
  echo "---"
done

echo
echo "## dropdown menu source location"
fd -a 'DropdownMenu.*' web/src/components/ds/core

echo
echo "## any React 19 type references in repo"
rg -n '"react":|"`@types/react`":|jsxImportSource|React\.JSX|Cannot find namespace \'JSX\'' . -g 'package.json' -g 'tsconfig*.json' -g '*.d.ts' -g '*.ts' -g '*.tsx' || true

Repository: Helpthread/helpthread

Length of output: 50378


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "## web/package.json"
sed -n '1,220p' web/package.json

echo
echo "## web tsconfig files"
find web -maxdepth 2 \( -name 'tsconfig*.json' -o -name 'package.json' \) | sort | while read -r f; do
  echo "### $f"
  sed -n '1,220p' "$f"
  echo "---"
done

echo
echo "## React / JSX references in web workspace"
rg -n '"react":|"`@types/react`":|jsxImportSource|React\.JSX|namespace JSX|Cannot find namespace \'JSX\'' web -g 'package.json' -g 'tsconfig*.json' -g '*.d.ts' -g '*.ts' -g '*.tsx' || true

Repository: Helpthread/helpthread

Length of output: 2450


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "## DropdownMenu files"
fd -a 'DropdownMenu' web/src/components/ds/core

echo
echo "## core declaration files with JSX.Element"
rg -n '\bJSX\.Element\b' web/src/components/ds/core -g '*.d.ts'

echo
echo "## possible source files for DropdownMenu"
fd -a 'DropdownMenu.*' web/src/components/ds/core web/src/components || true

echo
echo "## search for 'return null' near DropdownMenu"
rg -n -C 3 'return null|DropdownMenu' web/src/components/ds/core web/src/components -g '*.ts' -g '*.tsx' -g '*.d.ts' || true

Repository: Helpthread/helpthread

Length of output: 16459


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

sed -n '1,220p' web/src/components/ds/core/DropdownMenu.jsx

Repository: Helpthread/helpthread

Length of output: 854


Use React.JSX.Element in these declarations

These declarations still use the global JSX.Element return type; switch them to React.JSX.Element for the React 19 typings. DropdownMenu also needs | null because it returns null when closed.

  • web/src/components/ds/core/Avatar.d.ts
  • web/src/components/ds/core/DropdownMenu.d.ts
  • web/src/components/ds/core/EmptyState.d.ts
  • web/src/components/ds/core/MenuItem.d.ts
  • web/src/components/ds/core/Skeleton.d.ts
📍 Affects 5 files
  • web/src/components/ds/core/Avatar.d.ts#L13-L13 (this comment)
  • web/src/components/ds/core/DropdownMenu.d.ts#L9-L9
  • web/src/components/ds/core/EmptyState.d.ts#L7-L7
  • web/src/components/ds/core/MenuItem.d.ts#L9-L9
  • web/src/components/ds/core/Skeleton.d.ts#L7-L7
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ds/core/Avatar.d.ts` at line 13, Update the component
declarations to use React.JSX.Element instead of global JSX.Element in
Avatar.d.ts (13-13), EmptyState.d.ts (7-7), MenuItem.d.ts (9-9), and
Skeleton.d.ts (7-7). In DropdownMenu.d.ts (9-9), use React.JSX.Element | null to
reflect its closed-state return value.

56 changes: 21 additions & 35 deletions web/src/components/ds/core/Avatar.jsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,32 @@
import React from 'react'
import React from "react";

const PALETTE = [
['oklch(0.9 0.03 60)', 'oklch(0.42 0.07 60)'],
['oklch(0.9 0.03 150)', 'oklch(0.4 0.06 150)'],
['oklch(0.9 0.03 250)', 'oklch(0.42 0.06 250)'],
['oklch(0.9 0.03 320)', 'oklch(0.42 0.06 320)'],
['oklch(0.9 0.035 95)', 'oklch(0.42 0.06 95)'],
]
["oklch(0.9 0.03 60)", "oklch(0.42 0.07 60)"],
["oklch(0.9 0.03 150)", "oklch(0.4 0.06 150)"],
["oklch(0.9 0.03 250)", "oklch(0.42 0.06 250)"],
["oklch(0.9 0.03 320)", "oklch(0.42 0.06 320)"],
["oklch(0.9 0.035 95)", "oklch(0.42 0.06 95)"],
];
function initialsOf(email) {
const local = String(email || '').split('@')[0]
const parts = local.split(/[._-]+/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return local.slice(0, 2).toUpperCase()
const local = String(email || "").split("@")[0];
const parts = local.split(/[._-]+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return local.slice(0, 2).toUpperCase();
}

/** Initials avatar. Tone is a stable hash of the email; agent=true fills with accent.
* ring adds the white ring + shadow used in threads and the context panel. */
export function Avatar({ email, initials, size = 32, agent = false, ring = true, style }) {
let h = 0
for (const ch of String(email || initials || '?')) h = (h * 31 + ch.charCodeAt(0)) >>> 0
const [bg, fg] = agent ? ['var(--ht-accent)', 'var(--ht-on-accent)'] : PALETTE[h % PALETTE.length]
let h = 0;
for (const ch of String(email || initials || "?")) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const [bg, fg] = agent ? ["var(--ht-accent)", "var(--ht-on-accent)"] : PALETTE[h % PALETTE.length];
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
letterSpacing: '0.02em',
fontSize: Math.round(size * 0.34),
background: bg,
color: fg,
border: ring ? (size >= 48 ? '3px' : '2px') + ' solid var(--ht-surface)' : 'none',
boxShadow: ring ? (size >= 48 ? 'var(--ht-shadow-md)' : 'var(--ht-shadow-sm)') : 'none',
...style,
}}
>
{initials || (agent ? 'S' : initialsOf(email))}
<div style={{ width: size, height: size, borderRadius: "50%", flexShrink: 0, display: "flex",
alignItems: "center", justifyContent: "center", fontWeight: 600, letterSpacing: "0.02em",
fontSize: Math.round(size * 0.34), background: bg, color: fg,
border: ring ? (size >= 48 ? "3px" : "2px") + " solid var(--ht-surface)" : "none",
boxShadow: ring ? (size >= 48 ? "var(--ht-shadow-md)" : "var(--ht-shadow-sm)") : "none", ...style }}>
{initials || (agent ? "S" : initialsOf(email))}
</div>
)
);
}
16 changes: 8 additions & 8 deletions web/src/components/ds/core/Button.d.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/** The brand's one button. Sentence case labels; destructive actions arm on first press instead of opening a modal. */
export interface ButtonProps {
/** primary | outline | ghost | destructive */
variant?: 'primary' | 'outline' | 'ghost' | 'destructive'
variant?: "primary" | "outline" | "ghost" | "destructive";
/** destructive two-step arm state ("Confirm" fill) */
armed?: boolean
disabled?: boolean
title?: string
onClick?: () => void
style?: React.CSSProperties
children: React.ReactNode
armed?: boolean;
disabled?: boolean;
title?: string;
onClick?: () => void;
style?: React.CSSProperties;
children: React.ReactNode;
}
export declare function Button(props: ButtonProps): JSX.Element
export declare function Button(props: ButtonProps): JSX.Element;
77 changes: 16 additions & 61 deletions web/src/components/ds/core/Button.jsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,27 @@
import React from 'react'
import React from "react";

/** Primary action button. variant: primary | outline | ghost | destructive.
* Destructive supports the two-step arm pattern via the `armed` prop. */
export function Button({
variant = 'primary',
armed = false,
disabled = false,
onClick,
title,
style,
children,
}) {
export function Button({ variant = "primary", armed = false, disabled = false, onClick, title, style, children }) {
const base = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
whiteSpace: 'nowrap',
font: 'inherit',
fontSize: '13.5px',
fontWeight: 600,
borderRadius: 'var(--ht-radius-md)',
padding: '8px 18px',
cursor: disabled ? 'default' : 'pointer',
border: 'none',
}
display: "inline-flex", alignItems: "center", gap: 8, whiteSpace: "nowrap",
font: "inherit", fontSize: "13.5px", fontWeight: 600, borderRadius: "var(--ht-radius-md)",
padding: "8px 18px", cursor: disabled ? "default" : "pointer", border: "none",
};
const variants = {
primary: {
color: 'var(--ht-on-accent)',
background: disabled
? 'color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))'
: 'var(--ht-accent)',
},
outline: {
color: 'var(--ht-ink)',
background: 'var(--ht-surface)',
border: '1px solid var(--ht-border)',
padding: '7px 17px',
},
ghost: { color: 'var(--ht-ink-muted)', background: 'none' },
primary: { color: "var(--ht-on-accent)", background: disabled ? "color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))" : "var(--ht-accent)" },
outline: { color: "var(--ht-ink)", background: "var(--ht-surface)", border: "1px solid var(--ht-border)", padding: "7px 17px" },
ghost: { color: "var(--ht-ink-muted)", background: "none" },
destructive: armed
? {
color: 'var(--ht-surface)',
background: 'var(--ht-critical)',
border: '1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)',
padding: '7px 17px',
}
: {
color: 'var(--ht-critical)',
background: 'transparent',
border: '1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)',
padding: '7px 17px',
},
}
? { color: "var(--ht-surface)", background: "var(--ht-critical)", border: "1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)", padding: "7px 17px" }
: { color: "var(--ht-critical)", background: "transparent", border: "1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)", padding: "7px 17px" },
};
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
<button type="button" title={title} disabled={disabled} onClick={onClick}
style={{ ...base, ...variants[variant], ...style }}
onMouseEnter={(e) => {
if (!disabled) e.currentTarget.style.filter = 'brightness(0.95)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.filter = ''
}}
>
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = "brightness(0.95)"; }}
onMouseLeave={(e) => { e.currentTarget.style.filter = ""; }}>
{children}
</button>
)
);
}
27 changes: 16 additions & 11 deletions web/src/components/ds/core/CommandMenu.d.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
export interface CommandMenuItem {
label: string
snippet?: string
keywords?: string
shortcut?: string
label: string;
/** shown under the label, and matched against the query */
snippet?: string;
/** extra text matched against the query but not shown in the row */
keywords?: string;
shortcut?: React.ReactNode;
}

/** Searchable inserter for saved replies. */
export interface CommandMenuProps {
items?: CommandMenuItem[]
placeholder?: string
onPick?: (item: CommandMenuItem) => void
inline?: boolean
width?: number
initialQuery?: string
items?: CommandMenuItem[];
placeholder?: string;
onPick?: (item: CommandMenuItem) => void;
/** drops the rise-in animation and autofocus, for embedding inline */
inline?: boolean;
width?: number;
initialQuery?: string;
}
export declare function CommandMenu(props: CommandMenuProps): JSX.Element
export declare function CommandMenu(props: CommandMenuProps): JSX.Element;
Loading