You've read /CLAUDE.md (root). This file adds FRONTEND-SPECIFIC patterns.
What you learned from root:
- 6-dimension ontology (groups, people, things, connections, events, knowledge)
- 6-phase workflow (UNDERSTAND → MAP → DESIGN → IMPLEMENT → BUILD → TEST)
- Cascading context system (closer to file = higher precedence)
- Technology stack (Astro 5, React 19, Tailwind v4, shadcn/ui)
What this file adds:
- Frontend RENDERS the 6 dimensions
- Progressive complexity (5 layers)
- Component patterns (ThingCard, PersonCard, EventItem)
- Astro islands + React hooks
- Performance optimization
Backend implements, Frontend renders:
Backend (Convex) Frontend (You Build)
──────────────── ────────────────────
groups table → <GroupSelector>, <GroupHierarchy>
things table → <ThingCard type="product|course|...">
connections → <ConnectionList>, <RelationshipGraph>
events → <ActivityFeed>, <EventTimeline>
knowledge → <SearchResults>, <Recommendations>
people (role) → <PersonCard>, <RoleBadge>
Key principle: Ontology never changes. Components do.
Before building ANY page or component, search for existing templates:
# Product/shop pages
/web/src/pages/shop/product-landing.astro # Full e-commerce template with Stripe
# Search for similar pages
glob "web/src/pages/**/*.astro"
# Search for reusable components
glob "web/src/components/**/*.tsx"- User asks for feature → Identify type (product page, dashboard, etc.)
- Search templates first → Use Glob to find similar patterns
- Copy template → Use Read to examine, then copy structure
- Customize → Modify for specific use case
- Enhance → Add Stripe, features, interactivity
When to use:
- User wants to sell a product
- User needs a landing page for merchandise
- User mentions "shop", "buy", "sell", "product"
Features included:
- Product gallery with zoom
- Reviews section
- Urgency banners
- Stripe checkout integration
- Mobile-optimized
- Dark mode support
Setup:
- Copy template to new page
- Update product data (title, price, images)
- Ask: "Would you like this as your home page?"
- After creation: "Add Stripe? Just paste your keys: https://one.ie/docs/develop/stripe"
E-commerce:
- Product landing pages →
/web/src/pages/shop/product-landing.astro - Product galleries → Search for ThingCard implementations
- Shopping cart → Search for cart stores in
/web/src/stores/
Content:
- Blog posts →
/web/src/content/blog/ - Documentation →
/web/src/content/docs/ - Landing pages →
/web/src/pages/index.astro
Dashboards:
- Admin interfaces → Search for "dashboard" in pages
- Analytics → Search for chart components
- User profiles → Search for PersonCard implementations
Golden Rule: Copy existing patterns first. Build new ONLY when no template exists.
Read full architecture: /one/knowledge/astro-effect-simple-architecture.md
Layer 1: Content + Pages (80% of features - START HERE)
---
// Static content from content collections
import { getCollection } from "astro:content";
const products = await getCollection("products");
---
<Layout>
<div class="grid gap-4">
{products.map(product => (
<ThingCard thing={product.data} type="product" />
))}
</div>
</Layout>Layer 2: + Validation (15% - Add when needed)
// Effect.ts services for business logic
import { Effect } from "effect";
export const validateProduct = (
data: unknown
): Effect.Effect<Product, ProductError> =>
Effect.gen(function* () {
if (!data.name) {
return yield* Effect.fail({
_tag: "ValidationError",
message: "Product name required",
});
}
return data as Product;
});Layer 3: + State (4% - Add for island communication)
// Nanostores for island communication
import { atom } from "nanostores";
export const cart$ = atom<CartItem[]>([]);
// Use in ANY island
import { useStore } from "@nanostores/react";
const cart = useStore(cart$);Layer 4: + Multiple Sources (1% - Add for provider switching)
// Provider pattern enables source switching
const provider = getContentProvider("products");
const products = await provider.list();
// Switch sources with env var:
// CONTENT_SOURCE=markdown → Uses .md files
// CONTENT_SOURCE=api → Uses REST API
// CONTENT_SOURCE=hybrid → Tries API, falls back to MarkdownLayer 5: + Backend (<1% - Only when explicitly requested)
// Backend provides real-time data via Convex
import { useQuery } from "convex/react";
const products = useQuery(api.queries.products.list);Golden Rule: Start Layer 1. Add layers ONLY when pain is felt.
Read full guide: /one/knowledge/provider-agnostic-content.md
ONE component per dimension, MANY templates for common use cases:
Start with templates, not components:
# User says: "I want to sell coffee mugs"
# 1. Search templates first
glob "web/src/pages/**/*product*.astro" # Find product templates
glob "web/src/pages/**/*shop*.astro" # Find shop templates
# 2. Found: /web/src/pages/shop/product-landing.astro
# 3. Copy and customize for coffee mugs
# 4. Ask about Stripe integrationTemplate priority over raw components:
- ✅ Copy
/shop/product-landing.astro→ customize for your product - ❌ Build page from scratch using ThingCard
Why templates win:
- Complete page structure (header, gallery, reviews, footer)
- Stripe integration pre-configured
- Mobile-responsive layouts
- Dark mode support
- SEO optimization
- Performance best practices
// Generic thing renderer (use for ALL thing types)
export function ThingCard({
thing,
type
}: {
thing: Thing;
type: string
}) {
// Type-specific rendering via properties
const price = thing.properties.price;
const inventory = thing.properties.inventory;
return (
<Card>
<CardHeader>
<CardTitle>{thing.name}</CardTitle>
<Badge variant="outline">{type}</Badge>
</CardHeader>
<CardContent>
{price && <div className="text-lg font-bold">${price}</div>}
{inventory !== undefined && (
<div className="text-sm text-muted-foreground">
{inventory} in stock
</div>
)}
</CardContent>
</Card>
);
}
// Use for ANY thing type
<ThingCard thing={product} type="product" />
<ThingCard thing={course} type="course" />
<ThingCard thing={token} type="token" />// Person renderer (for all user types)
export function PersonCard({ person }: { person: Person }) {
return (
<Card>
<CardHeader>
<Avatar>
<AvatarImage src={person.avatarUrl} />
<AvatarFallback>{person.name[0]}</AvatarFallback>
</Avatar>
<CardTitle>{person.displayName}</CardTitle>
</CardHeader>
<CardContent>
<RoleBadge role={person.role} />
</CardContent>
</Card>
);
}// Activity feed renderer
export function EventItem({ event }: { event: Event }) {
return (
<div className="flex items-start gap-3">
<EventIcon type={event.type} />
<div>
<div className="font-medium">{formatEventType(event.type)}</div>
<div className="text-sm text-muted-foreground">
{formatDistance(event.timestamp, Date.now())} ago
</div>
</div>
</div>
);
}Why this works: AI sees 3 patterns (not 100), confidence = 98%.
Anti-pattern: ProductCard, CourseCard, UserProfile, ActivityItem (4+ patterns, confidence = 30%)
Read full patterns: /one/knowledge/patterns/frontend/component-template.md
Performance principle: Static HTML by default. Add interactivity strategically.
<!-- Static HTML (NO JavaScript) -->
<ProductCard product={product} />
<!-- Critical interactivity (loads immediately) -->
<ShoppingCart client:load />
<!-- Deferred interactivity (loads when browser idle) -->
<SearchBox client:idle />
<!-- Lazy loading (loads when visible) -->
<RelatedProducts client:visible />
<!-- Responsive features (loads on mobile) -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- Framework-specific (no SSR) -->
<ComplexWidget client:only="react" />Problem: Astro islands are isolated React trees. They can't share state via props.
Solution: Nanostores provide global state accessible from ANY island.
// stores/cart.ts (ONE file, ONE pattern)
import { atom } from "nanostores";
export const cart$ = atom<CartItem[]>([]);
// Island 1: Header.tsx
import { useStore } from "@nanostores/react";
import { cart$ } from "@/stores/cart";
export function Header() {
const cart = useStore(cart$);
return <Badge>{cart.length}</Badge>;
}
// Island 2: ProductCard.tsx
import { useStore } from "@nanostores/react";
import { cart$ } from "@/stores/cart";
export function ProductCard({ product }) {
const cart = useStore(cart$);
const addToCart = () => {
cart$.set([...cart, product]);
};
return <Button onClick={addToCart}>Add to Cart</Button>;
}
// Island 3: CartSidebar.tsx
import { useStore } from "@nanostores/react";
import { cart$ } from "@/stores/cart";
export function CartSidebar() {
const cart = useStore(cart$);
return (
<div>
{cart.map(item => <CartItem item={item} />)}
</div>
);
}Pattern: ONE way to share state across islands. NO localStorage hacks. NO URL params. NO window events.
Read full guide: /one/knowledge/astro-effect-simple-architecture.md#layer-3
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function ProductList({ groupId }: { groupId: string }) {
// Automatically updates when data changes
const products = useQuery(api.queries.entities.list, {
groupId,
type: "product",
status: "published",
});
if (products === undefined) {
return <Skeleton />;
}
return (
<div className="grid gap-4">
{products.map((product) => (
<ThingCard key={product._id} thing={product} type="product" />
))}
</div>
);
}import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
export function ProductCard({ product }: { product: Thing }) {
const updateProduct = useMutation(api.mutations.entities.update);
const [isUpdating, setIsUpdating] = useState(false);
const handleUpdate = async () => {
setIsUpdating(true);
try {
await updateProduct({
id: product._id,
name: "New Name",
});
toast.success("Product updated!");
} catch (error) {
toast.error("Update failed");
} finally {
setIsUpdating(false);
}
};
return (
<Button onClick={handleUpdate} disabled={isUpdating}>
Update Product
</Button>
);
}Power: Frontend code never changes when backend switches.
// Development: markdown files
// .env: CONTENT_SOURCE=markdown
const provider = getContentProvider("products");
// Production: Convex real-time
// .env: CONTENT_SOURCE=convex
const provider = getContentProvider("products");
// SAME CODE. DIFFERENT SOURCE.Implementation:
// lib/providers/ContentProvider.ts
export interface ContentProvider {
list(): Promise<Thing[]>;
get(id: string): Promise<Thing>;
create(data: Partial<Thing>): Promise<Thing>;
update(id: string, data: Partial<Thing>): Promise<Thing>;
delete(id: string): Promise<void>;
}
// lib/providers/getContentProvider.ts
export function getContentProvider(collection: string): ContentProvider {
const source = import.meta.env.CONTENT_SOURCE || "markdown";
switch (source) {
case "convex":
return new ConvexProvider(collection);
case "api":
return new ApiProvider(collection);
case "hybrid":
return new HybridProvider(
new ApiProvider(collection),
new MarkdownProvider(collection)
);
default:
return new MarkdownProvider(collection);
}
}Read full pattern: /one/knowledge/provider-agnostic-content.md
Always use shadcn components for UI:
// Available components (50+ pre-installed)
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
// Example usage
export function ProductCard({ product }: { product: Thing }) {
return (
<Card>
<CardHeader>
<CardTitle>{product.name}</CardTitle>
<Badge variant="outline">{product.properties.category}</Badge>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{product.properties.description}
</p>
<Separator className="my-4" />
<div className="text-2xl font-bold">
${product.properties.price}
</div>
</CardContent>
<CardFooter>
<Button className="w-full">Add to Cart</Button>
</CardFooter>
</Card>
);
}Read component list: https://ui.shadcn.com/docs/components
CRITICAL: Uses CSS-based configuration (NO tailwind.config.mjs).
/* src/styles/global.css */
@import "tailwindcss";
@theme {
/* Colors in HSL format (NO OKLCH!) */
--color-background: 0 0% 100%;
--color-foreground: 222.2 84% 4.9%;
--color-primary: 222.2 47.4% 11.2%;
--color-secondary: 210 40% 96.1%;
--color-muted: 210 40% 96.1%;
--color-accent: 210 40% 96.1%;
--color-destructive: 0 84.2% 60.2%;
}
/* Dark mode overrides */
@variant dark (.dark &);
.dark {
--color-background: 222.2 84% 4.9%;
--color-foreground: 210 40% 98%;
--color-primary: 210 40% 98%;
}Key rules:
- ALWAYS use HSL format:
--color-name: 0 0% 100% - ALWAYS wrap with
hsl():hsl(var(--color-background)) - NO
@applydirective in Tailwind v4 - Use
@variant dark (.dark &)for dark mode
Usage in components:
// ALWAYS wrap colors with hsl()
<div className="bg-background text-foreground">
<h1 className="text-primary">Title</h1>
<p className="text-muted-foreground">Description</p>
</div>Read full guidelines: /one/knowledge/guidelines.md#tailwind-v4
Core Web Vitals Requirements:
- LCP: < 2.5s
- FID: < 100ms
- CLS: < 0.1
- Lighthouse: 90+
Techniques:
- Image Optimization:
import { Image } from "astro:assets";
<Image
src={thumbnail}
alt="Product thumbnail"
width={400}
height={300}
format="webp"
quality={85}
loading="lazy"
/>- Code Splitting:
// Dynamic imports for heavy components
const ProductBuilder = lazy(() => import('./ProductBuilder'));
{role === 'org_owner' && (
<Suspense fallback={<Skeleton />}>
<ProductBuilder />
</Suspense>
)}- Strategic Hydration:
<!-- Above fold: client:load -->
<ShoppingCart client:load />
<!-- Below fold: client:visible -->
<RelatedProducts client:visible />Read full optimization guide: /one/knowledge/performance.md
// Permission-aware navigation
export function Navigation({
role,
permissions
}: {
role: Role;
permissions: string[]
}) {
return (
<nav className="flex items-center gap-4">
{/* All roles see dashboard */}
<NavLink href="/dashboard">Dashboard</NavLink>
{/* Org owners and platform owners see admin */}
{(role === 'org_owner' || role === 'platform_owner') && (
<NavLink href="/admin">Admin</NavLink>
)}
{/* Platform owners see all groups */}
{role === 'platform_owner' && (
<NavLink href="/platform/groups">All Groups</NavLink>
)}
{/* Customers see marketplace */}
{role === 'customer' && (
<NavLink href="/marketplace">Marketplace</NavLink>
)}
</nav>
);
}Before coding, answer:
- Which dimension? (groups/people/things/connections/events/knowledge)
- Which thing type? (product, course, token, agent, etc.)
- Which connection type? (owns, purchased, enrolled_in, holds_tokens)
- Which event type? (created, updated, purchased, completed)
- Can this be static HTML? → Astro component (no JS)
- Needs interactivity? → Client island (
client:load|idle|visible) - Real-time data? → Convex
useQuery - Heavy component? → Dynamic import + code splitting
- Static content? → Astro component
- Simple interactivity? → React component + hooks
- Complex state? → React + nanostores
- Form handling? → React + Effect.ts validation
Read full decision tree: /one/connections/workflow.md#phase-5
Ontology violations:
- ❌ Creating custom tables
- ✅ Map to 6 dimensions
Performance anti-patterns:
- ❌
client:loadeverywhere - ✅ Use appropriate directive (idle, visible)
Pattern divergence:
- ❌ ProductCard, CourseCard, UserCard (many patterns)
- ✅ ThingCard, PersonCard (ONE pattern)
Read full list: /one/knowledge/rules.md#common-mistakes
cd web/
bun run dev # Development server (localhost:4321)
bun run build # Build for production
bunx astro check # Type checking
bunx astro sync # Generate content collection types
bun test # Run tests
bun test --watch # Watch modeRead full commands: /one/knowledge/development-commands.md
For more specific context:
- Component patterns:
/web/src/components/CLAUDE.md - Page patterns:
/web/src/pages/CLAUDE.md - Service layer:
/web/src/lib/services/CLAUDE.md(if exists)
Precedence rule: Closer to file = higher precedence.
Frontend Specialist: Render the 6-dimension ontology with performance and pattern convergence.