Design Component skill for Empathy Ledger. Use for storyteller cards, story cards, profile displays, and any UI component requiring cultural sensitivity. Provides data mapping, AI enrichment patterns, and design system guidelines.
This skill provides comprehensive guidance for designing and implementing UI components in Empathy Ledger, with special focus on storyteller cards, data display patterns, and AI-powered content enrichment.
/* Use semantic colors for dark mode support */
--background /* Page background */
--foreground /* Primary text */
--card /* Card surfaces */
--card-foreground/* Card text */
--muted /* Muted backgrounds */
--muted-foreground /* Secondary text */
--popover /* Dropdown/popover backgrounds */
--border /* Borders */
--primary /* Primary actions */
--accent /* Accent/highlight (sunshine yellow) */
--destructive /* Errors/warnings */
| Color | Meaning | Usage |
|---|
| Amber/Gold | Elder wisdom, featured | Elder badges, featured indicators |
| Emerald | Growth, community | Story counts, active status |
| Purple | Sacred, knowledge | Knowledge keeper badges |
| Terracotta | Earth, connection | Cultural affiliations |
| Sage | Calm, respectful | General UI elements |
interface StorytellerCardData {
// Identity (Always Show)
id: string
display_name: string
avatar_url?: string
pronouns?: string
// Cultural Context (Show When Available)
cultural_background?: string
cultural_affiliations?: string[]
traditional_territory?: string
languages_spoken?: string[]
// Status Indicators
is_elder: boolean
is_featured: boolean
status: 'active' | 'inactive' | 'pending'
traditional_knowledge_keeper?: boolean
// Story Metrics
story_count: number
featured_quote?: string
expertise_themes?: string[]
// Professional Context
occupation?: string
years_of_experience?: number
specialties?: string[]
// AI-Enriched Fields
ai_summary?: string
theme_expertise?: string[]
connection_strength?: number
suggested_connections?: string[]
}
TIER 1 - Always Display:
├── display_name
├── avatar (or initials fallback)
├── cultural_background
└── story_count
TIER 2 - Show on Card:
├── elder_status badge
├── featured badge
├── top 3 specialties
├── primary location
└── featured_quote (if available)
TIER 3 - Show on Hover/Expand:
├── full bio
├── all specialties
├── languages
├── organisations
└── theme expertise
TIER 4 - Profile Page Only:
├── contact info
├── full story list
├── connection graph
└── detailed analytics
<StorytellerCard
storyteller={storyteller}
variant="default"
showStories={true}
showActions={true}
/>
<StorytellerCard
storyteller={storyteller}
variant="compact"
/>
<StorytellerCard
storyteller={storyteller}
variant="featured"
/>
<StorytellerListCard storyteller={storyteller} />
// API: POST /api/storytellers/{id}/enhance-bio
interface BioEnhancement {
original_bio: string
enhanced_bio: string // Grammar, flow improvements
key_themes: string[] // Extracted from bio
suggested_specialties: string[]
cultural_keywords: string[]
}
// From storyteller's stories, extract compelling quotes
interface QuoteExtraction {
quotes: Array<{
text: string
story_id: string
themes: string[]
impact_score: number
}>
suggested_featured: string // Best quote for card
}
// Analyze all stories to determine expertise areas
interface ThemeExpertise {
primary_themes: string[] // Top 3 most discussed
secondary_themes: string[] // Supporting themes
unique_perspective: string // What makes them unique
theme_depth_scores: Record<string, number>
}
// Find storytellers with complementary themes
interface ConnectionSuggestion {
storyteller_id: string
connection_type: 'theme_overlap' | 'geographic' | 'community'
overlap_score: number
reason: string // "Both share expertise in healing stories"
}
// Generate concise storyteller summary
interface StoritellerSummary {
one_liner: string // "Elder from Wurundjeri Country..."
card_summary: string // 50-100 chars for cards
full_summary: string // 200-300 chars for profiles
voice_style: string // "Warm and reflective storytelling"
}
<div className="relative">
<Avatar
src={storyteller.avatar_url}
fallback={getInitials(storyteller.display_name)}
className="w-16 h-16 border-2 border-background shadow-md"
/>
{/* Status Badges - Position top-right */}
<div className="absolute -top-1 -right-1 flex gap-1">
{storyteller.is_featured && (
<Badge variant="featured" size="icon">
<Star className="w-3 h-3" />
</Badge>
)}
{storyteller.is_elder && (
<Badge variant="elder" size="icon">
<Crown className="w-3 h-3" />
</Badge>
)}
</div>
</div>
{/* Respectful cultural display */}
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="w-4 h-4 text-terracotta-500" />
<span className="text-sm">
{storyteller.cultural_background}
{storyteller.traditional_territory && (
<span className="text-xs ml-1">
({storyteller.traditional_territory})
</span>
)}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<BookOpen className="w-4 h-4 text-emerald-500" />
<span className="font-semibold">{storyteller.story_count}</span>
<span className="text-muted-foreground text-sm">
{storyteller.story_count === 1 ? 'Story' : 'Stories'}
</span>
</div>
{storyteller.years_of_experience && (
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4 text-blue-500" />
<span className="font-semibold">{storyteller.years_of_experience}</span>
<span className="text-muted-foreground text-sm">Years</span>
</div>
)}
</div>
{/* Show max 3 on card, rest on hover/detail */}
<div className="flex flex-wrap gap-1.5">
{storyteller.specialties?.slice(0, 3).map((specialty, i) => (
<Badge
key={specialty}
variant="secondary"
className={cn(
"text-xs",
specialtyColors[i % specialtyColors.length]
)}
>
{specialty}
</Badge>
))}
{(storyteller.specialties?.length || 0) > 3 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
+{storyteller.specialties!.length - 3} more
</Badge>
)}
</div>
{storyteller.featured_quote && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg border-l-2 border-accent">
<Quote className="w-4 h-4 text-accent mb-1" />
<p className="text-sm italic text-foreground/80 line-clamp-2">
"{storyteller.featured_quote}"
</p>
</div>
)}
// POST /api/storytellers/{id}/enrich
async function enrichStoryteller(storytellerId: string) {
const response = await fetch(`/api/storytellers/${storytellerId}/enrich`, {
method: 'POST',
body: JSON.stringify({
enrich_bio: true,
extract_quotes: true,
analyze_themes: true,
suggest_connections: true
})
})
// Returns enrichment job ID for polling
return response.json()
}
// Check for AI-enriched fields
const hasAIData = storyteller.ai_summary || storyteller.theme_expertise?.length
{hasAIData && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-accent" />
<span>AI Enhanced</span>
</div>
)}
-- AI-generated summary
ALTER TABLE profiles ADD COLUMN ai_summary TEXT;
-- Extracted theme expertise (from stories analysis)
ALTER TABLE profiles ADD COLUMN theme_expertise TEXT[];
-- AI-generated featured quote selection
ALTER TABLE profiles ADD COLUMN featured_quote TEXT;
ALTER TABLE profiles ADD COLUMN featured_quote_story_id UUID;
-- Connection strength scores
ALTER TABLE profiles ADD COLUMN connection_scores JSONB DEFAULT '{}';
-- Enrichment status
ALTER TABLE profiles ADD COLUMN ai_enrichment_status TEXT DEFAULT 'pending';
ALTER TABLE profiles ADD COLUMN ai_enriched_at TIMESTAMPTZ;
CREATE VIEW storyteller_connections AS
SELECT
p1.id as storyteller_id,
p2.id as connected_storyteller_id,
array_length(
ARRAY(SELECT unnest(p1.theme_expertise) INTERSECT SELECT unnest(p2.theme_expertise)),
1
) as theme_overlap,
p1.cultural_background = p2.cultural_background as same_culture
FROM profiles p1
CROSS JOIN profiles p2
WHERE p1.id != p2.id
AND p1.is_storyteller = true
AND p2.is_storyteller = true;
| Component | Location | Purpose |
|---|---|---|
StorytellerCard | src/components/storyteller/storyteller-card.tsx | Main card component |
UnifiedStorytellerCard | src/components/storyteller/unified-storyteller-card.tsx | Flexible card variants |
ElegantStorytellerCard | src/components/storyteller/elegant-storyteller-card.tsx | Premium design variant |
StoryCard | src/components/story/story-card.tsx | Story display card |
QuoteCard | src/components/ui/quote-card.tsx | Quote display |
ThemeBadge | src/components/ui/theme-badge.tsx | Theme display |
Purpose: Visual indicator of syndication consent status
Variants:
type ConsentStatus = 'approved' | 'pending' | 'revoked' | 'expired'
const statusConfig = {
approved: { color: 'sage', icon: CheckCircle, label: 'Active' },
pending: { color: 'amber', icon: Clock, label: 'Pending' },
revoked: { color: 'ember', icon: XCircle, label: 'Revoked' },
expired: { color: 'muted', icon: AlertCircle, label: 'Expired' }
}
Design:
<Badge className={cn(
"flex items-center gap-1.5",
status === 'approved' && "bg-sage-100 text-sage-900",
status === 'pending' && "bg-amber-100 text-amber-900",
status === 'revoked' && "bg-ember-100 text-ember-900",
status === 'expired' && "bg-muted text-muted-foreground"
)}>
<Icon className="h-3 w-3" />
{label}
</Badge>
Purpose: Display individual syndication consent with site info and controls
Structure:
┌─────────────────────────────────────────────┐
│ [Site Logo] JusticeHub │
│ justicehub.org.au │
│ │
│ Status: [Active Badge] │
│ Cultural Level: Public │
│ Created: Jan 5, 2026 │
│ │
│ 📊 456 views • Last accessed 2 hours ago │
│ │
│ [View Analytics] [Revoke Access] │
└─────────────────────────────────────────────┘
Colors:
Purpose: Show token with security-conscious masking
Pattern:
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted rounded font-mono text-sm">
{tokenMasked ? 'LRK••••••••••XA' : token}
</code>
<Button variant="ghost" size="sm" onClick={() => setTokenMasked(!tokenMasked)}>
{tokenMasked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" onClick={handleCopy}>
<Copy className="h-4 w-4" />
</Button>
</div>
Security:
Purpose: Consistent chart design across dashboards
Colors (Recharts):
const siteColors = {
justicehub: '#C85A54', // Ember
actfarm: '#6B8E72', // Sage
theharvest: '#D97757', // Clay
actplacemat: '#4A90A4' // Sky
}
// Usage
<Line
type="monotone"
dataKey="justicehub"
stroke={siteColors.justicehub}
strokeWidth={2}
dot={{ fill: siteColors.justicehub, r: 4 }}
activeDot={{ r: 6 }}
/>
Grid & Axes:
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
/>
Purpose: Confirm consent revocation with cultural messaging
Layout:
<Dialog>
<DialogHeader>
<DialogTitle className="text-ember-900">
Revoke Consent for JusticeHub?
</DialogTitle>
<DialogDescription>
This will immediately remove JusticeHub's access to your story.
They will no longer be able to display it on their platform.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-sky-50 p-4 border border-sky-200">
<p className="text-sm text-sky-900 font-medium">
✨ You maintain full control
</p>
<p className="text-sm text-sky-700 mt-1">
Your story remains on Empathy Ledger. You can grant consent
again at any time.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="reason">Reason for revoking (optional)</Label>
<Textarea
id="reason"
placeholder="E.g., Story needs updating, not ready for external sharing..."
rows={3}
/>
<p className="text-xs text-muted-foreground">
This helps us improve the platform (shared anonymously with
JusticeHub if consent was active).
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Keep Consent
</Button>
<Button variant="destructive" onClick={handleRevoke}>
Revoke Access
</Button>
</DialogFooter>
</Dialog>
Messaging Principles:
Purpose: Show content sensitivity level with cultural context
Levels:
const culturalLevels = {
public: {
color: 'sage',
icon: Globe,
label: 'Public',
description: 'Safe to share widely'
},
community: {
color: 'clay',
icon: Users,
label: 'Community',
description: 'Indigenous communities only'
},
restricted: {
color: 'amber',
icon: Lock,
label: 'Restricted',
description: 'Requires elder approval'
},
sacred: {
color: 'ember',
icon: ShieldAlert,
label: 'Sacred',
description: 'Not for external sharing'
}
}
Component:
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-{color}-700" />
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
No Consents Yet:
<div className="text-center py-12 space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-sage-100 flex items-center justify-center">
<Share2 className="h-8 w-8 text-sage-700" />
</div>
<div>
<h3 className="text-lg font-semibold">No syndication consents yet</h3>
<p className="text-muted-foreground mt-1">
Your stories are safe with you. When you're ready to share with
external platforms like JusticeHub, you'll see them here.
</p>
</div>
<Button onClick={handleCreateConsent}>
<Plus className="mr-2 h-4 w-4" />
Share a Story
</Button>
</div>
All Consents Revoked:
<div className="text-center py-12 space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-sky-100 flex items-center justify-center">
<ShieldCheck className="h-8 w-8 text-sky-700" />
</div>
<div>
<h3 className="text-lg font-semibold">You're in control</h3>
<p className="text-muted-foreground mt-1">
All your stories have been removed from external platforms.
You can re-share whenever you're ready.
</p>
</div>
</div>
Invoke when: