33 - Add terminal UI primitives and base layout

This commit is contained in:
myrmidex 2026-06-28 02:22:07 +02:00
parent d72b672b23
commit 28757d1e6b
6 changed files with 137 additions and 6 deletions

View file

@ -1,11 +1,20 @@
import AppLayout from './components/layout/AppLayout'
import Button from './components/ui/Button'
import DigitalProgressBar from './components/ui/DigitalProgressBar'
import Panel from './components/ui/Panel'
function App() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 font-mono">
<h1 className="text-primary text-4xl font-bold uppercase tracking-widest">
BUCKETS
</h1>
<p className="font-digital text-primary text-2xl">1234567890</p>
</div>
<AppLayout>
<Panel className="mx-auto max-w-md space-y-4">
<h2 className="text-primary font-bold uppercase tracking-wider">
Emergency Fund
</h2>
<DigitalProgressBar current={750} capacity={1000} />
<p className="font-digital text-primary text-xl">$750 / $1000</p>
<Button glow>+ Add Bucket</Button>
</Panel>
</AppLayout>
)
}

View file

@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
interface AppLayoutProps {
children: ReactNode
}
export default function AppLayout({ children }: AppLayoutProps) {
return (
<div className="bg-background text-foreground min-h-screen font-mono">
<header className="glow-red border-b-4 border-red-500 px-6 py-4">
<h1 className="text-primary text-2xl font-bold uppercase tracking-widest">
BUCKETS
</h1>
</header>
<main className="p-6">{children}</main>
</div>
)
}

View file

@ -0,0 +1,19 @@
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/cn'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
glow?: boolean
}
export default function Button({ glow, className, ...props }: ButtonProps) {
return (
<button
className={cn(
'border-2 border-red-500 bg-black px-4 py-2 font-mono font-bold uppercase tracking-wider text-red-500 transition-colors hover:bg-red-500 hover:text-black',
glow && 'glow-red',
className,
)}
{...props}
/>
)
}

View file

@ -0,0 +1,59 @@
interface DigitalProgressBarProps {
current: number
capacity: number
}
const BAR_COUNT = 20
// Zone colors are driven by the theme's chart tokens (chart-1 red → chart-2
// amber → chart-3 green) so they stay in sync with the palette; the glow is
// derived from the same token via color-mix rather than a duplicated literal.
function getZoneToken(index: number): string {
if (index < 10) return 'var(--chart-1)'
if (index < 18) return 'var(--chart-2)'
return 'var(--chart-3)'
}
function getBarColor(index: number): string {
if (index < 10) return 'bg-chart-1'
if (index < 18) return 'bg-chart-2'
return 'bg-chart-3'
}
export default function DigitalProgressBar({
current,
capacity,
}: DigitalProgressBarProps) {
const filledBars =
capacity > 0
? Math.min(Math.round((current / capacity) * BAR_COUNT), BAR_COUNT)
: 0
return (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${BAR_COUNT}, 1fr)`,
gap: '10px',
height: '2rem',
}}
>
{Array.from({ length: BAR_COUNT }, (_, i) => {
const filled = i < filledBars
return (
<div
key={i}
style={{
borderRadius: '3px',
transition: 'background-color 300ms, box-shadow 300ms',
boxShadow: filled
? `0 0 8px color-mix(in oklch, ${getZoneToken(i)} 60%, transparent)`
: 'none',
}}
className={filled ? getBarColor(i) : 'bg-chart-1/10'}
/>
)
})}
</div>
)
}

View file

@ -0,0 +1,13 @@
import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/cn'
type PanelProps = HTMLAttributes<HTMLDivElement>
export default function Panel({ className, ...props }: PanelProps) {
return (
<div
className={cn('glow-red border-4 border-red-500 bg-black p-6', className)}
{...props}
/>
)
}

13
frontend/src/lib/cn.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Minimal className joiner filters out falsy values and joins with spaces.
* Avoids pulling clsx / tailwind-merge for the small primitive set here.
*
* Note: this does NOT resolve conflicting Tailwind utilities. If a consumer
* passes a class that conflicts with a default (e.g. bg-blue-500 over bg-black),
* both land in the DOM and CSS source order decides the winner not "last wins".
*/
export function cn(
...classes: Array<string | false | null | undefined>
): string {
return classes.filter(Boolean).join(' ')
}