app: shell + scificn-ui frontend (SCI-FI theme, brightened --text-muted)

scificn-ui v0.1.0 integrated copy-paste per its docs: Button/Panel/Badge/Card/Alert
in components/ui/, cn() helper in lib/utils.ts, theme tokens in app/globals.css.
Deliberate deviation from upstream: --text-muted #4A4A40 -> #6B6B5C (+12pt lightness)
for readability on the near-black background (default SCI-FI theme only). Landing page
proves the integration; no game UI yet.
This commit is contained in:
2026-09-28 15:56:24 +00:00
parent f6d2876a44
commit e688e9a0f0
10 changed files with 955 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap');
@import "tailwindcss";
/* ============================================================
TAILWIND V4 THEME — tokens that generate utility classes
============================================================ */
@theme {
/* Original palette — retained */
--color-pink: #B53082;
--color-purple: #381B57;
--color-orange: #F2963A;
--color-teal: #6DC3BB;
--color-beige: #C8B99A;
--color-navy: #0E1628;
--color-void: #07050F;
/* New palette */
--color-green: #00ed3f;
--color-amber: #ff8800;
--color-amber-light: #ffa238;
--color-red: #cc2200;
--color-blue: #4466cc;
--color-bone: #E0D5BE;
/* Neutral dark backgrounds */
--color-bg: #050505;
--color-surface: #0D0D0D;
--color-surface-raised: #141414;
/* Typography */
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'Courier New', monospace;
/* Zero radius */
--radius: 0px;
}
/* ============================================================
SEMANTIC TOKENS — contextual roles mapped to palette
============================================================ */
:root {
/* Backgrounds — neutral dark, no color tint */
--background: #050505;
--surface: #0D0D0D;
--surface-raised: #141414;
/* Borders */
--border: #252525;
--border-active: #00ed3f;
/* Text */
--text-primary: #00ed3f;
--text-secondary: #E0D5BE;
/* SpaceWanderer tweak (v0.1.0): upstream default is #4A4A40 (HSL 60,7%,27%) — too dim for
secondary text on the near-black background. Brightened to HSL 60,8%,39% (+12pt lightness,
same warm-gray hue) so muted labels stay readable while remaining clearly muted. */
--text-muted: #6B6B5C;
--text-warning: #ff8800;
--text-danger: #cc2200;
/* Accent shortcuts */
--accent-primary: #00ed3f;
--accent-secondary: #4466cc;
--accent-warning: #ff8800;
/* Glow — original (kept for backward compat) */
--glow-teal: 0 0 8px #6DC3BB66, 0 0 20px #6DC3BB33;
--glow-pink: 0 0 8px #B5308266, 0 0 20px #B5308233;
--glow-orange: 0 0 8px #F2963A66, 0 0 20px #F2963A33;
/* Glow — new palette */
--glow-green: 0 0 8px #00ed3f66, 0 0 20px #00ed3f33;
--glow-amber: 0 0 8px #ff880066, 0 0 20px #ff880033;
--glow-red: 0 0 8px #cc220066, 0 0 20px #cc220033;
--glow-blue: 0 0 8px #4466cc66, 0 0 20px #4466cc33;
/* Text glow — original (kept) */
--text-glow-teal: 0 0 6px #6DC3BB99, 0 0 14px #6DC3BB55;
--text-glow-orange: 0 0 6px #F2963A99, 0 0 14px #F2963A55;
--text-glow-pink: 0 0 6px #B5308299, 0 0 14px #B5308255;
/* Text glow — new palette */
--text-glow-green: 0 0 6px #00ed3f99, 0 0 14px #00ed3f55;
--text-glow-amber: 0 0 6px #ff880099, 0 0 14px #ff880055;
--text-glow-red: 0 0 6px #cc220099, 0 0 14px #cc220055;
--text-glow-blue: 0 0 6px #4466cc99, 0 0 14px #4466cc55;
/* Corner notch clip-paths */
--clip-corner-sm: polygon(6px 0%, 100% 0%, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0% 100%, 0% 6px);
--clip-corner-md: polygon(10px 0%, 100% 0%, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0% 100%, 0% 10px);
--clip-corner-lg: polygon(16px 0%, 100% 0%, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0% 100%, 0% 16px);
/* Geometry */
--border-width: 1px;
}
/* ============================================================
KEYFRAME ANIMATIONS
============================================================ */
@keyframes flicker {
0%, 89%, 91%, 93%, 100% { opacity: 1; }
90% { opacity: 0.4; }
92% { opacity: 0.8; }
94% { opacity: 0.6; }
}
@keyframes blink-cursor {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes glitch {
0%, 100% { transform: translate(0); }
10% { transform: translate(-2px, 1px); }
20% { transform: translate(2px, -1px); }
30% { transform: translate(-1px, 2px); }
40% { transform: translate(1px, -1px); }
50% { transform: translate(0); }
}
@keyframes scanline-move {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes data-scroll {
0% { transform: translateY(0); }
100% { transform: translateY(-50%); }
}
/* ============================================================
BASE RESET
============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
border-radius: 0 !important;
}
html {
font-size: 14px;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
padding: 0;
background-color: var(--background);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 1rem;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
button,
input,
textarea,
select {
font-family: var(--font-mono);
font-size: inherit;
border-radius: 0;
}
a {
color: var(--text-primary);
text-decoration: none;
}
a:hover {
text-shadow: var(--text-glow-green);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin: 0;
}
p {
margin: 0;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-active);
box-shadow: var(--glow-green);
}
/* Text selection */
::selection {
background: #00ed3f;
color: #050505;
}
/* ============================================================
UTILITY CLASSES
============================================================ */
/* CRT scanline overlay — apply to panels/containers */
.scanlines {
position: relative;
overflow: hidden;
}
.scanlines::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 3px,
rgba(0, 0, 0, 0.12) 3px,
rgba(0, 0, 0, 0.12) 4px
);
pointer-events: none;
z-index: 10;
}
/* Phosphor flicker text */
.flicker {
animation: flicker 8s infinite;
}
/* Glitch on hover */
.glitch-hover:hover {
animation: glitch 0.3s ease-in-out;
}
/* Pulsing glow */
.pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Corner notch variants */
.clip-corner-sm {
clip-path: var(--clip-corner-sm);
}
.clip-corner-md {
clip-path: var(--clip-corner-md);
}
.clip-corner-lg {
clip-path: var(--clip-corner-lg);
}
/* ============================================================
THEME: STAR WARS
Holographic blue primary · bone/gold warnings · teal info
Deep blue-black backgrounds
============================================================ */
[data-theme="star-wars"] {
/* Remap color primitives used by components */
--color-green: #1a6dff; /* blue hologram = "active" */
--color-amber: #E0D5BE; /* bone/gold = "warning" */
--color-red: #cc0000; /* Sith red = "danger" */
--color-blue: #6DC3BB; /* teal = "info" */
/* Backgrounds — deep blue-black */
--background: #020509;
--surface: #060E18;
--surface-raised: #0C1826;
/* Borders */
--border: #152030;
--border-active: #1a6dff;
/* Text */
--text-primary: #1a6dff;
--text-secondary: #E0D5BE;
--text-muted: #2A3A52;
--text-warning: #E0D5BE;
--text-danger: #cc0000;
/* Accent shortcuts */
--accent-primary: #1a6dff;
--accent-secondary: #6DC3BB;
--accent-warning: #E0D5BE;
/* Glow — remap green→blue, amber→bone, blue→teal */
--glow-green: 0 0 8px rgba(26,109,255,0.40), 0 0 20px rgba(26,109,255,0.20);
--glow-amber: 0 0 8px rgba(224,213,190,0.35), 0 0 20px rgba(224,213,190,0.15);
--glow-red: 0 0 8px rgba(204,0,0,0.40), 0 0 20px rgba(204,0,0,0.20);
--glow-blue: 0 0 8px rgba(109,195,187,0.40), 0 0 20px rgba(109,195,187,0.20);
/* Text glow — remap */
--text-glow-green: 0 0 6px rgba(26,109,255,0.60), 0 0 14px rgba(26,109,255,0.30);
--text-glow-amber: 0 0 6px rgba(224,213,190,0.55), 0 0 14px rgba(224,213,190,0.25);
--text-glow-red: 0 0 6px rgba(204,0,0,0.60), 0 0 14px rgba(204,0,0,0.30);
--text-glow-blue: 0 0 6px rgba(109,195,187,0.60), 0 0 14px rgba(109,195,187,0.30);
}
[data-theme="star-wars"] ::selection {
background: #1a6dff;
color: #020509;
}
/* ============================================================
THEME: ALIEN
Bone/ivory primary · amber warnings · acid green info
Warm organic dark backgrounds
============================================================ */
[data-theme="alien"] {
/* Remap color primitives used by components */
--color-green: #E0D5BE; /* bone = "active" (bleached, eerie) */
--color-amber: #ff8800; /* amber = "warning" (Nostromo lights) */
--color-red: #cc2200; /* blood red = "danger" */
--color-blue: #00ed3f; /* acid green = "info" (bioluminescent) */
/* Backgrounds — warm organic dark */
--background: #040302;
--surface: #080604;
--surface-raised: #0E0A06;
/* Borders */
--border: #201808;
--border-active: #E0D5BE;
/* Text */
--text-primary: #E0D5BE;
--text-secondary: #C8B99A;
--text-muted: #403020;
--text-warning: #ff8800;
--text-danger: #cc2200;
/* Accent shortcuts */
--accent-primary: #E0D5BE;
--accent-secondary: #00ed3f;
--accent-warning: #ff8800;
/* Glow — remap green→bone, blue→acid-green */
--glow-green: 0 0 8px rgba(224,213,190,0.35), 0 0 20px rgba(224,213,190,0.15);
--glow-amber: 0 0 8px rgba(255,136,0,0.40), 0 0 20px rgba(255,136,0,0.20);
--glow-red: 0 0 8px rgba(204,34,0,0.40), 0 0 20px rgba(204,34,0,0.20);
--glow-blue: 0 0 8px rgba(0,237,63,0.40), 0 0 20px rgba(0,237,63,0.20);
/* Text glow — remap */
--text-glow-green: 0 0 6px rgba(224,213,190,0.55), 0 0 14px rgba(224,213,190,0.25);
--text-glow-amber: 0 0 6px rgba(255,136,0,0.60), 0 0 14px rgba(255,136,0,0.30);
--text-glow-red: 0 0 6px rgba(204,34,0,0.60), 0 0 14px rgba(204,34,0,0.30);
--text-glow-blue: 0 0 6px rgba(0,237,63,0.60), 0 0 14px rgba(0,237,63,0.30);
}
[data-theme="alien"] ::selection {
background: #E0D5BE;
color: #040302;
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#050505"/>
<circle cx="32" cy="32" r="14" fill="none" stroke="#00ed3f" stroke-width="2.5"/>
<ellipse cx="32" cy="32" rx="26" ry="8" fill="none" stroke="#E0D5BE" stroke-width="2" transform="rotate(-18 32 32)"/>
<circle cx="49" cy="22" r="2.5" fill="#ff8800"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'SpaceWanderer',
description: 'Turn-based space adventure — build & expand your ship, survive the void.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Panel, PanelContent, PanelFooter, PanelHeader } from '@/components/ui/panel';
import pkg from '@/package.json';
export default function Home() {
return (
<main className="min-h-screen flex items-center justify-center p-6">
<Panel className="w-full max-w-xl" notch="md">
<PanelHeader>
<Badge variant="ACTIVE">SYS:ONLINE</Badge>
<span
style={{
marginLeft: 'auto',
fontSize: '0.65rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
}}
>
v{pkg.version} — STACK PROOF
</span>
</PanelHeader>
<PanelContent>
<h1
style={{
fontSize: '1.6rem',
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--color-green)',
textShadow: 'var(--text-glow-green)',
}}
>
SpaceWanderer
</h1>
<p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
Turn-based space adventure — build &amp; expand your ship from found parts, survive the void.
</p>
<Card className="mt-4">
<CardHeader>
<CardTitle>Ship status</CardTitle>
</CardHeader>
<CardContent>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--text-muted)' }}>HULL</span>
<Badge variant="ACTIVE">NOMINAL</Badge>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--text-muted)' }}>CREW</span>
<span style={{ color: 'var(--text-secondary)' }}>—</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--text-muted)' }}>NEXT ENCOUNTER</span>
<Badge variant="SCANNING">SCANNING...</Badge>
</div>
</div>
</CardContent>
</Card>
<Alert variant="STATUS" className="mt-4">
<AlertTitle>Scaffold online</AlertTitle>
<AlertDescription>
Base stack + scificn-ui frontend integrated. No game systems yet — single player only, by design.
</AlertDescription>
</Alert>
</PanelContent>
<PanelFooter>
<Button variant="EXEC" size="SM">INITIATE</Button>
<span style={{ marginLeft: 'auto', fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
SCIFICN/UI v0.1.0 · SCI-FI THEME
</span>
</PanelFooter>
</Panel>
</main>
);
}
+119
View File
@@ -0,0 +1,119 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
[
'relative w-full',
'border border-solid border-l-[3px]',
'p-4',
'font-mono',
'rounded-none',
],
{
variants: {
variant: {
STATUS: [
'border-[var(--border)]',
'border-l-[var(--color-green)]',
'bg-[var(--surface)]',
],
WARNING: [
'border-[var(--border)]',
'border-l-[var(--color-amber)]',
'bg-[var(--surface)]',
],
CRITICAL: [
'border-[var(--border)]',
'border-l-[var(--color-red)]',
'bg-[var(--surface)]',
],
INFO: [
'border-[var(--border)]',
'border-l-[var(--color-blue)]',
'bg-[var(--surface)]',
],
},
},
defaultVariants: { variant: 'STATUS' },
}
)
const prefixMap = {
STATUS: { symbol: '◈', color: 'var(--color-green)', glow: 'var(--text-glow-green)' },
WARNING: { symbol: '⚠', color: 'var(--color-amber)', glow: 'var(--text-glow-amber)' },
CRITICAL: { symbol: '✕', color: 'var(--color-red)', glow: 'var(--text-glow-red)' },
INFO: { symbol: 'ℹ', color: 'var(--color-blue)', glow: 'var(--text-glow-blue)' },
}
export interface AlertProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {}
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant = 'STATUS', children, ...props }, ref) => {
const v = variant ?? 'STATUS'
const prefix = prefixMap[v]
return (
<div ref={ref} className={cn(alertVariants({ variant }), className)} role="alert" {...props}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
<span
style={{
color: prefix.color,
textShadow: prefix.glow,
fontSize: '0.85rem',
lineHeight: 1.5,
flexShrink: 0,
}}
>
{prefix.symbol}
</span>
<div style={{ flex: 1 }}>{children}</div>
</div>
</div>
)
}
)
Alert.displayName = 'Alert'
/* ── AlertTitle ── */
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, style, ...props }, ref) => (
<h5
ref={ref}
className={cn(className)}
style={{
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--text-secondary)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginBottom: '0.25rem',
...style,
}}
{...props}
/>
)
)
AlertTitle.displayName = 'AlertTitle'
/* ── AlertDescription ── */
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, style, ...props }, ref) => (
<p
ref={ref}
className={cn(className)}
style={{
fontSize: '0.8rem',
color: 'var(--text-muted)',
lineHeight: 1.6,
...style,
}}
{...props}
/>
)
)
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription, alertVariants }
+83
View File
@@ -0,0 +1,83 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
[
'inline-flex items-center gap-1.5',
'px-2 py-0.5',
'text-[0.65rem] font-mono font-medium uppercase tracking-widest',
'border border-solid',
'rounded-none',
'whitespace-nowrap',
],
{
variants: {
variant: {
ACTIVE: [
'border-[var(--color-green)]',
'text-[var(--color-green)]',
],
OFFLINE: [
'border-[var(--border)]',
'text-[var(--text-muted)]',
],
WARNING: [
'border-[var(--color-amber)]',
'text-[var(--color-amber)]',
],
CRITICAL: [
'border-[var(--color-red)]',
'text-[var(--color-red)]',
],
SCANNING: [
'border-[var(--color-green)]',
'text-[var(--color-green)]',
],
},
},
defaultVariants: {
variant: 'ACTIVE',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, children, ...props }: BadgeProps) {
const isScanning = variant === 'SCANNING'
return (
<span
className={cn(badgeVariants({ variant }), className)}
style={
variant === 'ACTIVE'
? { textShadow: 'var(--text-glow-green)', boxShadow: 'inset 0 0 8px #00ed3f11' }
: variant === 'CRITICAL'
? { textShadow: 'var(--text-glow-red)', boxShadow: 'inset 0 0 8px #cc220011' }
: undefined
}
{...props}
>
{isScanning && (
<span
style={{
display: 'inline-block',
animation: 'blink-cursor 1s step-end infinite',
}}
>
●
</span>
)}
{!isScanning && variant === 'ACTIVE' && <span>●</span>}
{!isScanning && variant === 'OFFLINE' && <span>○</span>}
{!isScanning && variant === 'WARNING' && <span>⚠</span>}
{!isScanning && variant === 'CRITICAL' && <span>✕</span>}
{children}
</span>
)
}
export { Badge, badgeVariants }
+91
View File
@@ -0,0 +1,91 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
[
'inline-flex items-center justify-center',
'font-mono font-medium uppercase tracking-widest',
'border border-solid',
'cursor-pointer select-none',
'transition-all duration-150',
'focus-visible:outline-none',
'disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none',
'rounded-none',
'whitespace-nowrap',
],
{
variants: {
variant: {
EXEC: [
'bg-transparent',
'border-[var(--color-green)]',
'text-[var(--color-green)]',
'hover:bg-[var(--color-green)]',
'hover:text-[var(--color-bg)]',
'hover:shadow-[var(--glow-green)]',
'focus-visible:shadow-[var(--glow-green)]',
],
GHOST: [
'bg-transparent',
'border-transparent',
'text-[var(--text-secondary)]',
'hover:text-[var(--color-green)]',
'hover:border-[var(--border)]',
'focus-visible:border-[var(--border)]',
],
OUTLINE: [
'bg-[var(--surface)]',
'border-[var(--border)]',
'text-[var(--text-secondary)]',
'hover:border-[var(--border-active)]',
'hover:text-[var(--color-green)]',
'hover:shadow-[var(--glow-green)]',
'focus-visible:border-[var(--border-active)]',
'focus-visible:shadow-[var(--glow-green)]',
],
ABORT: [
'bg-transparent',
'border-[var(--color-red)]',
'text-[var(--color-red)]',
'hover:bg-[var(--color-red)]',
'hover:text-[var(--color-bg)]',
'hover:shadow-[var(--glow-red)]',
'focus-visible:shadow-[var(--glow-red)]',
],
},
size: {
SM: 'h-7 px-3 text-[0.65rem] gap-1.5',
MD: 'h-9 px-4 text-[0.75rem] gap-2',
LG: 'h-11 px-6 text-[0.875rem] gap-2.5',
},
},
defaultVariants: {
variant: 'OUTLINE',
size: 'MD',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+73
View File
@@ -0,0 +1,73 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-[var(--surface)] border border-[var(--border)]',
'text-[var(--text-secondary)] font-mono',
className
)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col gap-1.5 p-4 border-b border-[var(--border)]', className)}
{...props}
/>
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-sm font-semibold uppercase tracking-widest text-[var(--color-green)]',
className
)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-xs text-[var(--text-muted)]', className)}
{...props}
/>
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-4', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-4 border-t border-[var(--border)]', className)}
{...props}
/>
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
+112
View File
@@ -0,0 +1,112 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/* ── Panel (outer container) ── */
export interface PanelProps extends React.HTMLAttributes<HTMLDivElement> {
notch?: 'sm' | 'md' | 'lg' | 'none'
}
const Panel = React.forwardRef<HTMLDivElement, PanelProps>(
({ className, notch = 'md', style, ...props }, ref) => {
const clipMap = {
sm: 'var(--clip-corner-sm)',
md: 'var(--clip-corner-md)',
lg: 'var(--clip-corner-lg)',
none: 'none',
}
return (
<div
ref={ref}
className={cn('rounded-none', className)}
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
clipPath: clipMap[notch],
overflow: 'hidden',
...style,
}}
{...props}
/>
)
}
)
Panel.displayName = 'Panel'
/* ── PanelHeader ── */
const PanelHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, style, ...props }, ref) => (
<div
ref={ref}
className={cn(className)}
style={{
display: 'flex',
alignItems: 'center',
padding: '0.6rem 1rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
gap: '0.5rem',
...style,
}}
{...props}
/>
)
)
PanelHeader.displayName = 'PanelHeader'
/* ── PanelTitle ── */
const PanelTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, style, ...props }, ref) => (
<h3
ref={ref}
className={cn(className)}
style={{
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
margin: 0,
...style,
}}
{...props}
/>
)
)
PanelTitle.displayName = 'PanelTitle'
/* ── PanelContent ── */
const PanelContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, style, ...props }, ref) => (
<div
ref={ref}
className={cn(className)}
style={{ padding: '1rem', ...style }}
{...props}
/>
)
)
PanelContent.displayName = 'PanelContent'
/* ── PanelFooter ── */
const PanelFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, style, ...props }, ref) => (
<div
ref={ref}
className={cn(className)}
style={{
display: 'flex',
alignItems: 'center',
padding: '0.6rem 1rem',
borderTop: '1px solid var(--border)',
background: 'var(--surface-raised)',
gap: '0.5rem',
...style,
}}
{...props}
/>
)
)
PanelFooter.displayName = 'PanelFooter'
export { Panel, PanelHeader, PanelTitle, PanelContent, PanelFooter }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}