Compare commits

...
Author SHA1 Message Date
Hanzo AI 51a660fa3f feat(hanzogui): add cloud-console primitives (PageHeader, DataTable, Field, StatusTag, States, Loader)
Additive umbrella views under src/views/cloud — brand-agnostic, generic
building blocks back-ported from hanzoai/console2 so every Hanzo Tamagui app
shares one DataTable/PageHeader/Field/StatusTag/empty-state/loader.

- imports adapted to the @hanzogui/* sibling convention (Text from ../Text)
- States decoupled from any HTTP client (structural { status, message })
- Loader is brandless (neutral Spinner) — brand marks stay in the app
- adds @hanzogui/lucide-icons-2 workspace dep (ChevronDown, TriangleAlert)
2026-06-28 19:25:52 -07:00
9 changed files with 469 additions and 0 deletions
+1
View File
@@ -99,6 +99,7 @@
"@hanzogui/label": "workspace:*",
"@hanzogui/linear-gradient": "workspace:*",
"@hanzogui/list-item": "workspace:*",
"@hanzogui/lucide-icons-2": "workspace:*",
"@hanzogui/menu": "workspace:*",
"@hanzogui/polyfill-dev": "workspace:*",
"@hanzogui/popover": "workspace:*",
+1
View File
@@ -60,6 +60,7 @@ export * from './views/GuiProvider'
export * from './views/Anchor'
export * from './views/EnsureFlexed'
export * from './views/Fieldset'
export * from './views/cloud'
export * from '@hanzogui/input'
export * from '@hanzogui/spinner'
export * from './views/Text'
@@ -0,0 +1,100 @@
/**
* DataTable — the shared list primitive for cloud-console modules.
*
* Rows are typed `<T>`; columns declare a key, header, optional fixed width, and
* an optional cell renderer. Built on the Gui stack/text/spinner primitives
* (shorthand style props) so it adapts across platforms. Loading and empty
* states are first-class.
*/
import type { ReactNode } from 'react'
import { Spinner } from '@hanzogui/spinner'
import { XStack, YStack } from '@hanzogui/stacks'
import { Text } from '../Text'
export type Column<T> = {
key: string
header: string
/** Cell renderer; defaults to `String(row[key])`. */
render?: (row: T) => ReactNode
/** Fixed width (px) for the column; otherwise flexes. */
width?: number
}
export function DataTable<T>({
columns,
rows,
loading,
empty = 'Nothing here yet.',
rowKey,
onRowPress,
}: {
columns: Column<T>[]
rows: T[]
loading?: boolean
empty?: string
rowKey: (row: T) => string
onRowPress?: (row: T) => void
}) {
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (rows.length === 0) {
return (
<YStack p="$6" items="center" borderWidth={1} borderColor="$borderColor" rounded="$4">
<Text color="$color11">{empty}</Text>
</YStack>
)
}
return (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
<XStack bg="$color2" py="$2.5" px="$3" gap="$3">
{columns.map((c) => (
<Text
key={c.key}
width={c.width}
flex={c.width ? undefined : 1}
fontSize="$2"
fontWeight="700"
color="$color11"
>
{c.header}
</Text>
))}
</XStack>
<YStack>
{rows.map((row) => (
<XStack
key={rowKey(row)}
py="$2.5"
px="$3"
gap="$3"
borderTopWidth={1}
borderColor="$borderColor"
items="center"
hoverStyle={onRowPress ? { bg: '$color2' } : undefined}
cursor={onRowPress ? 'pointer' : undefined}
onPress={onRowPress ? () => onRowPress(row) : undefined}
>
{columns.map((c) => (
<YStack key={c.key} width={c.width} flex={c.width ? undefined : 1}>
{c.render ? (
c.render(row)
) : (
<Text fontSize="$3" numberOfLines={1}>
{String((row as Record<string, unknown>)[c.key] ?? '')}
</Text>
)}
</YStack>
))}
</XStack>
))}
</YStack>
</YStack>
)
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Field primitives — labeled rows over the Gui input controls.
*
* One way to render a labeled control. Every editor field goes through these, so
* spacing, disabled styling, and the label column stay consistent across a
* cloud-console form. Style props use the v5 shorthand set (p/px/items/…).
*/
import type { ReactNode } from 'react'
import { Input, TextArea } from '@hanzogui/input'
import { Label } from '@hanzogui/label'
import { Select } from '@hanzogui/select'
import { Switch } from '@hanzogui/switch'
import { Slider } from '@hanzogui/slider'
import { XStack, YStack } from '@hanzogui/stacks'
import { ChevronDown } from '@hanzogui/lucide-icons-2'
import { Text } from '../Text'
/** Labeled row: fixed label column + flexing control. */
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
return (
<XStack gap="$3" items="flex-start" flexWrap="wrap">
<Label width={180} pt="$2" color="$color11" fontSize="$3">
{label}
</Label>
<YStack flex={1} minW={240}>
{children}
</YStack>
</XStack>
)
}
export function FieldText({
value,
onChange,
disabled,
secure,
placeholder,
}: {
value: string
onChange: (v: string) => void
disabled?: boolean
secure?: boolean
placeholder?: string
}) {
return (
<Input
value={value}
onChangeText={onChange}
disabled={disabled}
secureTextEntry={secure}
placeholder={placeholder}
autoCapitalize="none"
/>
)
}
export function FieldTextArea({
value,
onChange,
disabled,
rows = 6,
}: {
value: string
onChange: (v: string) => void
disabled?: boolean
rows?: number
}) {
return (
<TextArea value={value} onChangeText={onChange} disabled={disabled} numberOfLines={rows} />
)
}
export function FieldSwitch({
checked,
onChange,
disabled,
}: {
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}) {
return (
<Switch checked={checked} onCheckedChange={onChange} disabled={disabled} size="$3">
<Switch.Thumb />
</Switch>
)
}
export function FieldSelect({
value,
options,
onChange,
disabled,
}: {
value: string
options: string[]
onChange: (v: string) => void
disabled?: boolean
}) {
return (
<Select value={value} onValueChange={onChange} disablePreventBodyScroll native>
<Select.Trigger disabled={disabled} iconAfter={<ChevronDown size={16} />}>
<Select.Value placeholder="Select…" />
</Select.Trigger>
<Select.Content>
<Select.Viewport>
{options.map((opt, i) => (
<Select.Item key={opt} index={i} value={opt}>
<Select.ItemText>{opt}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select>
)
}
export function FieldSlider({
value,
min,
max,
step,
onChange,
disabled,
}: {
value: number
min: number
max: number
step: number
onChange: (v: number) => void
disabled?: boolean
}) {
return (
<XStack gap="$3" items="center">
<Text width={56} fontSize="$3" color="$color11">
{value}
</Text>
<Slider
flex={1}
min={min}
max={max}
step={step}
value={[value]}
onValueChange={(v) => onChange(v[0] ?? min)}
disabled={disabled}
>
<Slider.Track>
<Slider.TrackActive />
</Slider.Track>
<Slider.Thumb index={0} circular />
</Slider>
</XStack>
)
}
@@ -0,0 +1,30 @@
/**
* Loader — a centered loading state with an optional label.
*
* Brand-agnostic on purpose: the shared Gui is white-labeled across brands, so
* this uses the neutral Gui Spinner (not a brand mark). Apps that want a branded
* full-screen loader compose their own mark over this layout.
*/
import type { ReactNode } from 'react'
import { Spinner } from '@hanzogui/spinner'
import { YStack } from '@hanzogui/stacks'
import { Text } from '../Text'
export function Loader({
label,
size = 'large',
}: {
label?: ReactNode
size?: 'small' | 'large'
}) {
return (
<YStack flex={1} minH={240} items="center" justify="center" gap="$3">
<Spinner size={size} color="$color11" />
{label ? (
<Text fontSize="$3" color="$color11">
{label}
</Text>
) : null}
</YStack>
)
}
@@ -0,0 +1,36 @@
/**
* PageHeader — section title + optional subtitle and right-aligned actions.
*
* A cloud-console layout primitive: the heading row every admin/data surface
* opens with. Built on the Gui stack + text primitives (shorthand style props),
* so it adapts across web and native and inherits the active theme.
*/
import type { ReactNode } from 'react'
import { XStack, YStack } from '@hanzogui/stacks'
import { Text } from '../Text'
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle?: string
actions?: ReactNode
}) {
return (
<XStack justify="space-between" items="flex-start" gap="$4">
<YStack gap="$1">
<Text fontSize="$7" fontWeight="800">
{title}
</Text>
{subtitle ? (
<Text fontSize="$3" color="$color11">
{subtitle}
</Text>
) : null}
</YStack>
{actions ? <XStack gap="$2">{actions}</XStack> : null}
</XStack>
)
}
@@ -0,0 +1,79 @@
/**
* Honest async states — one truthful way to explain a failed/empty load.
*
* A `/v1`-style endpoint can 404 (not routed on this deployment), 401/403
* (access enforced server-side), or 503 (backend starting). `honestError` maps a
* structural error (`{ status?, message }`) to a specific, truthful title+body —
* never a generic crash, never fabricated data — and `ErrorState` renders it with
* an optional retry. Decoupled from any HTTP client: pass any error with a
* numeric `status` and a `message`.
*/
import { Button } from '@hanzogui/button'
import { Card } from '@hanzogui/card'
import { XStack } from '@hanzogui/stacks'
import { TriangleAlert } from '@hanzogui/lucide-icons-2'
import { Text } from '../Text'
/** Surface-specific overrides for the 404/unauthorized explanations. */
export type HonestCopy = { notFound?: string; unauthorized?: string }
/** A structural error: an HTTP-ish status plus a message. No client coupling. */
export type HonestErrorLike = { status?: number; message: string }
/** Map a structural error to an honest title + body. Defaults are truthful. */
export function honestError(
err: HonestErrorLike,
copy: HonestCopy = {},
): { title: string; body: string } {
if (err.status === 404)
return {
title: 'Not available on this deployment',
body:
copy.notFound ??
'This API is not routed on this host yet. It appears automatically once the deployment proxies it through the gateway.',
}
if (err.status === 503)
return {
title: 'Service unavailable',
body: 'The service is starting up or temporarily unavailable. Retry in a moment.',
}
if (err.status === 401 || err.status === 403 || /sign ?in|login|unauthorized/i.test(err.message))
return {
title: 'Access required',
body:
copy.unauthorized ??
'This view requires an authorized session, enforced server-side. Sign in with an account that has access.',
}
return { title: 'Could not load', body: err.message }
}
/** The honest error card — title, explanation, and an optional retry. */
export function ErrorState({
err,
onRetry,
copy,
}: {
err: HonestErrorLike
onRetry?: () => void
copy?: HonestCopy
}) {
const { title, body } = honestError(err, copy)
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
<XStack gap="$2" items="center">
<TriangleAlert size={16} />
<Text fontSize="$4" fontWeight="700">
{title}
</Text>
</XStack>
<Text fontSize="$3" color="$color11">
{body}
</Text>
{onRetry ? (
<Button size="$2" self="flex-start" onPress={onRetry}>
Retry
</Button>
) : null}
</Card>
)
}
@@ -0,0 +1,45 @@
/**
* StatusTag — maps a resource/cluster lifecycle string to a tone, in one place.
*
* Provisioned things report free-form lifecycle strings (and platform health
* verdicts report green/yellow/red directly); this normalizes them to a single
* green/yellow/red/neutral palette so every list reads the same.
*/
import { Text } from '../Text'
type Tone = 'green' | 'yellow' | 'red' | 'neutral'
const toneOf = (status: string): Tone => {
const s = status.toLowerCase()
if (s === 'green') return 'green'
if (s === 'yellow') return 'yellow'
if (s === 'red') return 'red'
if (['ready', 'active', 'running', 'available', 'ok'].includes(s)) return 'green'
if (['creating', 'provisioning', 'pending', 'updating', 'attaching'].includes(s)) return 'yellow'
if (['error', 'failed', 'degraded', 'down'].includes(s)) return 'red'
return 'neutral'
}
// `as const` keeps the literal token types (not widened to `string`) so they
// satisfy the Gui `bg`/`color` token unions.
const TONE_BG = {
green: '$color5',
yellow: '$color4',
red: '$color4',
neutral: '$color3',
} as const
const TONE_FG = {
green: '$color12',
yellow: '$color12',
red: '$color12',
neutral: '$color11',
} as const
export function StatusTag({ status }: { status?: string }) {
const tone = toneOf(status ?? '')
return (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={TONE_BG[tone]} color={TONE_FG[tone]}>
{status || 'unknown'}
</Text>
)
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Cloud-console primitives — shared, brand-agnostic building blocks for
* Hanzo-style admin/data UIs (console2 and any cloud surface). Generic by
* construction: no app/client coupling. Composed on the Gui sibling primitives.
*/
export { PageHeader } from './PageHeader'
export { StatusTag } from './StatusTag'
export { Loader } from './Loader'
export { DataTable, type Column } from './DataTable'
export {
FieldRow,
FieldText,
FieldTextArea,
FieldSwitch,
FieldSelect,
FieldSlider,
} from './Field'
export {
ErrorState,
honestError,
type HonestCopy,
type HonestErrorLike,
} from './States'