Entity Card System
Unified schema-driven entity cards with custom overrides, container-query responsiveness, and agent-writable card configuration.
Problem
Entity cards are rendered with generic, identical layouts everywhere -- grid view, kanban, chat results, entity-card blocks. The old compatibility alias at features/entities/components/registry.ts was unused and has since been removed; the canonical registry lives under features/entities/components/entity-card/. A custom OpportunityCard component exists but is never registered or rendered. The grid/kanban/mobile views have inline card rendering with no customization hooks.
Entity types already declare config.ui.cardFields in seed data, but nothing reads this config for card rendering. Every entity type looks the same regardless of its schema, and there is no path for agents to customize card appearance without code deployment.
Solution
Build a unified EntityCard component with a three-tier resolution chain: code-registered custom components, DB-stored declarative cardConfig, and smart schema-derived defaults. All three tiers render into the same layout zones and use CSS container queries for responsive behavior. One component replaces all inline card rendering across grid, kanban, chat results, block system, and mobile fallback views.
Design
Architecture
The resolution chain determines which renderer handles each entity type:
resolveEntityCard(typeSlug, entityType) ->
1. Code registry hit? -> CustomCardComponent
2. entityType.config.ui.cardConfig exists? -> ConfigDrivenCard
3. fallback -> SchemaCard (smart default)All three tiers produce the same EntityCardRenderProps contract and render inside the same container-query responsive shell. The EntityCard component is the single public API -- consumers never interact with inner components directly.
Component hierarchy:
EntityCard (resolution + shell + container queries)
CustomCardComponent (code-defined, registered per type slug)
ConfigDrivenCard (renders from cardConfig JSON)
SchemaCard (auto-classifies fields from json_schema)Card Layout Zones
All tiers render into a consistent zone structure:
- Header: Avatar, title, type label, badges, score
- Hero: Large prominent metric (currency values, key numbers)
- Body: Description text, truncated via line-clamp based on container size
- Metrics: Small stats row (numbers, dates, tag counts)
- Secondary: Additional fields, visible only at expanded size
Container query breakpoints:
- Compact (below 280px): header only (title + badges + score) -- kanban columns
- Standard (280-479px): header + hero + body (2 lines) + metrics -- grid cells
- Expanded (480px+): all zones including secondary fields -- detail previews
Container queries are CSS-only (SSR-safe). No JS-based size detection.
CardConfig -- Agent-Writable Configuration
Stored in entity_types.config.ui.cardConfig. Agents write this via the updateEntityTypeSchema admin tool.
interface CardConfig {
hero?: string; // Field name for large metric
badges?: string[]; // Field names for header badges
description?: string; // Field name for body text
metrics?: string[]; // Field names for stat row
accent?: {
field: string;
map: Record<string, string>; // value -> color token
};
hide?: string[]; // Fields to exclude from card
}Accent color values are restricted to an allowlist of Tailwind color tokens to prevent CSS injection. The hide array takes precedence over the legacy cardFields array.
Field Classification (Smart Defaults)
When no cardConfig exists, SchemaCard auto-classifies fields from json_schema.properties:
| Pattern | Zone | Examples |
|---|---|---|
Field with enum in schema | badges | status, category, priority |
Named description, summary, notes, overview | description | -- |
Number with currency-ish name (value, revenue, cost, price, amount) | hero | estimated_annual_value |
| Other number fields | metrics | score, progress_pct |
| Boolean fields | badges (as icon) | is_active, approved |
| Array fields | badges (tag row) | tags, categories |
| Date fields | metrics | date, deadline |
| Everything else | secondary (hidden on compact) | -- |
If config.ui.cardFields is set, those fields get priority placement regardless of heuristics. Classification results are memoized per entity type (keyed by type slug + schema hash), not per entity.
API
No new API endpoints. The card system is purely client-side rendering.
Key exported functions:
registerEntityCard(typeSlug, component)-- register a custom card rendererresolveEntityCard(typeSlug, entityType)-- resolve which component to renderclassifyFields(schema, content, config)-- classify schema fields into layout zones
Integration Points
The EntityCard component replaces inline card rendering in:
EntityGridView-- replace inline cards with<EntityCard variant="standard" />EntityKanbanView-- replace inline cards with<EntityCard variant="compact" />DataTablemobile fallback -- replace simple card listentity-card-block-- delegate toEntityCardwhen full entity data is available, keep lightweight fallback for chat tool results with partial datafrom-chat.tsbridge -- enhance to pass full entity/entityType when available
Custom Component Registration
Custom cards register in features/custom/components/entity-cards.ts, imported once at app startup:
import { registerEntityCard } from "@/features/entities/components/entity-card/registry";
import { OpportunityCard } from "./opportunity/opportunity-card";
registerEntityCard("opportunity", OpportunityCard);The existing OpportunityCard gets refactored to accept the EntityCardRenderProps contract.
Trade-offs
Three-tier resolution vs. config-only: A config-only approach would be simpler but cannot handle genuinely custom rendering logic (charts inside cards, complex conditional layouts). The three-tier chain keeps config as the 90% path while allowing code overrides for the 10%.
Container queries vs. variant prop: Container queries make the card truly context-independent -- it adapts to whatever space it is placed in. A variant prop would require every consumer to know the right variant. Container queries are well-supported in modern browsers and are SSR-safe.
Flat CardConfig vs. nested layout tree: A flat config with named zones is easy for agents to generate in one tool call. A nested layout tree would be more powerful but harder to validate and more error-prone for AI authoring.
No DB migration: cardConfig is stored in the existing config JSONB column on entity_types. This avoids a migration but means the config is not independently queryable. This is acceptable because card config is always read alongside the entity type.
Acceptance Criteria
- Single
EntityCardcomponent renders everywhere: grid, kanban, chat, blocks, mobile - Schema-driven default card shows meaningful field layout without any configuration
-
cardConfigin entity type config controls card layout zones - Custom card components register and resolve correctly via registry
- Container queries adapt card layout to available width without prop changes
- Existing
OpportunityCardworks through the new registry -
classifyFieldscorrectly categorizes all JSON schema field types - Memoization prevents re-classification per entity in a list
- Unit tests for
classifyFieldscovering all field type heuristics - Unit tests for registry resolution chain
- Visual verification in grid, kanban, chat, and block contexts
- No regressions in existing entity type rendering
Files
New
features/entities/components/entity-card/types.ts-- EntityCardProps, EntityCardRenderProps, CardConfig, ClassifiedFields, CardConfigSchema (Zod)features/entities/components/entity-card/registry.ts-- registerEntityCard, resolveEntityCardfeatures/entities/components/entity-card/classify-fields.ts-- field classification heuristicsfeatures/entities/components/entity-card/classify-fields.test.ts-- unit tests for classificationfeatures/entities/components/entity-card/schema-card.tsx-- smart default card from schemafeatures/entities/components/entity-card/config-driven-card.tsx-- renders from CardConfig JSONfeatures/entities/components/entity-card/entity-card.tsx-- public API component (resolution + shell + container queries)features/entities/components/entity-card/entity-card.test.ts-- registry and resolution testsfeatures/entities/components/entity-card/index.ts-- barrel exportfeatures/custom/components/entity-cards.ts-- custom card registration barrel
Modified
features/entities/components/entity-grid-view.tsx-- replace inline cards with EntityCardfeatures/entities/components/entity-kanban-view.tsx-- replace inline cards with EntityCardfeatures/entities/components/data-table/data-table.tsx-- replace mobile card fallback with EntityCardfeatures/blocks/components/entity-card-block.tsx-- delegate to EntityCard when full entity data availablefeatures/custom/components/opportunity/opportunity-card.tsx-- refactor to EntityCardRenderProps contractfeatures/entities/types.ts-- add CardConfig to EntityTypeConfig.uifeatures/entities/components/entity-card/-- canonical registry/types location after the old compatibility alias was removed