440 lines
21 KiB
TypeScript
440 lines
21 KiB
TypeScript
/**
|
|
* Greyhaven Design System — Shared Component Catalog & Token Utilities
|
|
*
|
|
* Single source of truth consumed by:
|
|
* - MCP server (mcp/server.ts)
|
|
* - SKILL.md generator (scripts/generate-skill.ts)
|
|
*/
|
|
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Token utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const TOKEN_CATEGORIES = ['color', 'typography', 'spacing', 'radii', 'shadows', 'motion'] as const
|
|
export type TokenCategory = (typeof TOKEN_CATEGORIES)[number]
|
|
|
|
export interface FlatToken {
|
|
path: string
|
|
value: unknown
|
|
type?: string
|
|
description?: string
|
|
}
|
|
|
|
export function loadTokenFile(root: string, name: string): Record<string, unknown> {
|
|
const filePath = path.join(root, 'tokens', `${name}.json`)
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
}
|
|
|
|
export function flattenTokens(
|
|
obj: Record<string, unknown>,
|
|
prefix = '',
|
|
): FlatToken[] {
|
|
const results: FlatToken[] = []
|
|
|
|
for (const [key, val] of Object.entries(obj)) {
|
|
if (key.startsWith('$')) continue
|
|
const currentPath = prefix ? `${prefix}.${key}` : key
|
|
const node = val as Record<string, unknown>
|
|
|
|
if (node && typeof node === 'object' && '$value' in node) {
|
|
results.push({
|
|
path: currentPath,
|
|
value: node.$value,
|
|
type: node.$type as string | undefined,
|
|
description: node.$description as string | undefined,
|
|
})
|
|
} else if (node && typeof node === 'object') {
|
|
results.push(...flattenTokens(node, currentPath))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
export function getTokens(root: string, category?: string): FlatToken[] {
|
|
if (category && TOKEN_CATEGORIES.includes(category as TokenCategory)) {
|
|
const data = loadTokenFile(root, category)
|
|
return flattenTokens(data)
|
|
}
|
|
const all: FlatToken[] = []
|
|
for (const cat of TOKEN_CATEGORIES) {
|
|
try {
|
|
const data = loadTokenFile(root, cat)
|
|
all.push(...flattenTokens(data))
|
|
} catch {
|
|
// skip missing files
|
|
}
|
|
}
|
|
return all
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component catalog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ComponentSpec {
|
|
name: string
|
|
file: string
|
|
category: string
|
|
exports: string[]
|
|
description: string
|
|
props: string
|
|
example: string
|
|
}
|
|
|
|
export const COMPONENT_CATALOG: ComponentSpec[] = [
|
|
// ── Primitives ──────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Button',
|
|
file: 'components/ui/button.tsx',
|
|
category: 'primitives',
|
|
exports: ['Button', 'buttonVariants'],
|
|
description: 'Primary interactive element. 6 variants: default (orange), secondary, outline, ghost, link, destructive. Sizes: default (h-9), sm (h-8), lg (h-10), icon (size-9).',
|
|
props: 'variant?: "default" | "secondary" | "outline" | "ghost" | "link" | "destructive"; size?: "default" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg"; asChild?: boolean',
|
|
example: '<Button variant="default" size="default">Click me</Button>',
|
|
},
|
|
{
|
|
name: 'Badge',
|
|
file: 'components/ui/badge.tsx',
|
|
category: 'primitives',
|
|
exports: ['Badge', 'badgeVariants'],
|
|
description: 'Status indicator / tag. Variants include default, secondary, outline, destructive, success, warning, info, plus channel pills (WhatsApp, Email, Telegram, Zulip).',
|
|
props: 'variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" | ...; asChild?: boolean',
|
|
example: '<Badge variant="success">Active</Badge>',
|
|
},
|
|
{
|
|
name: 'Input',
|
|
file: 'components/ui/input.tsx',
|
|
category: 'primitives',
|
|
exports: ['Input'],
|
|
description: 'Text input field with focus ring, disabled, and aria-invalid states.',
|
|
props: 'All standard HTML input props',
|
|
example: '<Input type="email" placeholder="you@example.com" />',
|
|
},
|
|
{
|
|
name: 'Textarea',
|
|
file: 'components/ui/textarea.tsx',
|
|
category: 'primitives',
|
|
exports: ['Textarea'],
|
|
description: 'Multi-line text input.',
|
|
props: 'All standard HTML textarea props',
|
|
example: '<Textarea placeholder="Write your message..." />',
|
|
},
|
|
{
|
|
name: 'Label',
|
|
file: 'components/ui/label.tsx',
|
|
category: 'primitives',
|
|
exports: ['Label'],
|
|
description: 'Form label using Radix Label primitive.',
|
|
props: 'All standard HTML label props + Radix Label props',
|
|
example: '<Label htmlFor="email">Email</Label>',
|
|
},
|
|
{
|
|
name: 'Checkbox',
|
|
file: 'components/ui/checkbox.tsx',
|
|
category: 'primitives',
|
|
exports: ['Checkbox'],
|
|
description: 'Checkbox using Radix Checkbox primitive.',
|
|
props: 'checked?: boolean; onCheckedChange?: (checked: boolean) => void',
|
|
example: '<Checkbox id="terms" />',
|
|
},
|
|
{
|
|
name: 'Switch',
|
|
file: 'components/ui/switch.tsx',
|
|
category: 'primitives',
|
|
exports: ['Switch'],
|
|
description: 'Toggle switch using Radix Switch primitive.',
|
|
props: 'checked?: boolean; onCheckedChange?: (checked: boolean) => void',
|
|
example: '<Switch id="dark-mode" />',
|
|
},
|
|
{
|
|
name: 'Select',
|
|
file: 'components/ui/select.tsx',
|
|
category: 'primitives',
|
|
exports: ['Select', 'SelectContent', 'SelectGroup', 'SelectItem', 'SelectLabel', 'SelectTrigger', 'SelectValue'],
|
|
description: 'Dropdown select using Radix Select.',
|
|
props: 'value?: string; onValueChange?: (value: string) => void',
|
|
example: '<Select><SelectTrigger><SelectValue placeholder="Choose..." /></SelectTrigger><SelectContent><SelectItem value="a">Option A</SelectItem></SelectContent></Select>',
|
|
},
|
|
{
|
|
name: 'RadioGroup',
|
|
file: 'components/ui/radio-group.tsx',
|
|
category: 'primitives',
|
|
exports: ['RadioGroup', 'RadioGroupItem'],
|
|
description: 'Radio button group using Radix RadioGroup.',
|
|
props: 'value?: string; onValueChange?: (value: string) => void',
|
|
example: '<RadioGroup defaultValue="a"><RadioGroupItem value="a" /><RadioGroupItem value="b" /></RadioGroup>',
|
|
},
|
|
{
|
|
name: 'Toggle',
|
|
file: 'components/ui/toggle.tsx',
|
|
category: 'primitives',
|
|
exports: ['Toggle', 'toggleVariants'],
|
|
description: 'Toggle button. Variants: default, outline.',
|
|
props: 'variant?: "default" | "outline"; size?: "default" | "sm" | "lg"; pressed?: boolean',
|
|
example: '<Toggle aria-label="Bold"><BoldIcon /></Toggle>',
|
|
},
|
|
{
|
|
name: 'Code',
|
|
file: 'components/ui/code.tsx',
|
|
category: 'primitives',
|
|
exports: ['Code', 'codeVariants'],
|
|
description: 'Inline or block code snippet. Always use this instead of hand-rolling <code>/<pre> styling. Uses bg-muted + border-border so the outline stays visible in both light and dark modes. Block variant auto-wraps in <pre> for whitespace preservation and break-all for long commands.',
|
|
props: 'variant?: "inline" | "block"; language?: string (optional, for future syntax highlighting)',
|
|
example: '<p>Install with <Code>pnpm install</Code>.</p>\n\n<Code variant="block" language="bash">{`pnpm install\npnpm dev`}</Code>',
|
|
},
|
|
// ── Layout ──────────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Card',
|
|
file: 'components/ui/card.tsx',
|
|
category: 'layout',
|
|
exports: ['Card', 'CardHeader', 'CardTitle', 'CardDescription', 'CardAction', 'CardContent', 'CardFooter'],
|
|
description: 'Container with header/content/footer slots. Off-white bg, rounded-xl, subtle shadow.',
|
|
props: 'Standard div props. Compose with CardHeader, CardTitle, CardDescription, CardContent, CardFooter sub-components.',
|
|
example: '<Card><CardHeader><CardTitle>Title</CardTitle><CardDescription>Description</CardDescription></CardHeader><CardContent>Content</CardContent></Card>',
|
|
},
|
|
{
|
|
name: 'Accordion',
|
|
file: 'components/ui/accordion.tsx',
|
|
category: 'layout',
|
|
exports: ['Accordion', 'AccordionItem', 'AccordionTrigger', 'AccordionContent'],
|
|
description: 'Expandable sections using Radix Accordion.',
|
|
props: 'type: "single" | "multiple"; collapsible?: boolean',
|
|
example: '<Accordion type="single" collapsible><AccordionItem value="item-1"><AccordionTrigger>Section 1</AccordionTrigger><AccordionContent>Content</AccordionContent></AccordionItem></Accordion>',
|
|
},
|
|
{
|
|
name: 'Tabs',
|
|
file: 'components/ui/tabs.tsx',
|
|
category: 'layout',
|
|
exports: ['Tabs', 'TabsList', 'TabsTrigger', 'TabsContent'],
|
|
description: 'Tab navigation using Radix Tabs. Pill-style triggers.',
|
|
props: 'value?: string; onValueChange?: (value: string) => void',
|
|
example: '<Tabs defaultValue="tab1"><TabsList><TabsTrigger value="tab1">Tab 1</TabsTrigger></TabsList><TabsContent value="tab1">Content</TabsContent></Tabs>',
|
|
},
|
|
{
|
|
name: 'Separator',
|
|
file: 'components/ui/separator.tsx',
|
|
category: 'layout',
|
|
exports: ['Separator'],
|
|
description: 'Visual divider line. Horizontal or vertical.',
|
|
props: 'orientation?: "horizontal" | "vertical"; decorative?: boolean',
|
|
example: '<Separator />',
|
|
},
|
|
// ── Overlay ─────────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Dialog',
|
|
file: 'components/ui/dialog.tsx',
|
|
category: 'overlay',
|
|
exports: ['Dialog', 'DialogTrigger', 'DialogContent', 'DialogHeader', 'DialogTitle', 'DialogDescription', 'DialogFooter', 'DialogClose'],
|
|
description: 'Modal dialog using Radix Dialog.',
|
|
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
|
|
example: '<Dialog><DialogTrigger asChild><Button>Open</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Title</DialogTitle></DialogHeader></DialogContent></Dialog>',
|
|
},
|
|
{
|
|
name: 'AlertDialog',
|
|
file: 'components/ui/alert-dialog.tsx',
|
|
category: 'overlay',
|
|
exports: ['AlertDialog', 'AlertDialogTrigger', 'AlertDialogContent', 'AlertDialogHeader', 'AlertDialogTitle', 'AlertDialogDescription', 'AlertDialogFooter', 'AlertDialogAction', 'AlertDialogCancel'],
|
|
description: 'Confirmation dialog requiring user action.',
|
|
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
|
|
example: '<AlertDialog><AlertDialogTrigger asChild><Button variant="destructive">Delete</Button></AlertDialogTrigger><AlertDialogContent>...</AlertDialogContent></AlertDialog>',
|
|
},
|
|
{
|
|
name: 'Tooltip',
|
|
file: 'components/ui/tooltip.tsx',
|
|
category: 'overlay',
|
|
exports: ['Tooltip', 'TooltipTrigger', 'TooltipContent', 'TooltipProvider'],
|
|
description: 'Tooltip popup (0ms delay) using Radix Tooltip.',
|
|
props: 'Standard Radix Tooltip props',
|
|
example: '<TooltipProvider><Tooltip><TooltipTrigger>Hover me</TooltipTrigger><TooltipContent>Tooltip text</TooltipContent></Tooltip></TooltipProvider>',
|
|
},
|
|
{
|
|
name: 'Popover',
|
|
file: 'components/ui/popover.tsx',
|
|
category: 'overlay',
|
|
exports: ['Popover', 'PopoverTrigger', 'PopoverContent'],
|
|
description: 'Floating content panel using Radix Popover.',
|
|
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
|
|
example: '<Popover><PopoverTrigger asChild><Button>Open</Button></PopoverTrigger><PopoverContent>Content</PopoverContent></Popover>',
|
|
},
|
|
{
|
|
name: 'Drawer',
|
|
file: 'components/ui/drawer.tsx',
|
|
category: 'overlay',
|
|
exports: ['Drawer', 'DrawerTrigger', 'DrawerContent', 'DrawerHeader', 'DrawerTitle', 'DrawerDescription', 'DrawerFooter', 'DrawerClose'],
|
|
description: 'Bottom sheet drawer using Vaul.',
|
|
props: 'open?: boolean; onOpenChange?: (open: boolean) => void',
|
|
example: '<Drawer><DrawerTrigger asChild><Button>Open</Button></DrawerTrigger><DrawerContent><DrawerHeader><DrawerTitle>Title</DrawerTitle></DrawerHeader></DrawerContent></Drawer>',
|
|
},
|
|
// ── Navigation ──────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Navbar',
|
|
file: 'components/ui/navbar.tsx',
|
|
category: 'navigation',
|
|
exports: ['Navbar', 'NavbarLink', 'navbarVariants'],
|
|
description: 'Top navigation bar. Fixed top, z-50, h-[65px]. Off-white bg (light) / off-black (dark). Font-semibold menu items. Hover: opacity-70 (no bg). Active links: orange (text-primary), full opacity. Variants: solid, transparent, minimal. Logo left, nav center, actions right. Mobile hamburger.',
|
|
props: 'variant?: "solid" | "transparent" | "minimal"; logo?: ReactNode; actions?: ReactNode. NavbarLink: active?: boolean',
|
|
example: '<Navbar variant="solid" logo={<Logo size="sm" />}><NavbarLink href="/" active>Home</NavbarLink><NavbarLink href="/about">About</NavbarLink></Navbar>',
|
|
},
|
|
{
|
|
name: 'Breadcrumb',
|
|
file: 'components/ui/breadcrumb.tsx',
|
|
category: 'navigation',
|
|
exports: ['Breadcrumb', 'BreadcrumbList', 'BreadcrumbItem', 'BreadcrumbLink', 'BreadcrumbPage', 'BreadcrumbSeparator', 'BreadcrumbEllipsis'],
|
|
description: 'Breadcrumb navigation trail.',
|
|
props: 'Standard list composition',
|
|
example: '<Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbLink href="/">Home</BreadcrumbLink></BreadcrumbItem><BreadcrumbSeparator /><BreadcrumbItem><BreadcrumbPage>Current</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>',
|
|
},
|
|
{
|
|
name: 'Pagination',
|
|
file: 'components/ui/pagination.tsx',
|
|
category: 'navigation',
|
|
exports: ['Pagination', 'PaginationContent', 'PaginationItem', 'PaginationLink', 'PaginationPrevious', 'PaginationNext', 'PaginationEllipsis'],
|
|
description: 'Page navigation controls.',
|
|
props: 'Standard list composition with PaginationLink items',
|
|
example: '<Pagination><PaginationContent><PaginationItem><PaginationPrevious href="#" /></PaginationItem><PaginationItem><PaginationLink href="#">1</PaginationLink></PaginationItem><PaginationItem><PaginationNext href="#" /></PaginationItem></PaginationContent></Pagination>',
|
|
},
|
|
// ── Data Display ────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Table',
|
|
file: 'components/ui/table.tsx',
|
|
category: 'data',
|
|
exports: ['Table', 'TableHeader', 'TableBody', 'TableRow', 'TableHead', 'TableCell', 'TableCaption', 'TableFooter'],
|
|
description: 'Data table with header, body, footer.',
|
|
props: 'Standard HTML table element composition',
|
|
example: '<Table><TableHeader><TableRow><TableHead>Name</TableHead></TableRow></TableHeader><TableBody><TableRow><TableCell>John</TableCell></TableRow></TableBody></Table>',
|
|
},
|
|
{
|
|
name: 'Progress',
|
|
file: 'components/ui/progress.tsx',
|
|
category: 'data',
|
|
exports: ['Progress'],
|
|
description: 'Progress bar using Radix Progress.',
|
|
props: 'value?: number (0-100)',
|
|
example: '<Progress value={60} />',
|
|
},
|
|
{
|
|
name: 'Avatar',
|
|
file: 'components/ui/avatar.tsx',
|
|
category: 'data',
|
|
exports: ['Avatar', 'AvatarImage', 'AvatarFallback'],
|
|
description: 'User avatar with image and fallback.',
|
|
props: 'Standard Radix Avatar composition',
|
|
example: '<Avatar><AvatarImage src="/avatar.jpg" /><AvatarFallback>JD</AvatarFallback></Avatar>',
|
|
},
|
|
{
|
|
name: 'Calendar',
|
|
file: 'components/ui/calendar.tsx',
|
|
category: 'data',
|
|
exports: ['Calendar'],
|
|
description: 'Date picker calendar using react-day-picker.',
|
|
props: 'mode?: "single" | "range" | "multiple"; selected?: Date; onSelect?: (date: Date) => void',
|
|
example: '<Calendar mode="single" selected={date} onSelect={setDate} />',
|
|
},
|
|
// ── Feedback ────────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Alert',
|
|
file: 'components/ui/alert.tsx',
|
|
category: 'feedback',
|
|
exports: ['Alert', 'AlertTitle', 'AlertDescription'],
|
|
description: 'Inline alert message. Variants: default, destructive.',
|
|
props: 'variant?: "default" | "destructive"',
|
|
example: '<Alert><AlertTitle>Heads up!</AlertTitle><AlertDescription>This is an alert.</AlertDescription></Alert>',
|
|
},
|
|
{
|
|
name: 'Skeleton',
|
|
file: 'components/ui/skeleton.tsx',
|
|
category: 'feedback',
|
|
exports: ['Skeleton'],
|
|
description: 'Loading placeholder with pulse animation.',
|
|
props: 'Standard div props (set dimensions with className)',
|
|
example: '<Skeleton className="h-4 w-[250px]" />',
|
|
},
|
|
{
|
|
name: 'Spinner',
|
|
file: 'components/ui/spinner.tsx',
|
|
category: 'feedback',
|
|
exports: ['Spinner'],
|
|
description: 'Loading spinner (Loader2Icon with spin animation).',
|
|
props: 'Standard SVG icon props',
|
|
example: '<Spinner />',
|
|
},
|
|
{
|
|
name: 'Empty',
|
|
file: 'components/ui/empty.tsx',
|
|
category: 'feedback',
|
|
exports: ['Empty'],
|
|
description: 'Empty state placeholder with header/media/title/description.',
|
|
props: 'Standard composition with sub-components',
|
|
example: '<Empty><EmptyTitle>No results</EmptyTitle><EmptyDescription>Try a different search</EmptyDescription></Empty>',
|
|
},
|
|
// ── Form ────────────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Form',
|
|
file: 'components/ui/form.tsx',
|
|
category: 'form',
|
|
exports: ['Form', 'FormField', 'FormItem', 'FormLabel', 'FormControl', 'FormDescription', 'FormMessage'],
|
|
description: 'Form wrapper using react-hook-form. Provides field-level validation and error display via Zod.',
|
|
props: 'Wraps react-hook-form useForm return value. FormField takes name + render prop.',
|
|
example: '<Form {...form}><FormField name="email" render={({field}) => (<FormItem><FormLabel>Email</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>)} /></Form>',
|
|
},
|
|
// ── Composition ─────────────────────────────────────────────────────────
|
|
{
|
|
name: 'Logo',
|
|
file: 'components/ui/logo.tsx',
|
|
category: 'composition',
|
|
exports: ['Logo', 'logoVariants'],
|
|
description: 'Greyhaven logo SVG. Size: sm/md/lg/xl. Variant: color (orange icon + foreground text) or monochrome (all foreground).',
|
|
props: 'size?: "sm" | "md" | "lg" | "xl"; variant?: "color" | "monochrome"',
|
|
example: '<Logo size="md" variant="color" />',
|
|
},
|
|
{
|
|
name: 'Hero',
|
|
file: 'components/ui/hero.tsx',
|
|
category: 'composition',
|
|
exports: ['Hero', 'heroVariants'],
|
|
description: 'Full-width hero section. Variants: centered, left-aligned, split (text + media). Heading in Source Serif, subheading in sans.',
|
|
props: 'variant?: "centered" | "left-aligned" | "split"; background?: "default" | "muted" | "accent" | "dark"; heading: ReactNode; subheading?: ReactNode; actions?: ReactNode; media?: ReactNode',
|
|
example: '<Hero variant="centered" heading="Build something great" subheading="With the Greyhaven Design System" actions={<Button>Get Started</Button>} />',
|
|
},
|
|
{
|
|
name: 'CTASection',
|
|
file: 'components/ui/cta-section.tsx',
|
|
category: 'composition',
|
|
exports: ['CTASection', 'ctaSectionVariants'],
|
|
description: 'Call-to-action section block. Centered or left-aligned, with heading, description, and action buttons.',
|
|
props: 'variant?: "centered" | "left-aligned"; background?: "default" | "muted" | "accent" | "subtle"; heading: ReactNode; description?: ReactNode; actions?: ReactNode',
|
|
example: '<CTASection heading="Ready to start?" description="Join thousands of developers" actions={<Button>Sign up free</Button>} />',
|
|
},
|
|
{
|
|
name: 'Section',
|
|
file: 'components/ui/section.tsx',
|
|
category: 'composition',
|
|
exports: ['Section', 'sectionVariants'],
|
|
description: 'Titled content section with spacing. py-10 internal padding. Colored variants (highlighted, accent) get my-8 vertical margin so they visually detach from adjacent sections; default has no margin so same-bg siblings flow seamlessly.',
|
|
props: 'variant?: "default" | "highlighted" | "accent"; width?: "narrow" | "default" | "wide" | "full"; title?: string; description?: string',
|
|
example: '<Section title="Features" description="What we offer" width="wide">Content</Section>',
|
|
},
|
|
{
|
|
name: 'Footer',
|
|
file: 'components/ui/footer.tsx',
|
|
category: 'composition',
|
|
exports: ['Footer', 'footerVariants'],
|
|
description: 'Page footer. Minimal (single row) or full (multi-column with link groups).',
|
|
props: 'variant?: "minimal" | "full"; logo?: ReactNode; copyright?: ReactNode; linkGroups?: FooterLinkGroup[]; actions?: ReactNode',
|
|
example: '<Footer variant="minimal" copyright="© 2024 Greyhaven" />',
|
|
},
|
|
{
|
|
name: 'PageLayout',
|
|
file: 'components/ui/page-layout.tsx',
|
|
category: 'composition',
|
|
exports: ['PageLayout'],
|
|
description: 'Full page shell composing Navbar + main content + optional sidebar + Footer. Auto-offsets for fixed navbar.',
|
|
props: 'navbar?: ReactNode; sidebar?: ReactNode; footer?: ReactNode',
|
|
example: '<PageLayout navbar={<Navbar />} footer={<Footer />}>Main content</PageLayout>',
|
|
},
|
|
]
|