React Render Types Composition
HorusGoul/eslint-plugin-react-render-types
Composition patterns for building React components with @renders type annotations from eslint-plugin-react-render-types.
Rules and best practices when working on the dashboard React frontend codebase (including the inlined Gram Elements code)
$ npx skills add speakeasy-api/gram --skill frontend -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install speakeasy-api/gram frontend --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/speakeasy-api/gram.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.agents/skills/frontend .claude/skills/frontend && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "frontend" agent skill from https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontend into .claude/skills/frontend/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "frontend", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontendType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add speakeasy-api/gram --skill frontend -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install speakeasy-api/gram frontend --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/speakeasy-api/gram.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.agents/skills/frontend .agents/skills/frontend && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "frontend" agent skill from https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontend into .agents/skills/frontend/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "frontend", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add speakeasy-api/gram --skill frontend -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install speakeasy-api/gram frontend --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/speakeasy-api/gram.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.agents/skills/frontend .cursor/skills/frontend && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "frontend" agent skill from https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontend into .cursor/skills/frontend/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "frontend", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/speakeasy-api/gram.git --path .agents/skills/frontend--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add speakeasy-api/gram --skill frontend -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install speakeasy-api/gram frontend --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/speakeasy-api/gram.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.agents/skills/frontend .gemini/skills/frontend && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "frontend" agent skill from https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontend into .gemini/skills/frontend/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "frontend", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install speakeasy-api/gram frontendInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add speakeasy-api/gram --skill frontend -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/speakeasy-api/gram.git skills-src && mkdir -p .github/skills && cp -r skills-src/.agents/skills/frontend .github/skills/frontend && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "frontend" agent skill from https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontend into .github/skills/frontend/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "frontend", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add speakeasy-api/gram --skill frontend -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install speakeasy-api/gram frontend --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/speakeasy-api/gram.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.agents/skills/frontend .opencode/skills/frontend && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "frontend" agent skill from https://github.com/speakeasy-api/gram/tree/main/.agents/skills/frontend into .opencode/skills/frontend/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "frontend", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
frontendRules and best practices when working on the dashboard React frontend codebase (including the inlined Gram Elements code)
Frontend is an agent skill from speakeasy-api/gram. Rules and best practices when working on the dashboard React frontend codebase (including the inlined Gram Elements code)
Its SKILL.md is about 9.8k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Frontend & Design. It works with React and ESLint. The repository describes itself as: Securely scale AI usage across your organization. A single stack to Connect, Secure, Observe and Distribute agents, MCPs, and Skills within your company. The licence is AGPL-3.0.
Read from SKILL.md and the folder at commit 4d32da1. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
npmFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
speakeasy.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Frontend loads about 9.8k tokens when it runs. Until then it costs about 33 tokens; SKILL.md has 3,699 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from speakeasy-api/gram at commit 4d32da1, republished under its AGPL-3.0 licence (© speakeasy-api). 3,699 words, ~9,806 tokens.
.claude/skills/frontend/SKILL.md (or your agent's skills folder).Use aube package scripts for frontend checks. From the repo root, prefer aube run -F <package> <script> so commands run against the right frontend package without cd. Do not run npm exec, npx, bare vitest, bare eslint, bare oxfmt, or bare tsc unless you are debugging the package script itself.
| Need | Dashboard | Whole workspace |
|---|---|---|
| Package lint gate | aube run -F dashboard lint | aube run lint |
| Type-check only | aube run -F dashboard type-check | aube run type-check |
| Tests once | aube run -F dashboard test | Run the touched package's aube run test |
| Tests in watch mode | aube run -F dashboard test:watch | Run the touched package's aube run test:watch |
| ESLint only | aube run -F dashboard lint:eslint | Run the touched package's script |
| Format check only | aube run -F dashboard lint:format | Run the touched package's script |
For small edits, run the narrowest package script that proves the change. For shared or cross-package frontend changes, run the root aube run lint and aube run type-check scripts.
Name the actual state or behavior in identifiers and UI copy, not relative labels such as legacy or modern. For example, use agentIdentityIsNotConfigured for an assistant without an agent identity. Choose the name from the actual condition. Preserve externally contracted values, including metric labels used by queries or alerts, unless the change includes a compatibility plan.
Use the aube package manager
When interacting with the server, use the @gram/client package (this is an alias to client/dashboard/src/sdk/src/...)
The document client/dashboard/src/sdk/REACT_QUERY.md is very helpful for understanding how to use React Query hooks that come with the SDK.
For data fetching and server state, use @tanstack/react-query instead of manual useEffect/useState patterns
When invalidating React Query caches after mutations, invalidate ALL relevant query keys — not just the most specific one. Different hooks may use different query key prefixes for the same data (e.g., queryKeyInstance vs toolsets.getBySlug). Use broad invalidation helpers like invalidateAllToolset(queryClient) to ensure all consumers refresh.
Observability surfaces — Tool Logs, insights/analytics, summary cards, counts, and filter dropdowns — are served from pre-aggregated ClickHouse summary views behind endpoints like telemetry.getToolUsageSummary, telemetry.listToolUsageTraces, and telemetry.query. Default to these summary-backed endpoints; they're fast and are the right choice for almost every view.
Per-log detail is the rare exception. Free-text search over log bodies, arbitrary custom-attribute filters (e.g. @user.region), and inspecting an individual log/trace fall back to scanning the raw telemetry_logs table, which is slow. Only reach for those when detailed telemetry is genuinely what the user needs — not for default lists, summaries, or filter options.
When you add a control that triggers the raw-log path (a free-text search box, a custom-attribute filter), tell the user it may be slower — see SlowSearchNotice in LogsTools.tsx, shown only while such a filter is active — and keep the structured filters (server, user, agent, type, date) on the fast summary path. Don't build a default-on view whose first paint requires a raw-log scan.
The core rule: every UI pattern that appears in more than two places must be centralized so it can be changed in a single location.
components/ before writing anythingBefore writing any JSX for a UI element, check client/dashboard/src/components/ for an existing component. This includes layout wrappers, table headers, empty states, filter pill groups, search inputs, badges, cards — anything. Reuse what exists. Never create a one-off <div className="..."> when a named component already exists for that purpose.
If no component exists and you expect the pattern to appear in more than a few places across the app, create one in client/dashboard/src/components/ before using it. Name it for what it is, not where it happens to appear first (e.g., PageTabsTrigger, not SourceDetailTabTrigger).
If the same Tailwind className string (or any meaningful substring of one) appears on 3+ elements anywhere in the codebase, extract it to:
cva variantconst used in cn()The symptom to watch for: copy-pasting a className prop. That is always wrong.
If you find yourself copy-pasting a JSX structure — even with minor variations — stop and extract a parameterized component. Three near-identical blocks is the threshold.
Never use immediately-invoked function expressions inside JSX ({(() => { ... })()} ). Extract to a named sub-component or a variable above the return statement.
A ternary that wraps onto multiple lines, or chains a second ?: inside a branch, is unreadable. Reach for one of these instead:
switch statement when mapping a discriminator (e.g. a kind / type string) to one of several values.// ❌ wrong — nested multi-line ternary
attachmentType={
sourceKind === "function"
? "functions"
: sourceKind === "externalmcp" || sourceKind === "remotemcp"
? "external_mcp"
: "openapi"
}
// ✅ right — hoisted helper with a switch
function attachmentTypeForSourceKind(sourceKind: string | undefined): string {
switch (sourceKind) {
case "function":
return "functions";
case "externalmcp":
case "remotemcp":
return "external_mcp";
default:
return "openapi";
}
}
attachmentType={attachmentTypeForSourceKind(sourceKind)}Single-line, single-branch ternaries (isOpen ? "x" : "y") are fine.
A component that has grown past ~150 lines of JSX is doing too much. Break it up. If a page has multiple tabs, each tab's content is its own component.
Many pages render the same <h1> + <p> header block in 2–3 conditional render paths (loading skeleton, empty state, populated state). Examples observed: InsightsTools.tsx, LogsTools.tsx, LogsAgents.tsx, SecurityOverview.tsx, PolicyCenter.tsx. Symptoms: a copy change touches the same string in 3 places; Edit with replace_all fails because indentation differs between the duplicates.
When adding or editing page headers, lift the title and subtitle into a small <PageHeader title="…" subtitle="…" /> (or pass them as props to a shared shell), not into each render branch. When editing existing duplicated copy, target a unique trailing fragment of the string (e.g. "in chat messages.") so a single replace_all covers every copy regardless of indentation — and file a follow-up to extract a shared header.
When the same empty-state component is reused across pages but needs different copy per caller (e.g. HooksEmptyState rendered from both /insights/tools and /logs/tools), add optional title / subtitle props with sensible defaults rather than forking the component:
export function HooksEmptyState({
title = "No logs captured",
subtitle = "Install Observability plugin in your AI agent to start capturing tool execution logs",
}: { title?: string; subtitle?: string } = {}) {
/* … */
}Backwards-compatible callers stay <HooksEmptyState />; only the variant caller passes overrides. Avoids divergent copies of the surrounding scaffolding (provider cards, setup dialogs, etc.).
Use the design system Table from @/components/ui/Table for dashboard tables. Do not add new shadcn table wrappers or hand-roll table styling with raw <table> markup when Table can express the UI. If you find a lingering legacy table pattern, migrate it when touched.
import { Column, Table } from "@/components/ui/Table";For normal data tables, prefer the declarative columns / data / rowKey API. Define Column<T>[] near the component so render functions stay typed, use render for rich cells, and use width for stable layouts instead of ad hoc cell class widths.
const columns: Column<Role>[] = [
{
key: "name",
header: "Name",
width: "180px",
render: (role) => <Type className="font-medium">{role.name}</Type>,
},
{
key: "members",
header: "Members",
width: "100px",
render: (role) => <Type>{role.memberCount}</Type>,
},
];
<Table columns={columns} data={roles} rowKey={(row) => row.id} />;For empty and loading states, use the Table's built-in empty surface and the shared SkeletonTable from @/components/ui/skeleton. Do not rebuild a one-off empty <tbody> or skeleton table.
<Table
columns={columns}
data={filteredKeys}
rowKey={(row) => row.id}
className="max-h-[500px] overflow-y-auto"
noResultsMessage={<Type>No matching API keys</Type>}
/>Search and filter controls are siblings above the table. On pages, wrap them in Page.Toolbar (see the page-toolbar skill); the bare Stack form below is for non-page surfaces like dialogs and sheets. Keep filter state outside the table, derive filtered rows with useMemo, and pass the result to data. Use existing controls such as SearchBar, MultiSelect, Select, or page-specific filter pills; do not put form controls inside Table.Header unless they are truly column headers. If the table is paginated, reset the page index when filters change.
const [search, setSearch] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const filteredRows = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return rows.filter((row) => {
const matchesSearch =
normalizedSearch.length === 0 ||
row.name.toLowerCase().includes(normalizedSearch);
const matchesTags =
selectedTags.length === 0 ||
row.tags.some((tag) => selectedTags.includes(tag));
return matchesSearch && matchesTags;
});
}, [rows, search, selectedTags]);
<Stack direction="horizontal" gap={2} className="mb-4 h-fit">
<SearchBar
value={search}
onChange={(value) => {
setSearch(value);
setPage(0);
}}
placeholder="Search tools"
className="w-64"
/>
<MultiSelect
options={tagOptions}
defaultValue={selectedTags}
onValueChange={(value) => {
setSelectedTags(value);
setPage(0);
}}
placeholder="Filter by tag"
autoSize
/>
</Stack>
<Table
columns={columns}
data={filteredRows}
rowKey={(row) => row.id}
noResultsMessage={<Type>No matching tools</Type>}
/>;Footers that summarize, paginate, or load more rows should usually be sibling bars immediately below the table. The table API does not require a special footer component for this; keep the table declarative and put pagination/load-more controls after it.
<Table columns={columns} data={visibleRows} rowKey={(row) => row.id} />;
{
totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-3">
<Type className="text-muted-foreground text-sm">
{pageStart}-{pageEnd} of {filteredRows.length}
</Type>
<div className="flex items-center gap-1">
<Button
variant="tertiary"
size="sm"
onClick={() => setPage((page) => page - 1)}
disabled={page === 0}
>
Previous
</Button>
<Button
variant="tertiary"
size="sm"
onClick={() => setPage((page) => page + 1)}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
</div>
);
}Use the compound API only when the body needs custom structure that the declarative API cannot express, such as mixed rows, a full-width CTA row, or a custom no-results branch. Keep the design system wrapper, header, row, and cell components as the default primitives.
<Table columns={columns}>
<Table.Header columns={columns} />
{items.length === 0 ? (
<Table.NoResultsMessage>No results found.</Table.NoResultsMessage>
) : (
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id} row={item} columns={columns} />
))}
</Table.Body>
)}
<Table.Row>
<div className="border-border bg-muted/20 col-span-full border-t py-5 text-center">
<Type className="text-muted-foreground text-sm">
Want to grant new members access?
</Type>
<Button variant="tertiary" size="sm" className="mt-2">
Configure Roles
</Button>
</div>
</Table.Row>
</Table>Use grouped or expandable rows through the table props instead of nesting unrelated cards or custom accordions around a table. Current patterns use hideHeader for grouped parent rows and renderExpandedContent for nested details.
<Table
columns={groupColumns}
data={groups}
rowKey={(row) => row.key}
hideHeader
renderExpandedContent={(group) => (
<Table
columns={childColumns}
data={group.items}
rowKey={(row) => row.id}
hideHeader
/>
)}
/>Raw <tr> / <td> should be rare and stay inside a <Table.Body> only when native table semantics are needed and Table does not expose them, such as a colSpan overflow row. If the row is a normal data row, use <Table.Row row={row} columns={columns} /> or the declarative data prop.
These patterns were established in the audit log (#2140) and deployment log (#2167) redesigns. Apply them whenever building search, filtering, or keyboard navigation.
Never create new RegExp() inside a render callback (e.g., highlightMatch). Extract it to a useMemo keyed on the search query:
const searchRegex = useMemo(() => {
if (!searchQuery) return null;
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(${escaped})`, "gi");
}, [searchQuery]);Then use searchRegex inside useCallback-wrapped functions.
Wrap search queries with useDeferredValue before passing them to expensive useMemo computations (e.g., filtering all logs). This keeps the input responsive while React defers the downstream recomputation:
const deferredSearchQuery = useDeferredValue(searchQuery);
const filteredIndices = useMemo(() => { /* expensive filter */ }, [deferredSearchQuery, ...]);If a value can be computed from current state, derive it inline — don't sync it via useEffect. This prevents a flash of stale values between renders:
// DO: derive during render
const effectiveSearchIndex =
searchMatchIndices.length > 0
? Math.min(currentSearchIndex, searchMatchIndices.length - 1)
: 0;
// DON'T: clamp via useEffect (causes stale render flash)
useEffect(() => {
if (currentSearchIndex >= searchMatchIndices.length) setCurrentSearchIndex(0);
}, [searchMatchIndices.length]);When a component has keyboard navigation (j/k/g/G) with a currentIndex state, reset it when the underlying data changes (filters, pagination, data refresh):
useEffect(() => {
setCurrentLogIndex(null);
}, [logs]); // or parsedLogs, depending on the componentApp.tsx wraps the entire app in a global TooltipProvider. Never add another TooltipProvider inside a component — doing so creates a redundant Radix context per instance and contributes to ResizeObserver loop completed with undelivered notifications errors in the browser.
Use <Tooltip>, <TooltipTrigger>, and <TooltipContent> directly — they inherit the global provider automatically. For simple cases use the existing <SimpleTooltip tooltip="..."> wrapper from @/components/ui/tooltip.
// ✅ correct
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Hello</TooltipContent>
</Tooltip>
// ❌ wrong — TooltipProvider already exists at the app root
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Hello</TooltipContent>
</Tooltip>
</TooltipProvider>Use the right primitive for the link type — mixing them causes full-page reloads, broken multi-tenancy, or missing security headers.
Internal navigation (any URL inside the dashboard): Use the route helpers from client/dashboard/src/routes.tsx. Top-level routes and subpages expose .Link, .href(), and .goTo():
// Wrap a child node with .Link
<routes.sources.Link>
<Button>Connect a Source</Button>
</routes.sources.Link>
// Subpages get .Link too — use it instead of building strings
<routes.insights.tools.Link>
<Button>Track AI usage</Button>
</routes.insights.tools.Link>
// Plain react-router Link with .href() when you need a className or are inside <p>
<Link to={routes.plugins.href()} className="underline underline-offset-2">
Observability plugin
</Link>Never hardcode org/project slugs in an href (e.g. https://app.getgram.ai/speakeasy-team/projects/default/plugins). The route helpers resolve the current :orgSlug / :projectSlug from the URL, so the same call works for every tenant.
External links (anywhere outside the dashboard): Use a plain <a> with target="_blank" and rel="noopener noreferrer". This matches the existing pattern (AddServerDialog.tsx:1162, CatalogDetail.tsx:229) and the security attributes are mandatory — noopener blocks window.opener access; noreferrer strips the Referer header.
<a
href="https://www.speakeasy.com/product/mcp-gateway/catalog"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
MCP Registry
</a>The @/components/ui/link wrapper sets target="_blank" when external is true but also injects an icon — fine for nav rows, too heavy for inline links inside subtext. Reach for the plain <a> for inline external links.
color not colour, canceled/canceling not cancelled/cancelling, behavior not behaviour, license (noun and verb) not licence, catalog not catalogue, gray not grey, center not centre, analyze not analyse, and -ize/-ization endings (organize, customize, authorization, synchronization) not -ise/-isation. This applies only to what the user reads — leave code identifiers, API field names, and third-party tokens alone even when they use British spelling (e.g. an upstream colour field stays colour; only the visible label is Americanized).{rangeLabel}, {periodUsage.credits}, or {projectName}. When rewording copy that contains a token, keep the token in place — replace it with the literal current value (e.g. "the last 30 days") only when the data fetch itself is locked to that value. Otherwise the copy starts lying as soon as the user changes a filter.prettier-plugin-tailwindcss reorders classes on save. Write classes in any order; the formatter will normalize them and the diff stays clean across the codebase.contextInfo= / suggestions= props passed to ExploreWithAI / InsightsConfig. Those strings are sent to the LLM as analytical context; if they drift from the visible label, the AI assistant talks about a card the user can't see.First choice: a page template. Do not hand-roll the Page frame. Pick the template that matches the page's shape from @/components/page-templates and fill in data — it owns the frame, breadcrumbs, scope gate, the single header, and the loading/empty/error branches (which is what stops the "three-branch header" duplication). Full recipes: client/dashboard/src/components/page-templates/README.md.
| Page shape | Template |
|---|---|
| collection: search/filter + table or card grid + empty state | ResourceListPage |
| one entity: hero + sections (rail wired via app sidebar) | DetailPage |
| tabs that are different resources (not one entity's sections) | TabbedPage |
a single create/edit <form> | FormPage |
stacked titled config sections (or a prose column via variant="content") | SettingsPage |
| dashboard: stat row + summary/chart cards | OverviewPage |
| fullbleed analytics/observe surface (sticky filters + big table/charts) | WorkbenchPage |
| multi-step flow | WizardPage |
| auth / standalone, outside the app shell | CenteredPage |
| genuine bespoke app canvas (chat, playground, builder) | FullBleedPage (escape hatch) |
import { ResourceListPage } from "@/components/page-templates";
// Gate the DATA-OWNING component from outside so its query never fires for
// unauthorized users. A data hook called in the same component that renders the
// template runs BEFORE the template's own `scope` gate — so wrap it here.
export default function Environments(): JSX.Element {
return (
<RequireScope scope="project:read" level="page">
<EnvironmentsInner />
</RequireScope>
);
}
function EnvironmentsInner(): JSX.Element {
const q = useEnvironments(); // runs only after the page gate above passes
const rows = q.data ?? [];
// Write affordances get their OWN component-level gate — the page scope is
// read, so an any-of page scope must never be what hides a write button.
const newButton = (
<RequireScope scope="project:write" level="component">
<Button>New environment</Button>
</RequireScope>
);
return (
<ResourceListPage
title="Environments"
description="One-line purpose."
primaryAction={rows.length > 0 ? newButton : undefined}
isLoading={q.isPending}
isEmpty={rows.length === 0}
empty={{
icon: "blocks",
heading: "No environments yet",
action: newButton,
}}
>
<Table columns={columns} data={rows} rowKey={(r) => r.id} />
</ResourceListPage>
);
}Scope gating (important): wrap the data-owning component in RequireScope (the view scope, e.g. project:read) and call data hooks inside it, so the query never fires for unauthorized users. The templates also accept a scope prop, but it only gates rendering — a hook in the same component that renders the template runs before that gate, so prefer the outer wrapper for anything that fetches. Gate write-only affordances (create/delete buttons, mutation tabs) with their own level="component" RequireScope for the write scope; never rely on an any-of page scope to hide them.
Only a page that fits none of the templates falls back to the raw skeleton (and it still must render the header exactly once — no bespoke <h1>):
import { Page } from "@/components/page-layout";
export default function MyNewPage(): JSX.Element {
return (
<Page>
<Page.Header>
<Page.Header.Breadcrumbs />
</Page.Header>
<Page.Body>
<Page.Section>
{/* Area eyebrow (OBSERVE/SECURE/CONNECT/DISTRIBUTE/ORGANIZATION, from
the URL) over a thin serif title. area="…" overrides, area="" suppresses. */}
<Page.Section.Title>My New Page</Page.Section.Title>
<Page.Section.Description>One-line purpose.</Page.Section.Description>
<Page.Section.Body>{/* content */}</Page.Section.Body>
</Page.Section>
</Page.Body>
</Page>
);
}Checklist for the content below the title:
One page title per page. Secondary sections get text-eyebrow overlines (utility groupings, table/list sections) or a smaller serif text-display-xs (content subsections with their own body) — never a second eyebrow + full-size serif stack.
Stat rows → StatRow from @/components/stat-row (a MetricCard.Group with a built-in isLoading → skeleton swap); pass metrics with explicit tones. Drop to raw MetricCard in MetricCard.Group only for a bespoke row.
<StatRow
isLoading={q.isPending}
metrics={[
{ label: "Total rules", value: total, tone: "information" },
{
label: "Violations",
value: n,
tone: n > 0 ? "destructive" : "neutral",
delta: "+3",
description: "last 7 days",
},
]}
/>Tables → design-system Table (headers come out as eyebrows for free); hand-rolled grids use text-eyebrow header labels.
List/filter controls → Page.Toolbar (see the page-toolbar skill), mono uppercase segments for mode switches.
Empty states → InlineEmptyState from @/components/inline-empty-state (icon/graphic + heading + description + action, orientation="horizontal" variant) for empty regions inside a page; EmptyState from @/components/page-layout for full-page voids. Never hand-roll the border-dashed + rounded-full icon-blob block — InlineEmptyState is the square-hairline-tile idiom and the templates' empty prop routes through it.
Chart/summary panels → ChartCard from @/components/chart/ChartCard (titled panel with loading/error states); the detail-column width wrapper is DetailBody from @/components/detail-body (never re-type max-w-[1270px] px-8 py-8).
Loading → content-shaped skeletons (SkeletonTable, geometry-matched rows), never a lone spinner or a premature empty state.
Cards white (bg-card), page gutter gray, hairline borders, no shadows/gradients/washes, square corners — per the styling rules below.
A page that renders its own <h1> instead of this pattern is a defect; if a custom header is unavoidable, it must still render <PageEyebrow /> + text-display-sm font-thin.
The primitive shelf was consolidated; these imports are gone or moved. Reaching for a removed one is a defect:
MetricCard — one primitive only: @/components/ui/MetricCard (label/value/tone). The analytics tile that formats numbers/thresholds/deltas is StatTile (@/components/chart/stat-tile, StatTile/StatTileGroup) — not a second MetricCard.PrivateInput; use <Input type="password" reveal /> (the reveal prop adds the show/hide eye toggle).DashboardCard — now Card.Dashboard (title/action/tooltip + children).ToggleButton — imported from @/components/ui/SegmentedControl (re-homed); use SegmentedControl for a full option group.Modal / IconButton — removed. Use Dialog for modals; Button with the icon prop for icon-only buttons.@/components/detail/: SettingsSection/DangerSettingsSection (settings-section, also re-exported from the page-templates barrel) and DetailSidebarNav/DetailSidebarInfoLabel (detail-sidebar-nav) — not under pages/mcp/... and not Mcp-prefixed.The dashboard follows an editorial, print-like design language. The load-bearing rules:
--radius-*: initial in App.css), so rounded-sm/md/lg/... generate nothing — never write them. rounded-full is reserved for true circles (avatars, status dots, spinners); pills on wide elements are off-style.shadow-* on in-flow surfaces (cards, buttons, inputs, tiles, sticky bars). Shadows are allowed only on floating overlays (menus, dialogs, tooltips, sheets). No gradients, no colored tint washes (bg-blue-500/10, bg-amber-100, bg-*-softest panels) — express semantics with colored text, borders, or a small dot on a neutral surface.bg-card sheet on the gray page gutter. Beware: bg-background is the page-gray token, NOT white — use bg-card for white surfaces.Page.Section.Title renders both automatically (area prop overrides, "" suppresses); custom headers render <PageEyebrow /> from @/components/page-eyebrow above an h1 with text-display-sm font-thin. The area derives from the URL via useNavArea() — one source shared with the sidebar highlight.text-eyebrow (mono 11px uppercase tracked muted utility) is THE style for table headers, section overlines, and stat labels. Never hand-roll text-xs font-medium uppercase tracking-*.MetricCard from @/components/ui/MetricCard inside MetricCard.Group (one bordered strip, hairline dividers; the Group lays tiles side by side — render one MetricCard per stat inside it). tone is a required prop (TypeScript errors if omitted — there is no default) — declare a semantic tone (counts → information, health → success, errors → conditional destructive, blocked/stale → conditional warning, neutral only when nothing applies).@/components/chart/palette (exports SERIES — ink + muted brand hues, ACCENT_RED for risk/error only, TOOLTIP — square Chart.js tooltip styling, and AXIS tokens). Components resolve the themed ramp with useSeriesColors() from @/components/chart/useSeriesColors; pure data builders take a colors param instead of calling hooks.getIdentityTint(label) from @/components/gradient-colors — never random-hue or saturated gradient fills.ui/Tabs/SegmentedControl (mono uppercase, solid-ink active) for mode switches; PageTabsList + PageTabsTrigger (flush underline, no outer box) for page-level tabs. Pairing plain TabsList with PageTabsTrigger draws a boxed underline hybrid — wrong.@/components/ui; every component lives in its own directory with an index.stories.tsx beside it; add a story whenever you add a component.bg-neutral-100, border-gray-200, text-gray-500, bg-emerald-*, etc. — tokens only.@tailwindcss/typography must remain in devDependencies — the dashboard uses prose and not-prose classes directly (e.g. CatalogDetail.tsx, tool.tsx) which are provided by this plugin.Pre-GA features get a Preview or Beta badge wherever the user would otherwise mistake the feature for being GA. The same ReleaseStageBadge component renders on every surface, so labels never drift.
Source of truth: client/dashboard/src/components/release-stage-badge.tsx — exports ReleaseStageBadge and the ReleaseStage = "preview" | "beta" type.
Underlying primitive: the design system <Badge> (@/components/ui/Badge). ReleaseStageBadge composes it with background enabled — this is the source of truth for shape (mono, uppercase, tracked, hairline-bordered, square). Do not override these classes; the design system owns them. The wrapper just picks a semantic variant and adds a tooltip.
Variant → stage mapping (variant names are hooks, not literal semantics):
preview → the warning variant (amber).beta → the information variant (Speakeasy brand blue).The badge variants (
neutral | destructive | information | success | warning) are tuned for alert/feedback contexts, but the names are just hooks —warninghere means "experimental, use with caution," not "alert." That's the intended way to reuse the palettes; don't invent new variants without design buy-in.
Never hardcode Tailwind colors (no bg-violet-500, no raw bg-warning-softest spans). If you find yourself reaching for raw classes for a new badge use case, that's a signal to either pick an existing variant or add one to @/components/ui/Badge.
Set stage on the route declaration. app-sidebar.tsx forwards item.stage through ScopeGatedNavItem → NavButton, which renders the badge with a hover tooltip explaining what the stage means. The badge auto-hides in collapsed-icon mode.
// client/dashboard/src/routes.tsx
assistants: {
title: "Assistants",
url: "assistants",
icon: "bot",
stage: "preview", // ← sidebar pill appears automatically
component: AssistantsRoot,
},Gotcha: if you ever introduce another sidebar wrapper that calls
NavButtondirectly (instead of going throughNavMenuButton), you must forwardstage={item.stage}explicitly.app-sidebar.tsx'sScopeGatedNavItemdoes this — copy that pattern.
Pass stage on the primary Page.Section.Title for the page (usually the first Page.Section under Page.Body). Don't put it on secondary section titles like "Recent Chats" — the badge labels the whole feature, not individual sections.
<Page.Section>
<Page.Section.Title stage="beta">Risk Overview</Page.Section.Title>
<Page.Section.Description>…</Page.Section.Description>
</Page.Section>Gotcha: pages with multiple render branches (loading / empty / populated) must include the title — and therefore the
stage— in every branch.PolicyCenter.tsxandSecurityOverview.tsxare the reference for this pattern.
For ObserveTabNav-style tab strips, add stage to the local tab descriptor and render <ReleaseStageBadge size="xs" noTooltip> inline. Use inline-flex items-center gap-2 on the tab <Link> so the badge tracks the label without disrupting the active-tab underline.
// client/dashboard/src/components/observe/ObserveTabNav.tsx
type Tab = { label: string; href: string; stage?: ReleaseStage };
const tabs: Tab[] = [
{ label: "Employees", href: `${baseSlug}/employees`, stage: "preview" },
];<h1> headings (pages that don't use Page.Section.Title)A handful of pages render their own <h1 className="text-xl font-semibold">…</h1> instead of Page.Section.Title (e.g., InsightsEmployees, InsightsAgents). Wrap the heading and the badge in a flex items-center gap-2 div:
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">AI Agent Costs</h1>
<ReleaseStageBadge stage="preview" />
</div>Consider migrating these pages to
Page.Section.Titlein a follow-up — but don't bundle that refactor with the badge addition.
Grep stage="preview" and stage="beta" and delete every match:
routes.tsx — remove the stage: field on the route entryPage.Section.Title stage="…" — drop the propObserveTabNav (or similar) tab descriptors — drop the stage field<ReleaseStageBadge> usages — delete the element and unwrap the flex items-center gap-2 divThere's no other cleanup. The component itself stays in place for the next pre-GA feature.
DrawerContent requires DrawerTitle (and optionally DrawerDescription) inside it. Omitting them generates a console error and breaks screen reader accessibility. DrawerHeader and DrawerFooter are optional layout wrappers.
<Drawer>
<DrawerTrigger>Open</DrawerTrigger>
<DrawerContent>
<DrawerTitle>Session Details</DrawerTitle>
<DrawerDescription>Viewing trace for this chat.</DrawerDescription>
{/* content */}
</DrawerContent>
</Drawer>If the title is visually redundant, hide it from sighted users while keeping it for screen readers:
<DrawerTitle className="sr-only">Details</DrawerTitle>DialogContent requires DialogTitle (and optionally DialogDescription) inside it. Omitting them generates a console error and breaks screen reader accessibility. DialogHeader and DialogFooter are optional layout wrappers.
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
{/* content */}
</DialogContent>
</Dialog>If the title is visually redundant, hide it from sighted users while keeping it for screen readers:
<DialogTitle className="sr-only">Details</DialogTitle>© speakeasy-api, AGPL-3.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in .agents/skills/frontend of speakeasy-api/gram.
Open the folder on GitHubat commit 4d32da1
Frontend next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Frontend this skillspeakeasy-api/gram | 272 | — | ~9.8k | Automated safety check: Pass | AGPL-3.0 | |
| React Render Types CompositionHorusGoul/eslint-plugin-react-render-types | 111 | — | ~1.1k | Automated safety check: Pass | MIT | |
| DaleuiDaleStudy/daleui | 119 | — | ~675 | Automated safety check: Pass | MIT | |
| Odc FrontendDouglasNeuroInformatics/OpenDataCapture | 119 | — | ~2.3k | Automated safety check: Pass | Apache-2.0 | |
| Docs Writer Referencereactjs/ar.react.dev | 164 | — | ~6.2k | Automated safety check: Pass | CC-BY-4.0 | |
| Olore Tanstack Query Latestolorehq/olore | 103 | — | ~487 | Automated safety check: Pass | MIT |
HorusGoul/eslint-plugin-react-render-types
Composition patterns for building React components with @renders type annotations from eslint-plugin-react-render-types.
DaleStudy/daleui
Use the daleui React design system with semantic Panda CSS tokens and accessible components.
DouglasNeuroInformatics/OpenDataCapture
Write or change React UI in apps/web, apps/gateway or packages/react-core — a component (not the route file that renders it), its user-facing strings, its Storybook story, or moving it into…
reactjs/ar.react.dev
Reference page structure, templates, and writing patterns for src/content/reference/.
olorehq/olore
Local TanStack Query documentation reference (latest). An agent skill from olorehq/olore.
JetBrains/skills
Search tool for modern web development best practices. An agent skill from JetBrains/skills.
speakeasy-api/gram
A skill your agent uses when automating the Gram dashboard in a browser, capturing screenshots, inspecting pages.
speakeasy-api/gram
A skill your agent uses when adding, changing, restyling, reviewing, validating, or previewing a Gram/Speakeasy transactional email, in Go or in LMX/MJML — a template<name.go, a TemplateKey…
speakeasy-api/gram
A skill your agent uses when adding, changing, or styling UI in client/admin (the Gram admin dashboard) that touches shadcn/ui — a button, dialog, table, sidebar, badge, select, tabs, tooltip, card…
speakeasy-api/gram
A skill your agent uses when adding, editing, reviewing, testing, or locating a reviewed skill distributed with the Platform MCP plugin; triggers include "Platform MCP skill", "platformmcpskills"…
speakeasy-api/gram
A skill your agent uses when changing or reviewing Gram ClickHouse schemas, migrations, queries, inserts, access principals, bootstrap SQL, Cloud compatibility, partial migration failures, or…
speakeasy-api/gram
A skill your agent uses when gating a feature behind a flag, dogfooding or gradually rolling out a change, choosing between productfeatures and PostHog feature flags, adding or checking a product…
Categories
Rules and best practices when working on the dashboard React frontend codebase (including the inlined Gram Elements code). Frontend is an agent skill from speakeasy-api/gram.
Frontend fits situations like: frontend & Design work in your project.
Run `npx skills add speakeasy-api/gram --skill frontend -a claude-code`. Or copy the skill folder (.agents/skills/frontend in speakeasy-api/gram) into .claude/skills/frontend in your project. Claude Code loads it when a task matches its description.
Run `npx skills add speakeasy-api/gram --skill frontend -a codex`. Or copy the skill folder (.agents/skills/frontend in speakeasy-api/gram) into .agents/skills/frontend in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add speakeasy-api/gram --skill frontend -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/frontend, .gemini/skills/frontend, .github/skills/frontend and .opencode/skills/frontend in your project.
Going by SKILL.md and its folder, Frontend needs the command-line tools its instructions call (npm).
SKILL.md names 1 domain. In commands or code: speakeasy.com; the agent is likely to contact it when it follows the instructions. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Frontend is published under the AGPL-3.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 9.8k tokens (SKILL.md is roughly 39k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Frontend: React Render Types Composition (HorusGoul/eslint-plugin-react-render-types, 111 stars), Daleui (DaleStudy/daleui, 119 stars), Odc Frontend (DouglasNeuroInformatics/OpenDataCapture, 119 stars) and Docs Writer Reference (reactjs/ar.react.dev, 164 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
speakeasy-api (a GitHub organization) maintains it in speakeasy-api/gram, which has 272 GitHub stars. The repository holds 39 skills in this directory. The repository was last updated on October 7, 2026.
Source: speakeasy-api/gram on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.