Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

154 lines
6.0 KiB
TypeScript

import { Grid } from '@hanzo/ui/grid'
import { Text, View, YStack } from '@/components/ui'
import { tap } from '@hanzo/app/lib/tap'
import { TEAM } from '@hanzo/app/team'
import { Actions, Band, Head } from './band'
/**
* The crew a workspace arrives with, and a door into each one.
*
* The row is the roster the product draws under its own composer — same
* characters, same likenesses — so a face here and a face in the workspace are
* one agent.
*
* IT READS A TO Z, and `sort` is what holds that rather than the order somebody
* typed the literal in: a name appended to the end of the list lands where it
* belongs without anybody noticing it had to. `localeCompare` at base
* sensitivity so a lower-case handle files under its letter, not after Z.
*
* A TILE IS A LINK, and the link is the whole claim. `components/workspace/
* open.ts` reads `agent` off the query on load, so `/chat?agent=Maya` opens a
* room with that character rather than a page about her. The name travels as it
* is spelled here: the room prints it, and `roleOf` in `cast.tsx` lowercases it
* to find the likeness, so display capitals cost nothing.
*
* THE LINES ARE THE TEAM'S OWN — a core member's part is the `role`
* hanzoai/personas gives them (`components/workspace/team.ts`): Feynman
* researches, Vi sets direction, Maya coordinates. A character outside the core
* team with no line in the product carries a name and nothing else rather than a
* sentence written for a landing page.
*
* A PORTRAIT IS A FILE, so it is an `<img>` rather than the product's `Face`:
* fourteen fixed assets, known at build, and no workspace import to draw them.
* The PNGs are cut out with an alpha channel and the two photographs are not, so
* the disc carries the house ground `--neutral-800` that `AnyAgent` already sets
* for the same portraits. The per-character colours `cast.tsx` names stay in the
* product, where a hue tells two characters apart.
*
* HOVER IS CSS — a lift and a ring, scoped to `[data-crew]`, so this stays a
* Server Component and the movement is off under `prefers-reduced-motion`.
*/
/** One door: the name the room opens under, the likeness, the part it plays. */
type Door = {
name: string
face?: string
part?: string
/** the handle a room opens under, where it is not the name */
agent?: string
}
const CREW: Door[] = [
{ name: 'Antje', face: '/agents/antje.jpg' },
{ name: 'Zach', face: '/agents/zach.jpg', part: 'Quantitative Finance & Market Analyst' },
...TEAM.map((one) => ({ name: one.name, face: `/agents/${one.id}.png`, part: one.role })),
].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
/**
* The ring is inked against the disc, not against the page. The ground under it
* is `--neutral-800` whichever theme the reader picked, and the `--neutral-*`
* scale is fixed at `:root` and never retuned, so the two are measured against
* each other once and hold in both: 1.46:1 at rest, 10.2:1 lit. A ring in
* `--foreground` follows the page instead and flattens into the disc the moment
* the page turns light.
*
* The tile is a gui group: under a pointer or a key the disc rises 2px and its
* ring lights. The rise rides @hanzo/ui's `slide` motion, which eases it and
* which the motion sheet turns off for a reader who asked for stillness.
*/
const LIT = { y: -2, borderColor: 'var(--neutral-300)' } as const
export function Crew() {
return (
<Band id="crew">
<Head
eyebrow="Agents"
title="People and agents. One workspace."
lede="The team is already there. Open one and talk."
/>
{/* 116 puts seven across a laptop and two on a phone: `Grid` writes the
floor as `min(116px, 100%)`, so a column never outgrows the viewport,
and `max` holds the row to seven however wide the page gets. */}
<Grid data-crew="" columns={{ min: 116, max: 7 }} gap={24} mt={44}>
{CREW.map(({ name, face, part, agent }) => (
<YStack
key={name}
render={<a href={`/chat?agent=${encodeURIComponent(agent ?? name)}`} />}
group
$touchable={tap.$touchable}
items="center"
gap={10}
minW={0}
$platform-web={{ textAlign: 'center' }}
>
<View
data-disc=""
data-motion="slide"
width={64}
height={64}
rounded={9999}
bg="var(--neutral-800)"
borderWidth={1}
borderColor="var(--neutral-700)"
overflow="hidden"
shrink={0}
items="center"
justify="center"
$group-hover={LIT}
$group-focusVisible={LIT}
>
{face ? (
/* Empty alt: the name is printed under the face, and a reader
would otherwise hear it twice. */
<View
render={<img src={face} alt="" width={64} height={64} loading="lazy" decoding="async" />}
display="block"
width="100%"
height="100%"
objectFit="cover"
/>
) : (
<Text aria-hidden fontSize="$7" lineHeight="$7" color="var(--neutral-300)">
{name.slice(0, 1)}
</Text>
)}
</View>
<Text minW={0} color="inherit">
<Text fontSize="$2" fontWeight="500" lineHeight="1.1375rem" color="$ink">
{name}
</Text>
{part ? (
<Text display="block" fontSize="$1" lineHeight="1.0313rem" color="var(--text-tertiary)">
{part}
</Text>
) : null}
</Text>
</YStack>
))}
</Grid>
<Actions top={32}>
<Text render={<a href="/agents" />} {...tap} fontSize="$2" lineHeight="1.3rem" color="var(--muted-foreground)">
Or build your own.
</Text>
</Actions>
</Band>
)
}
export default Crew