mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
154 lines
6.0 KiB
TypeScript
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
|