33 - Add terminal UI primitives and base layout
This commit is contained in:
parent
d72b672b23
commit
28757d1e6b
6 changed files with 137 additions and 6 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
18
frontend/src/components/layout/AppLayout.tsx
Normal file
18
frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/components/ui/Button.tsx
Normal file
19
frontend/src/components/ui/Button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/ui/DigitalProgressBar.tsx
Normal file
59
frontend/src/components/ui/DigitalProgressBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
frontend/src/components/ui/Panel.tsx
Normal file
13
frontend/src/components/ui/Panel.tsx
Normal 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
13
frontend/src/lib/cn.ts
Normal 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(' ')
|
||||
}
|
||||
Loading…
Reference in a new issue