Files
zeekayandHanzo Dev df3e95e659 Finish the chat surface and the shell around it
Two regions had written the same keyboard-and-role helper, and one of
them spelled the press with a handler React DOM drops, so half the
controls answered Enter and half answered nothing. There is one now, and
the compiler refuses a call site that states any of it twice.

Enter in the composer moved to keydown: the keypress handler never ran,
so the key fell through and typed a newline into the message somebody
had just tried to send.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 15:51:06 -07:00

226 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Inbox: the panel, its tabs, and the list.
*
* It draws inside the shell's `aside`, which owns the 307 width, the panel
* surface and the corner; what is here is the panel's own anatomy.
*
* The header rhythm is image-derived (÷1.5 from 2048×1138): 12 above the title,
* 20 from the title to the tab row, a 38-tall tab row ending ON a hairline that
* spans the FULL panel width, and 10 from that rail to the first row. A rail
* running edge to edge under a tab row inset by the panel padding is what makes
* this read as one component rather than a row of buttons.
*
* Five states, each entered deliberately and none inheriting a neighbour's
* verdict — see fixture.ts. Only the last of them has a reference.
*/
import { CopyCheck, List as ListMark, MessageCircleMore, X } from '@hanzogui/lucide-icons-2'
import {
ScrollArea,
SizableText,
Spinner,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
XStack,
YStack,
} from '@hanzo/ui'
import { useState, type ReactNode } from 'react'
import { hideAside } from '~/shell/aside.ts'
import { PAD } from '~/shell/measure.ts'
import { gap, paint, ring, round, rung } from '~/theme/theme'
import { see, useNotes } from './feed.ts'
import { inTab, TABS, unseen, type Note, type Tab } from './note.ts'
import { press } from '~/shell'
import { Row } from './Row.tsx'
/** title 24img above · title→tabs 30img · tab row 57img · rail→list 15img */
const HEAD = 12
/** Measured 18.7 between the title's box and the tab row's; the ramp's 16 lands
* the rail at 90 against the reference's 90.7, and a rhythm off the ramp reads
* as no product at all. */
const BAND = 16
const TAB = 38
const LIST = 10
const MARK = 20
const BADGE = 16
const SHUT = 24
const face = { all: ListMark, task: CopyCheck, chat: MessageCircleMore } as const
const label = { all: 'All', task: 'Tasks', chat: 'Chat' } as const
/** A quiet middle, for the states the reference never shows. */
const Say = ({ children }: { children: ReactNode }) => (
<YStack flex={1} items="center" justify="center" p={PAD} gap={gap.inset}>
{children}
</YStack>
)
const Feed = ({ tab }: { tab: Tab }) => {
const feed = useNotes()
if (feed.phase === 'loading')
return (
<Say>
<Spinner />
</Say>
)
if (feed.phase === 'failure')
return (
<Say>
<SizableText size={rung.body} color={paint.mute}>
The inbox is not answering.
</SizableText>
<SizableText
size={rung.body}
color={paint.ink}
textDecorationLine="underline"
{...press(feed.reload)}
focusVisibleStyle={ring}
>
Try again
</SizableText>
</Say>
)
const notes: Note[] = inTab(feed.data ?? [], tab)
if (notes.length === 0)
return (
<Say>
<SizableText size={rung.body} color={paint.dim}>
Nothing here.
</SizableText>
</Say>
)
return (
<ScrollArea data-parity-key="inbox.list" flex={1} pt={LIST}>
<YStack role="list">
{notes.map((n) => (
<Row key={n.id} note={n} onSee={see} />
))}
</YStack>
</ScrollArea>
)
}
export const Inbox = () => {
const [tab, setTab] = useState<Tab>('all')
const feed = useNotes()
const notes = feed.data ?? []
return (
<YStack data-parity-key="inbox.panel" flex={1} minH={0}>
<XStack px={PAD} pt={HEAD} items="center" justify="space-between">
<SizableText data-parity-key="inbox.title" size={rung.title} fontWeight="500" color={paint.ink}>
Inbox
</SizableText>
<XStack
data-parity-key="inbox.close"
{...press(hideAside, 'Close the inbox')}
width={SHUT}
height={SHUT}
items="center"
justify="center"
rounded={round.field}
hoverStyle={{ background: paint.card }}
focusVisibleStyle={ring}
>
<X size={14} color={paint.dim} />
</XStack>
</XStack>
<Tabs
value={tab}
onValueChange={(v: string) => setTab(v as Tab)}
orientation="horizontal"
flex={1}
minH={0}
gap={0}
mt={BAND}
>
{/* The rail is the WRAPPER's border rather than the list's, so it runs
edge to edge under a tab row that is inset by the panel padding.
That is the thing that makes this read as one component instead of a
row of buttons, and it is measured: 460 lit of a 460-wide panel. */}
<YStack data-parity-key="inbox.rail" borderBottomWidth={1} borderColor={paint.rule}>
<TabsList
height={TAB}
self="stretch"
justify="flex-start"
items="stretch"
p={0}
pl={gap.tight}
gap={gap.tight}
bg="transparent"
rounded={0}
>
{TABS.map((t) => {
const on = t === tab
const Mark = face[t]
// All is the union; its count is the sum of the other two and
// tells a reader nothing they cannot already see, which is why
// the reference badges Tasks and leaves All bare.
const count = t === 'all' ? 0 : unseen(notes, t)
return (
<TabsTrigger
key={t}
data-parity-key={`inbox.tab.${t}`}
value={t}
height="100%"
// gui's tab frame stacks, because the segmented control it
// dresses by default holds one word. This one holds a glyph,
// a word and a count on one line.
flexDirection="row"
px={gap.tight}
gap="$1.5"
items="center"
justify="center"
rounded={0}
bg="transparent"
borderBottomWidth={2}
borderColor={on ? paint.accent : 'transparent'}
activeStyle={{ background: 'transparent' }}
hoverStyle={{ background: 'transparent' }}
focusStyle={{ background: 'transparent' }}
focusVisibleStyle={ring}
>
<Mark size={MARK} color={on ? paint.ink : paint.dim} />
<SizableText size={rung.row} color={on ? paint.ink : paint.dim}>
{label[t]}
</SizableText>
{count > 0 ? (
<YStack
data-parity-key="inbox.badge"
minW={BADGE}
height={BADGE}
px={4}
rounded={round.pill}
items="center"
justify="center"
bg={paint.alert}
>
<SizableText size={rung.chip} fontWeight="600" color={paint.panel}>
{count}
</SizableText>
</YStack>
) : null}
</TabsTrigger>
)
})}
</TabsList>
</YStack>
{TABS.map((t) => (
<TabsContent key={t} value={t} flex={1} minH={0}>
{t === tab ? <Feed tab={t} /> : null}
</TabsContent>
))}
</Tabs>
</YStack>
)
}