-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Contributing Templates
Templates are reusable UI examples built with Astryx. There are two types:
- Page templates — Full-page application layouts you copy to scaffold a route. Displayed full-width.
- Block templates — Smaller UI patterns (a form card, a stats row, a hero section) you drop into an existing page. Displayed centered in a contained preview.
Both live in packages/cli/assets/templates/ and are automatically discovered by the sandbox and CLI.
- Create a folder in
packages/cli/assets/templates/pages/with your template name - Add a
page.tsx(your template) and atemplate.doc.mjs(metadata) - Run
pnpm --filter @astryxdesign/sandbox dev - Visit
http://localhost:3000/templates/<your-folder-name>/
- Create files in
packages/cli/assets/templates/blocks/— for component-specific blocks, useblocks/components/<Component>/ - Add a
<Name>.tsxfile (the block) and a matching<Name>.doc.mjsfile (metadata) - Run
pnpm --filter @astryxdesign/sandbox dev - Visit
http://localhost:3000/templates/<Name>/
packages/cli/templates/
pages/ # Full-page templates
dashboard/
page.tsx # The template (React component, default export)
template.doc.mjs # Metadata
login/
page.tsx
template.doc.mjs
...
blocks/ # Block templates
components/ # Component-specific blocks
Button/
ButtonBasic.tsx # Block component (default export)
ButtonBasic.doc.mjs # Block metadata
ButtonWithIcon.tsx
ButtonWithIcon.doc.mjs
Table/
TableSortable.tsx
TableSortable.doc.mjs
...
Discovery is recursive — the CLI and sandbox scan templates/pages/ and templates/blocks/ at any depth, finding paired .tsx + .doc.mjs files. The subfolder structure (e.g. components/Button/) is organizational metadata, not functionally meaningful.
| If you're building... | Type | Why |
|---|---|---|
| A complete page with navigation, layout, and content | Page | It's scaffolding — someone copies the whole file to start a route |
| A reusable UI pattern or component example | Block | It's an ingredient — someone browses it and drops it into their page |
| A login/dashboard/settings page | Page | Full app pages are always page templates |
| A form card, data table with filters, hero section | Block | Focused patterns that compose into pages |
| A realistic usage example for a component | Block | Put it in blocks/components/<Component>/
|
The folder name (page) or file name (block) is the template's slug — the ID a
contributor copies (astryx template <slug>) and one of the signals astryx build "<idea>" ranks when it picks a template for a prompt. Name for the
pattern, and let metadata carry the domain vocabulary.
Name for the component/pattern archetype, not the data or the task. The best
slug describes the reusable UI pattern in the system's own vocabulary:
data-table, dashboard, detail-panel, kanban-board, file-explorer.
| Style | Example | Verdict |
|---|---|---|
| Component / pattern |
data-table, dashboard, detail-panel
|
✅ Use this. Generalizes across domains, reinforces the category, most discoverable. |
Hybrid {adjective}-{pattern}
|
filterable-table, split-detail
|
✅ Good fallback when the bare pattern name is ambiguous. |
| Data-shape / domain noun |
customer-list, order-detail
|
customer-list under-serves the identical "product list" or "invoice list" request. |
| Purpose / verb phrase |
search-and-filter, monitor-kpis
|
❌ Avoid as a slug. Verbs rank worst in build; they don't match the pattern vocabulary a search expands to. |
Put the domain nouns and synonyms in category and description, not the
slug. This is the "name for humans, index for machines" split. build's search
folds the category string (word-split) and every rendered component name into a
page's keywords, so metadata is where domain vocabulary earns its retrieval — for
free, and expandable without renaming anything:
// A records table. Slug = the pattern; category/description = the vocabulary.
export const doc = {
type: 'page',
name: 'Customer Directory', // display name — human-facing
description:
'Browsable, searchable, filterable table of customer, user, or order ' +
'records with an action toolbar.', // nouns + synonyms live here
category: 'Table - Records', // category words become keywords
isReady: true,
};Mechanical rules for slugs:
-
kebab-case, lowercase, matching the folder/file name. Blocks follow
{Component}{Variant}in PascalCase (see Block templates). -
No
-page,-app,-view, or-screensuffix.build's search treats those as stopwords, sotable-pageranks exactly liketable— the suffix is dead weight. Drop it: name ittable, or better,data-table. -
Give every page a specific, non-empty
category. It is the strongest keyword signal a page has, and an empty category forfeits it. Prefer a{Domain} - {Pattern}shape (Dashboard - Analytics,Table - Records). -
Keep the slug to 16 characters or fewer.
buildonly substring-matches a name when the shorter of (query word, slug) is at least 4 characters and covers at least 50% of the longer one (scoreCandidate,packages/cli/api/search/search.mjs). A slug of length L therefore needs a query word of L/2 characters to match at all: at 16 chars that is an 8-letter word, and at 27 (multi-column-layout-sidebar) the name is unreachable by anything a person would type. 10 of today's 51 page slugs are already past the line. -
Write the
descriptionto the four-slot shape below. It is the only metadata field whose removal measurably degrades retrieval, and the one place domain vocabulary earns its keep.
description is the retrieval surface. Field ablation across a 36-query battery
found it is the only field whose removal degrades results (top-1 15→9,
top-3 25→18); removing name changed nothing, and removing auto-extracted
keywords or category improved precision. Every constraint below is forced by
scoreCandidate, not a style preference.
Describe the shape, not the fixture. The sample data a template ships with is
interchangeable — swap customers for invoices and it is the same template — so the
data cannot be the thing that distinguishes it. A description built on content-type
nouns describes the fixture and locks the template to it: someone building an
invoice or a job record needs detail-page exactly and matches none of its
order vocabulary. Differentiate on four structural lenses instead:
| Lens | Vocabulary |
|---|---|
| Layout | split, centered, two-column, rail, stacked, masonry, asymmetric |
| Container | modal, panel, card, drawer, inline, full-bleed |
| Data shape | flat rows, grouped, hierarchical, time series, single record |
| Behavior | drag, resize, collapse, filter, drill down, step through |
Domain nouns still belong in the description, but as interchangeable examples rather than the identity — and list several, which itself signals that none of them is definitional. Say what a sibling does differently while you are there ("unlike the uniform gallery", "sits between the bare table and the filtered one"); that comparison is usually the most discriminating sentence available.
The shape — four slots, and don't be shy about length; nothing penalizes it short of becoming a catch-all:
| Slot | What goes in it | Example (file-explorer) |
|---|---|---|
| Archetype | what kind of surface, in a builder's words | "Hierarchical browser" |
| Job | what someone does here — the verb | "that drills through nested levels" |
| Differentiator | the layout, container, data shape or behavior separating this from its siblings | "as adjacent columns rather than replacing the list each time, with a metadata pane for the selected node" |
| Synonyms | the alternate words people type, plus a few example domains | "Files, folders, directory, tree, or Finder-style navigator" |
The rules the scorer forces:
-
Spell out synonyms; the
SYNONYMSmap cannot help here. Synonym hits are multiplied by 0.85, so a description hit becomesround(50 × 0.85)= 43 — below theMIN_TOKEN_SCOREfloor of 50 — and is discarded. The map fires for names and keywords, never for a description in a multi-term query. If "metrics" should find your dashboard, write the word. -
Use base forms. The stemmer strips
ing|ed|ies|es|s, so "filter" catches filters/filtering/filtered. Nominalizations don't survive it: "filtration" matches nothing, and "people" never reaches "person". -
-ableadjectives and verbs are different words here. Because the stemmer strips only those five suffixes,resizablenever matches a query for "resize", norcollapsiblefor "collapse", nordraggablefor "drag". Where a behavior is load-bearing, spend the words on both forms — "resizable, collapsible regions… drag a divider to resize or collapse any of them". -
Don't spend words on stopwords. Sixty words are stripped from every query,
including
page,screen,app,application,view,side. "Split view" and "side panel" contribute nothing fromviewandside. - Don't repeat component names. They already sit in the keyword pool, and that pool measures net-negative. Spend the budget on structural language the JSX cannot supply — "collapsible sections", "resizable left rail", "drag and drop between lanes", "drills through nested levels", "two-pane split".
-
Distinctive beats numerous. Nothing penalizes length, so more words are
better in isolation — but a long catch-all becomes a noise magnet.
dashboard-project-statusruns ~490 characters and wrongly tops both "what a customer bought" and "empty starting point". A term earns its place only if it discriminates this template from its siblings.
Worked example, including the wrong turn, because it is the easy mistake.
detail-page shipped:
Order detail page with timeline and line items
Eight words, three of them stopwords. The first rewrite went long but stayed on the fixture:
Single order record detail for fulfilling a purchase: customer and address summary, line items with quantities, totals with discount, shipping and tax, refund and invoice actions, plus an event timeline.
Better for retrieval, still wrong in kind — every distinguishing noun describes the order data it happens to ship. Someone building an invoice, a shipment or a job record wants precisely this layout and would match almost none of it. The structural version:
Single-record detail in two columns: a summary header with status and actions, a repeating line-item list, a totals block that sums it, and a chronological activity timeline in the rail. The shape for any record holding children plus a history — a customer order with shipping, an invoice, a transaction, or a job.
Layout (two columns, rail), data shape (one record with repeating children),
behavior (totals that sum) carry the claim; the nouns are demoted to a list of
examples. This is not just tidier — across a 36-query battery, structure-led
descriptions scored 32/36 top-1 against 23/36 for the noun-led pass once
build's multi-token scoring was fixed, because structural vocabulary is what a
builder types when they don't know the slug.
Why this is the convention. It was chosen by an API Arbitration that measured all four naming styles against
build's real scoring: a name-only control, a noun-generalization probe, and rank in the live roster. Component and hybrid names won on retrieval and generalized cleanly across domain nouns; data-shape names spiked on their own noun and under-served siblings; verb/ purpose names ranked worst. The domain vocabulary belongs in metadata, whichbuildweights more heavily than the slug anyway.Refinement (template-naming vibe test, 2026-08). A 2×2 that renamed all 51 page templates to a screen-based taxonomy found the name axis is a null on natural-language queries: 35 of 36 queries returned an identical ranking, and ablating
nameentirely changed 0 of 36 results. Pattern slugs still win, but on self-retrieval and legibility — a builder typing the template's own label finds it 74% of the time under current names versus 58% under the screen-based ones — not on natural-language retrieval. The practical consequence is the one this section already implies, now measured: authoring effort belongs indescription. Seeinternal/vibe-tests/template-naming-test/PLAN.md.
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export const doc = {
type: 'page',
name: 'Your Template Name',
description: 'One sentence about what this template does.',
isReady: false,
};| Field | Type | Description |
|---|---|---|
type |
'page' |
Must be 'page' for page templates |
name |
string | Display name shown in the sandbox gallery and CLI |
description |
string | One-sentence description |
isReady |
boolean | Set to false while developing. Shows "(WIP)" in the gallery and CLI. Set to true when it's done. |
/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export const doc = {
type: 'block',
name: 'Button — With Icon',
description: 'Button with a leading icon and text label.',
isReady: true,
aspectRatio: 1,
componentsUsed: ['Button'],
};| Field | Type | Description |
|---|---|---|
type |
'block' |
Must be 'block' for block templates |
name |
string | Display name, typically "Component — Example"
|
description |
string | One-sentence description |
isReady |
boolean | Set to false while developing |
aspectRatio |
number | Width-to-height ratio for preview containers |
scale |
number | Optional transform scale for the preview (default 1). Use 2 or 3 for tiny components like Badge, StatusDot, or single icons so they're visible in the preview box. |
componentsUsed |
string[] | Component names this block uses, for cross-referencing |
The aspect ratio controls the preview container that blocks render inside. The block is centered in a box with this ratio, constrained to a max width of 600px. The goal is to make the component fit the box naturally without excessive whitespace.
| Component shape | Ratio | Examples |
|---|---|---|
| Wide/horizontal | 16 / 4 |
TopNav, Breadcrumbs, Banner, Toolbar, TabList, Divider |
| Square | 1 |
Button, Badge, Avatar, Icon, Spinner, StatusDot |
| Tall/vertical | 3 / 4 |
Calendar, SideNav, List, TreeList |
| Content/form | 4 / 3 |
Card, Dialog, Table, TextInput, FormLayout |
How to tweak: Open the block preview in the sandbox (/templates/<Name>/). Below the preview box you'll see the current aspect ratio and a hint. Adjust the aspectRatio value in your .doc.mjs file until the component fills the box without clipping or excessive padding.
For small components (Badge, StatusDot, single Button, Icon), the default preview can look tiny inside the aspect-ratio box. Set scale in your .doc.mjs to enlarge the component in the preview:
export const doc = {
type: 'block',
name: 'Badge — Text Badge',
description: 'Simple text badge with the default neutral variant.',
isReady: true,
aspectRatio: 1,
scale: 2, // renders at 200% in the preview
componentsUsed: ['Badge'],
};The preview toolbar also has zoom controls (+/−) so reviewers can adjust the scale live without editing the doc file. The scale field sets the default.
If you're building a page template, follow these rules.
Your template is a React component with a default export. It renders fullscreen (no sandbox shell wrapping it), so you control the entire page layout. Use 'use client'; at the top.
The rule: page templates must not render AppShell (or TopNav / SideNav as global app chrome). The page root is Layout (or Center for centered single-card pages).
Exception — the Shell category. Templates whose category begins with Shell - (e.g. Shell - Top Nav, Shell - Left Sidebar, Shell - Top Nav + Left Sidebar) exist specifically to demonstrate app shell + navigation composition. They must use AppShell as their root, render TopNav and/or SideNav as global chrome, and typically fill the body with Skeleton placeholders. Every other category is content-only and uses Layout (or Center) as its root, per the rest of this section.
Templates are page content, not whole applications. Whoever consumes a template owns the surrounding chrome:
- An internal product wraps it in its own app shell (with company nav).
- The OSS sandbox/docsite wraps it in a standard demo shell.
- A standalone tool may use no shell at all.
A template that shipped its own AppShell would collide with the host's: you'd get a shell nested inside a shell (two viewport-claiming 100dvh containers, doubled scroll regions, duplicated chrome), and the host would need conditional logic to detect and strip the inner shell before injecting its own. By keeping templates content-only, the host decides the shell exactly once, and there is no conditionality to maintain. (See the "Why content-only?" note below.)
If you want to demonstrate shell composition itself (TopNav + SideNav + banner) as a full page, use a Shell - category page template (root AppShell). Smaller, isolated shell-composition examples can also live in the AppShell blocks (blocks/components/AppShell/).
| I'm building... | Root element | Why |
|---|---|---|
| Any full app page (dashboard, table, settings, editor) | Layout |
Content frame with header / content / start / end / footer slots. The host supplies the shell + nav around it. |
| A page with its own in-page section navigation (settings tabs, a docs catalog that switches the content pane) |
Layout with a start panel |
This nav is part of the page, not app chrome — render it as an LayoutPanel in the start slot. |
| A page-level header (title + page-scoped search/actions) |
Layout with a header slot |
Page headers belong to the page; put them in LayoutHeader. (App chrome — global top nav — does not.) |
| A standalone page with no chrome (login, auth, marketing, onboarding) | Center |
Centered single-card layout. |
| A sub-layout inside the content area |
Layout nested inside the content |
Split panes, detail panels, IDE-style multi-pane layouts. |
A Shell - category template (showcasing app shell + nav) |
AppShell |
The whole point is to demonstrate global chrome — render TopNav / SideNav in the shell and Skeleton placeholders for the body. |
Valid page root elements: Layout or Center — except Shell - category templates, which use AppShell as their root. No raw <div> roots.
Distinguish app chrome from in-page navigation. App chrome is global navigation that belongs to the whole product (top-level nav between sections, a product logo, account menus, a File/Edit/View menubar) — delete it; the host provides it. In-page navigation switches content within this one page (settings tabs, a documentation component switcher, a VSCode-style activity bar) — keep it, rendered as an
LayoutPanelin the layout'sstartslot.
'use client';
import {Layout, LayoutContent} from '@astryxdesign/core/Layout';
import {Heading, Text} from '@astryxdesign/core/Text';
export default function MyTemplate() {
return (
<Layout
content={
<LayoutContent>
<Heading level={1}>Page Title</Heading>
<Text type="body">Page content goes here.</Text>
</LayoutContent>
}
/>
);
}Use the header slot for the page title/actions and the start slot for in-page section nav. (Note: this is in-page nav — switching sections within the page — not global app chrome.)
'use client';
import {useState} from 'react';
import {
Layout,
LayoutContent,
LayoutHeader,
LayoutPanel,
} from '@astryxdesign/core/Layout';
import {Heading} from '@astryxdesign/core/Text';
import {List, ListItem} from '@astryxdesign/core/List';
const SECTIONS = ['Profile', 'Account', 'Notifications'];
export default function MyTemplate() {
const [active, setActive] = useState('Profile');
return (
<Layout
height="auto"
header={
<LayoutHeader hasDivider>
<Heading level={1}>Settings</Heading>
</LayoutHeader>
}
start={
<LayoutPanel hasDivider={false} padding={0}>
<List density="balanced">
{SECTIONS.map(s => (
<ListItem
key={s}
label={s}
isSelected={active === s}
onClick={() => setActive(s)}
/>
))}
</List>
</LayoutPanel>
}
content={
<LayoutContent padding={4}>
{/* Section content for `active` */}
</LayoutContent>
}
/>
);
}-
height:'fill'(100% of host container, content scrolls internally) |'auto'(grows with content, page scrolls as a whole) -
contentWidth: max content width in px (centers content; dividers stay full-bleed) - Slots:
header(LayoutHeader),content(LayoutContent),start/end(LayoutPanel),footer(LayoutFooter) - Content padding lives on
LayoutContentvia itspaddingprop (0 for dashboards/tables, 4 for forms/settings)
Don't paint a background or claim the viewport in the template — that's the host shell's job. Pick
height="fill"for app-style pages whose content scrolls internally (the host gives them a full-height region) andheight="auto"for document-style pages.
Use Astryx components from @astryxdesign/core/*. StyleX works for custom styles. Check npx astryx component --list to see what's available.
Icons: always use Icon.
Wrap SVG icons in <Icon> to get consistent sizing, color tokens, and alignment. Don't render raw <svg> elements at arbitrary sizes.
// ✅ Correct — themed color + consistent size
import {Icon} from '@astryxdesign/core/Icon';
import {ShieldCheckIcon} from '@heroicons/react/24/outline';
<Icon icon={ShieldCheckIcon} size="md" color="secondary" />
// ❌ Wrong — raw SVG with hardcoded dimensions
<ShieldCheckIcon style={{width: 20, height: 20}} />Icon sizes: xsm (12px), sm (16px), md (20px), lg (24px).
Exception: Components that accept an icon prop (like SideNavItem, Button) handle wrapping internally — pass the component reference directly:
// ✅ SideNavItem wraps it for you
<SideNavItem label="Dashboard" icon={HomeIcon} />
// ❌ Don't double-wrap
<SideNavItem label="Dashboard" icon={<Icon icon={HomeIcon} />} />Responsive grids: use Grid, not stacks.
Use Grid with minChildWidth for any grid of cards, tiles, or items that should reflow. Do NOT use HStack with wrapping or manual media queries.
// ✅ Auto-reflows based on available width
<Grid minChildWidth={280} gap={4}>
<Card>Item 1</Card>
<Card>Item 2</Card>
<Card>Item 3</Card>
</Grid>
// ❌ Doesn't reflow, breaks on small screens
<HStack gap={4}>
<Card>Item 1</Card>
<Card>Item 2</Card>
<Card>Item 3</Card>
</HStack>Stacks vs Grid:
-
VStack/HStack— fixed-direction layouts (forms, toolbars, button rows) -
GridwithminChildWidth— responsive grids that reflow (KPI cards, product tiles, galleries)
Centering: use Center.
For centering content (empty states, login forms, loading screens), use Center instead of flexbox or StyleX hacks.
<Center height="fill">
<Card>Centered content</Card>
</Center>Page templates used to wrap themselves in AppShell. As of PR #2528 they no longer do, for two reasons:
-
Consistency. Templates were inconsistent about whether they included navigation chrome. Some shipped a full sideNav, some a topNav, some none — so copying two templates into one app gave you mismatched, competing shells. Making every template content-only removes that variance: a template is always just the page body.
-
No internal-vs-external shell conditional. Consumers each have their own shell — an internal product injects its company app chrome, the OSS sandbox uses a demo shell, some tools use none. The host swaps in its shell around the template. If the template also shipped an
AppShell, the host would have to branch: "does this template already have a shell? if so, unwrap it before adding mine." That conditional is exactly what we wanted to avoid. A shell-inside-a-shell is also broken on its own — two100dvhviewport containers, nested scroll regions, and doubled chrome padding.
The fix is structural, not cosmetic: the template never provides a shell, so the host owns it unconditionally. Shell + nav is a separate, swappable layer — demonstrated either in the Shell - category page templates (root AppShell) or in the AppShell blocks (blocks/components/AppShell/), which is where shell-composition examples belong. This content-only rule applies to every category except Shell -, whose entire purpose is to showcase the shell.
What this means when authoring or migrating a template (every category except Shell -):
-
Root is
Layout(orCenter), neverAppShell. (Shell -category templates are the sole exception — they useAppShell.) - App chrome → delete it. Global/product navigation (top-level section nav, product logo, account menu, File/Edit/View menubar) is the host's responsibility.
-
Page header →
LayoutHeader. A title plus page-scoped search/actions belongs to the page (hosts don't supply page headers). Put it in theheaderslot. -
In-page section nav →
LayoutPanel. Navigation that switches content within this page (settings tabs, a docs catalog, an IDE activity bar) stays in the template, rendered in thestartslot.
If you're building a block template, follow these rules.
Blocks are smaller, focused components. They render centered in a contained preview, not fullscreen. Keep them to 20-100 lines. Include all necessary imports. Use 'use client'; at the top.
'use client';
import {Button} from '@astryxdesign/core/Button';
export default function ButtonBasic() {
return <Button label="Click me" variant="primary" />;
}- Keep blocks focused on one pattern or one component usage
- Include all imports — blocks must be self-contained
- Use realistic mock data (real names, realistic values)
- List all Astryx components used in
componentsUsedin your.doc.mjsso they show up in related-component lookups - Don't wrap blocks in
AppShell— they render inside a preview container, not as full pages
A sync script (scripts/sync-templates.js) recursively scans templates/pages/ and templates/blocks/ and generates:
- Route wrappers under
(fullscreen)/templates/so the sandbox can render each template and block at/templates/<slug>/ - Registry files (
templateRegistry.ts,blockRegistry.ts) with metadata (name, description, component, aspectRatio, etc.) - Source registry for the code view
This runs automatically via predev and prebuild hooks. You don't need to run it manually.
Both page templates and blocks live under /templates/<slug>/. There is no separate /blocks/ route. The preview shell automatically detects whether a slug is a block (by checking the block registry) and renders it accordingly:
- Page templates render fullscreen with viewport controls (desktop/tablet/mobile). Tablet and mobile use an iframe so CSS media queries respond to the actual viewport width.
- Blocks render centered in an aspect-ratio box (max-width 600px) with the ratio displayed below. No viewport controls.
The sandbox has an Official Templates page at /templates/ (accessible from the sidebar). It shows a sortable, filterable table of all templates and blocks with columns for Type, Name, Component, Summary, and copy-to-clipboard paths for Code and Doc files. Sort and filter state syncs to URL query params so you can share filtered views (e.g. ?filter.type=Block&filter.component=Button).
In the preview shell, clicking the sidebar icon (left of the toolbar) opens a tree view:
- Pages — all page templates
- Components — blocks grouped by component (e.g. Button > Basic, With Icon)
Blocks can be component-specific or general:
-
Component-specific blocks live in
blocks/components/<ComponentName>/(e.g.blocks/components/Button/ButtonBasic.tsx). They appear grouped under their component in the sidebar tree and the templates table. -
General blocks can live anywhere under
blocks/. The parent folder name becomes the component association.
# From the repo root
pnpm --filter @astryxdesign/sandbox dev
# All templates and blocks:
# http://localhost:3000/templates/<slug>/
# Official Templates table:
# http://localhost:3000/templates/Hot reload works. Edit your files and see changes immediately in the browser.
When you open a PR, GitHub Actions builds the sandbox and deploys a preview. Your template will be visible at:
https://studious-broccoli-o7e61n3.pages.github.io/pr/<PR-number>/sandbox/templates/<folder-name>/
The PR comment includes a link to the sandbox preview.
# List all templates (pages and blocks)
npx astryx template --list
# List only page templates
npx astryx template --list --type page
# List only block templates
npx astryx template --list --type block
# Show a template's source
npx astryx template <name>
# Copy a template to your project
npx astryx template <name> ./src/pages/my-page
# View a component with its related blocks
npx astryx component Button- Keep templates self-contained. All imports should come from
@astryxdesign/core/*or be defined in the template itself. - Use realistic fake data. Templates should look like a real app, not a placeholder.
- Set
isReady: falsewhile you're still working on it. Flip totruewhen you're happy with it.
Templates that need images use the checked-in demo assets under apps/docsite/public/template-assets/, referenced by a root-relative path.
- ✅ Reference
/template-assets/<name>.png— 101 assets ship in the repo today - ❌ Do NOT link to external URLs (unsplash, placehold.co, picsum, …)
- ❌ Do NOT paste a signed CDN URL (
https://scontent.xx.fbcdn.net/…?oh=…&oe=…) — those carry time-limited tokens and expire within days
Why checked-in, not a CDN. A template's images have to survive being scaffolded into someone else's project, offline, on a fork, with no network. The CLI handles that: stripTemplateAssetRefs (packages/cli/foundation/discovery/template-adapter.mjs) rewrites every /template-assets/… reference to a self-contained placeholder data URI when a template is scaffolded, so the page renders with zero setup and the builder drops in their own images. That rewrite keys on the root-relative path — a CDN URL would slip straight through it and leave the scaffolded project pointing at an asset it does not own.
Naming follows {style}-{category}-{orientation}-{number}, e.g. /template-assets/light-home-square-1.png.
📋 Browsing? See Template Assets for the full, categorized list of every image and video in the set, with direct links to preview each one.
The astryx set currently has ~140 images plus one video. Most images follow the pattern {style}-{category}-{orientation}-{number}:
| Dimension | Options |
|---|---|
| Style |
colorful, light, moody, illustrative
|
| Category |
home, lifestyle, working, product, scene
|
| Orientation |
horizontal, vertical, square
|
Some assets have unique names outside this pattern (e.g., building, theme-example families like Butter-* / Y2K-*, generic demo people DATA-*). Not all combinations exist — always check the full list before assuming a name is valid.
Step 1: Find the asset. Browse apps/docsite/public/template-assets/ in the repo, or the Template Assets page for a categorized list with previews.
ls apps/docsite/public/template-assets/ | grep light-workingStep 2: Reference it root-relative. The path is /template-assets/ plus the filename — no host, no origin:
<img src="/template-assets/light-working-vertical-1.png" alt="…" />@2x variants exist for some assets (DATA-Ami-Pena@2x.png). If the image you need is not in the set, add the file under apps/docsite/public/template-assets/ in the same PR, following the {style}-{category}-{orientation}-{number} naming.
Step 3: Verify it renders. Run the sandbox or docsite and look at the template — a path typo produces a broken image, not a build error.
pnpm -F @astryxdesign/sandbox devCheck the scaffolded form too, since that is what a consumer gets: stripTemplateAssetRefs swaps every /template-assets/… reference for a placeholder data URI, so the template must still read sensibly with placeholder images in place of yours.
Version 1.2 — see Versions for what that means and what changed.
Quality rubric for grading templates. Use this to evaluate any template (page or block) against the guidelines above. Every template in the gallery should score B or above.
Completed audit scores live in the central wiki ledger and appear on the sandbox's Template Audits page. See Recording an audit for the storage location, scorecard schema, and publication workflow.
| Grade | Score | Meaning |
|---|---|---|
| A | 90–100 | Exemplary. Copy-paste ready. No excuses needed. |
| B | 75–89 | Good. Minor issues a reviewer would flag but approve. |
| C | 60–74 | Needs work. Would get "request changes" in review. |
| D | 40–59 | Poor. Significant rewrites needed. |
| F | 0–39 | Failing. Actively teaches bad patterns. |
Count every JSX opening tag in the .tsx file. Classify each as Astryx or raw HTML.
-
Astryx element: Any component imported from
@astryxdesign/core(e.g.<Button>,<VStack>,<Card>). Component names are unprefixed. -
Raw HTML element: Any lowercase intrinsic JSX tag:
div,span,button,nav,aside,main,section,article,header,footer,figure,ul,ol,li,p,h1–h6,form,input,textarea,select,option,label,a,table,thead,tbody,tr,td,th,dl,dt,dd,hr,br,pre,code,blockquote,details,summary,dialog,menu,img,picture,video,audio,canvas,iframe -
Don't count: React fragments (
<>/<Fragment>), locally-defined PascalCase components (e.g.<Sidebar>,<KPICard>) — these are fine as long as they're defined in the same file and internally use Astryx components
For each raw HTML tag found, judge whether it's necessary (no Astryx equivalent) or unnecessary (an Astryx component could replace it).
| Raw HTML elements | Points |
|---|---|
| 0 | 30 |
| 1–2 (all necessary) | 25 |
| 1–2 (any unnecessary) | 20 |
| 3–5 | 15 |
| 6–10 | 8 |
| 11–20 | 4 |
| 21+ | 0 |
Use this to judge "necessary" vs "unnecessary":
| Raw HTML | Astryx Replacement |
|---|---|
<div> for layout |
VStack, HStack, Card, Section, Center
|
<div> for grid |
Grid |
<span> for text |
Text |
<p>, <h1>–<h6>
|
Text type="body", Heading level={N}
|
<button> |
Button, IconButton
|
<a> |
Link |
<nav>, <aside>
|
In-page nav → LayoutPanel (start slot). Global app nav → omit; the host shell provides it. |
<header>, <main>
|
LayoutHeader, LayoutContent slots of Layout
|
<ul>, <ol>, <li>
|
List, ListItem
|
<input> |
TextInput, NumberInput, CheckboxInput, etc. |
<textarea> |
TextArea |
<select> |
Selector |
<table>, <tr>, <td>
|
Table (data-driven API) |
<img> |
Acceptable IF src is a /template-assets/… path |
<form> |
FormLayout |
<hr> |
Divider |
<dialog> |
Dialog |
<details>, <summary>
|
Collapsible |
Necessary exceptions (don't penalize):
-
<img>with a/template-assets/…src (Astryx doesn't have a general Image component) -
<form>wrappingFormLayoutfor native submission semantics -
<input type="hidden">for form state
Every icon must use one of these patterns:
-
<Icon icon={SomeHeroIcon} />— standalone icon -
icon={SomeHeroIcon}as a prop on Astryx components (Button, SideNavItem, etc.)
Violations:
- Raw
<svg>elements used as icons - Inline SVG components defined in the file (
function ChevronIcon() { return <svg>... }) - Heroicons rendered without Icon wrapper (
<ChevronDownIcon className="w-5" />) - Any
<svg>,<path>,<circle>,<rect>,<line>,<polyline>,<polygon>,<ellipse>,<g>elements
| Raw SVG icon instances | Points |
|---|---|
| 0 | 15 |
| 1–2 | 10 |
| 3–5 | 5 |
| 6+ | 0 |
Templates should style through Astryx component props (gap, padding, variant, size, color, etc.), not custom CSS.
What counts as custom CSS (count individual CSS properties, not call sites):
- Properties inside
stylex.create({...})— e.g.{ root: { padding: 8, display: 'flex' } }= 2 declarations - Properties inside
style={{...}}JSX props — e.g.style={{ display: 'flex', gap: 8 }}= 2 declarations -
className="..."orclassName={...}— 1 per usage -
stylex.props(...)in JSX — 1 per usage
What does NOT count:
- Astryx component props that accept design-token values:
gap={4},padding={3},variant="primary",size="md",color="secondary",level={2},minChildWidth={280},contentPadding={4},height="fill"— these are the system working as designed
| Total custom style declarations | Points |
|---|---|
| 0 | 15 |
| 1–3 (justified — no Astryx alternative) | 12 |
| 1–3 (unjustified — Astryx prop exists) | 8 |
| 4–10 | 5 |
| 11–20 | 2 |
| 21+ | 0 |
Rules:
- The page root element must be
LayoutorCenter— neverAppShell, no raw<div>roots. Exception:Shell -category templates must useAppShellas their root. - No
AppShell, and noTopNav/SideNavused as global app chrome — exceptShell -category templates, which useAppShellwith globalTopNav/SideNavby design. For all other categories, in-page section nav goes in anLayoutPanel(start slot); page headers go inLayoutHeader - Responsive grids use
GridwithminChildWidth(not fixedcolumnsprop, not raw CSS grid) - Centered content uses
Center(not flexbox hacks) -
Single page only — the template is one
page.tsxfile rendering one page. No nested routes, no multi-page navigation that actually works, no router integration. The template demonstrates one page layout. Navigation links can exist but should be inert (href="#"oronClickhandlers).
| Condition | Points |
|---|---|
Correct root for the category — AppShell for Shell - templates, Layout / Center for all others — plus responsive grids, proper centering, single page |
15 |
| Valid root element but with issues (fixed columns, multi-page) | 8 |
Wrong root for the category (AppShell in a non-Shell template, or a non-AppShell root in a Shell - template), raw div root, or no Astryx root element |
0 |
-
Not wrapped in
AppShell(blocks render in a preview container) - Focused on a single pattern or component usage
- Between 20–100 lines (under 20 is too thin to be useful, over 100 is unfocused)
| Condition | Points |
|---|---|
| No AppShell, single-pattern focus, reasonable length | 15 |
| Minor issues (slightly long, slightly unfocused) | 10 |
| Wrapped in AppShell OR deeply unfocused | 0 |
Read the .doc.mjs file. The 10 points split three ways: fields are present
and accurate (6), the description follows the description convention (3),
and the slug and category follow the naming convention (1).
Changed in rubric 1.1. Naming and description conventions used to be an explicit non-scored soft signal here. They are now scored, and weighted by what each is measured to be worth:
descriptionis the only metadata field whose removal degrades retrieval, while the name axis decided 0 of 36 queries under ablation. A template can no longer score 10/10 on the strength of a three-word description like "Data table with actions".
Page templates — required fields:
type: 'page'-
name: string— display name -
description: string— one sentence isReady: boolean
Block templates — required fields:
type: 'block'-
name: string— typically"Component — Variant" -
description: string— one sentence isReady: boolean-
aspectRatio: number— must match component shape (see table) -
componentsUsed: string[]— must list ALL Astryx components actually used in the .tsx
Aspect ratio guide (blocks):
| Component shape | Ratio | Examples |
|---|---|---|
| Wide/horizontal | 16 / 4 |
TopNav, Breadcrumbs, Banner, Toolbar, TabList, Divider, Pagination, SegmentedControl |
| Square | 1 |
Button, Badge, Avatar, Icon, Spinner, StatusDot |
| Tall/vertical | 3 / 4 |
Calendar, SideNav, List, TreeList |
| Content/form | 4 / 3 |
Card, Dialog, Table, TextInput, FormLayout, TextArea |
Scale (blocks with small components): Badge, StatusDot, single Icon, Spinner → scale: 2 or 3.
componentsUsed accuracy: Every component imported in the .tsx must appear in componentsUsed (e.g. Button → 'Button').
| Condition | Points |
|---|---|
| All fields present, aspectRatio correct, componentsUsed accurate, scale where needed | 6 |
| All fields present but 1 inaccuracy | 4 |
| Missing 1 required field | 2 |
| Missing 2+ fields or no .doc.mjs | 0 |
Grade description against Writing the description.
Count distinct content words — words remaining after you drop stopwords
(page, screen, app, view, side, …) and any Astryx component name, which
is already in the keyword pool and earns nothing here.
| Condition | Points |
|---|---|
| All four slots present (archetype, job, differentiator, synonyms), separating this template from its siblings on layout / container / data shape / behavior, 6+ distinct content words, base forms, no component names carrying the load | 3 |
| Names the pattern and differentiates, but a slot is missing, a synonym is left implicit, or sample-data nouns are doing the separating | 2 |
| Generic or component-name restatement, or describes only the fixture it ships with (e.g. "Data table with actions", "Order detail page with timeline and line items") | 1 |
Absent, or a verbatim restatement of name
|
0 |
A description long enough to be a noise magnet is not a 3. If it reads as a
catch-all that would plausibly answer queries this template should lose (the
~490-character dashboard-project-status case), cap it at 1 and file a finding.
Grade the slug and category against Naming a Template.
This is 1 point because the name axis is measured inert for natural-language
retrieval — it is scored for legibility, self-retrieval, and the
astryx template <slug> copy path, not for build rank.
| Condition | Points |
|---|---|
Page: kebab-case pattern slug, 16 characters or fewer, no -page/-app/-view/-screen suffix, and a specific non-empty {Domain} - {Pattern} category. Block: PascalCase {Component}{Variant}, with {Component} matching a real Astryx component |
1 |
Any miss — stopword suffix, page slug over 16 characters, data-shape or verb-phrase slug, a vague or empty category, or a block name that doesn't lead with its component |
0 |
The 16-character ceiling is a page rule. Block names are compositional by
convention and are reached through their component association rather than by
being typed into build, so length is not scored for them.
Search the .tsx for image URLs or <img> tags.
-
Correct: root-relative
/template-assets/<name>.pngpaths, resolving to a file underapps/docsite/public/template-assets/. Names follow{style}-{category}-{orientation}-{number}. -
Incorrect: any absolute URL — a CDN or lookaside link bypasses
stripTemplateAssetRefs— plus external URLs (unsplash, placehold.co, picsum), and data URIs standing in for real images.
| Condition | Points |
|---|---|
No images needed, or every src is a /template-assets/… path that exists |
5 |
A /template-assets/… path that does not resolve to a checked-in file |
2 |
| Placeholder services (placehold.co, picsum, …) | 2 |
| Any absolute URL — CDN, lookaside, or external | 0 |
2 points each:
| Check | Points | How to verify |
|---|---|---|
'use client' directive |
2 | First line of .tsx is 'use client';
|
| Default export | 2 | File has export default function ComponentName()
|
| Self-contained imports | 2 | ALL imports from @astryxdesign/core/* or @heroicons/react/* or defined in same file. No external packages. |
| Realistic mock data | 2 | Uses names like "Sarah Chen", amounts like "$12,450", dates, etc. NOT "Lorem ipsum", "Item 1", "Placeholder", "Example", "Test", "foo", "bar". |
| No dead code | 2 | No unused imports, no unused variables, no commented-out blocks, no defined-but-never-called functions. |
When grading a template, produce this format:
# Template Grade: [NAME]
**Type**: page | block
**File**: [path]
**Grade**: [LETTER] ([SCORE]/100)
## Scorecard
| Category | Points | Max | Details |
|----------|--------|-----|---------|
| Astryx Component Purity | X | 30 | |
| Icon Purity | X | 15 | |
| Custom CSS | X | 15 | |
| Layout & Structure | X | 15 | |
| Doc Metadata | X | 10 | fields X/6 · description X/3 · naming X/1 |
| Image Handling | X | 5 | |
| Code Quality | X | 10 | |
| **TOTAL** | **X** | **100** | |
## Detailed Findings
### Raw HTML Elements ([count])
[line number, tag, necessary or has Astryx replacement]
### Raw SVG Icons ([count])
[line number, what icon]
### Custom CSS Declarations ([count])
[line number, property, what Astryx prop could replace it]
### Layout Issues
[description]
### Doc Metadata Issues
[missing or inaccurate fields]
### Description Convention
[distinct content word count, which of the four slots are missing, any component
names or stopwords carrying the load, and the rewritten description you propose]
### Naming Convention
[slug length, casing, stopword suffix, slug style, and the `category` value]
### Image Issues
[problematic URLs]
### Code Quality Issues
[violations]
## Top 3 Fixes
1. [highest impact]
2. [second]
3. [third]
Where the data lives, and how to publish a completed template review.
Storage. template-scores.json in the wiki repository (github.com/facebook/astryx.wiki.git). It contains audited templates only. An unaudited template has no placeholder row to maintain.
Viewing. The Template Audits page in the sandbox joins the ledger to the current page and block roster under packages/cli/assets/templates/. A template with no ledger entry appears as TBD, and a newly added template appears automatically. The page fetches the wiki ledger at runtime, so publishing a score does not require rebuilding the sandbox.
Who writes. The human or agent that completed the full audit writes the result. CI does not produce or publish scores because the rubric requires browser inspection and judgment. Publishing requires write access to the Astryx wiki.
Use scripts/template-score-ledger.mjs, on main since #4957. Run it from the Astryx repository root. Copy the exact page/<slug> or block/<Slug> ID from the Template Audits table or --queue; block IDs are case-sensitive.
# Pick an unaudited template first, then the oldest audits
node scripts/template-score-ledger.mjs --queue --limit 1
# Validate the scorecard and preview the exact ledger diff
node scripts/template-score-ledger.mjs --record page/centered-hero \
--from /tmp/centered-hero-scorecard.json --dry-run
# Commit, rebase, and publish the wiki ledger
node scripts/template-score-ledger.mjs --record page/centered-hero \
--from /tmp/centered-hero-scorecard.json --push--push refreshes the wiki, commits only template-scores.json, rebases before publishing, retries one concurrent-writer race, and prints the wiki commit URL. --push is the only publication step; --dry-run never writes or pushes, and a local --ledger <path> only updates that file unless the command also includes --push. --from - reads JSON from stdin when piping is more convenient.
Save only the audit payload. Do not add id or status; the tool owns those fields, marks the row current, derives the grade, sorts the ledger, and updates the ledger date. Screenshot-test evidence belongs in the evidence array, with a descriptive label and an href when the evidence is available at a durable URL.
{
"score": 88,
"lastAudited": "2026-08-12",
"commit": "<audited-git-sha>",
"rubricVersion": "1.2",
"categories": {
"component_purity": {"score": 25, "status": "published"},
"icon_purity": {"score": 15, "status": "published"},
"custom_css": {"score": 12, "status": "published"},
"layout_structure": {"score": 15, "status": "published"},
"doc_metadata": {"score": 10, "status": "published"},
"image_handling": {"score": 5, "status": "published"},
"code_quality": {"score": 6, "status": "published"}
},
"findings": [
"packages/cli/assets/templates/pages/centered-hero/page.tsx:42 uses a raw layout wrapper."
],
"topFixes": [
"Replace the raw wrapper with the matching Astryx layout primitive."
],
"evidence": [
{
"label": "Light/dark desktop and responsive screenshots",
"href": "<evidence-url>"
}
],
"notes": "Audited at the recorded commit."
}A fresh audit uses published for category results measured in that pass. inferred, intermediate, and unresolved exist to preserve the provenance of recovered historical results; do not use them to guess missing evidence.
- Audit the recorded commit at HEAD. Inspect the rendered template in a real browser, cover light and dark states, and include responsive viewport coverage for page templates.
- Work all seven categories. Every category needs a numeric score within its maximum, and the seven scores must add exactly to the overall score.
-
Record evidence, not assumptions. Cite exact file locations for deductions and put the screenshot-test evidence used in the review in the scorecard's
evidencearray. -
Let the tool derive the grade. If
gradeis supplied, it must match the rubric thresholds exactly. -
Explain regressions. A lower re-audit score is refused unless the command includes
--allow-regression "<why>"; the explanation is stored in the wiki commit message. -
Use one exact template ID. Page IDs are
page/<directory-slug>; block IDs areblock/<case-sensitive-file-slug>.
The tool validates unknown fields, duplicate IDs, category maxima, exact totals, provenance fields, and the ledger schema before writing. It also refuses to create a missing one-row ledger, so the historical collection cannot be replaced accidentally.
node scripts/template-score-ledger.mjs --stats
node scripts/template-score-ledger.mjs --queue --limit 5
node scripts/template-score-ledger.mjs --stats --jsonThe template ledger supports --stats, --queue, and --record. It does not have the component ledger's --check or --file-issues commands. The ledger is a report, not a CI or merge gate.
Current: 1.2, last changed 2026-08-27.
Cite this version in every audit. Every new audit scorecard and every current ledger row must carry the exact current value in its rubricVersion field. Scores taken under different rubric versions are not directly comparable; re-audit under the current version before interpreting a score delta. Recovered historical rows remain unversioned, as noted below.
Versioning rule. Treat the legacy 1 identifier as 1.0. Bump the minor when scoring changes (weights, thresholds, what counts as evidence, or any rule that can change a score) — the next such version is 1.1. Bump the patch when only wording, citations, or a check's detection recipe changes — the next such version is 1.0.1. Record every change in the dated history below.
Keep the version synchronized. When it changes, update the version heading on this page, the scorecard example in Recording an audit, the live ledger's top-level rubricVersion, and the bundled sandbox ledger seed together.
The rubric was introduced on 2026-06-23, followed by a Shell-category scoring change on 2026-06-26 and an image-handling scoring change on 2026-08-10. Because formal version tracking began afterward, those changes are folded into version 1 rather than retroactively numbered.
| Version | Date | What changed |
|---|---|---|
| 1.2 | 2026-08-27 | Changed what a description must differentiate on. Writing the description now asks for layout, container, data shape and behavior, because the sample data a template ships with is interchangeable and therefore cannot be what distinguishes it; domain nouns are demoted to interchangeable examples. §5b caps a description at 2 when sample-data nouns are doing the separating, so this can change a score and takes a minor bump. Also documents that -able adjectives and verb forms never match each other under the stemmer. Point values, category maxima and the 100-point total are unchanged. Measured: structure-led descriptions scored 32/36 top-1 against 23/36 noun-led over the same battery. |
| 1.1 | 2026-08-27 | Scored the naming and description conventions. Doc Metadata (10) now splits into fields present and accurate (6), description convention (3), and naming convention (1); previously all 10 graded field presence and naming was an explicit non-scored soft signal. Added the 16-character slug ceiling and the four-slot description shape to Naming a Template. Category set, category maxima, and the 100-point total are unchanged, so ledger rows stay schema-valid — but Doc Metadata subscores are not comparable across 1 and 1.1. Sourced from the template-naming vibe test (internal/vibe-tests/template-naming-test/PLAN.md), which found description the only metadata field whose removal degrades retrieval and the name axis inert. |
| 1 | 2026-08-12 | Established the current seven-category, 100-point template rubric as the tracked baseline. Recovered historical audits remain explicitly unversioned. |
Start here Astryx Philosophy Contributing with AI Assistants Contributing
Architecture System Architecture Architecture Cheat Sheet Theming Infrastructure Distribution
Building a component Component Lifecycle Component Authoring Guide API Conventions Design Conventions
Quality Component Audit Rubric Accessibility Checklist
Operations Release Process Night Watch Overview