mirror of
https://github.com/hanzoai/trust.git
synced 2026-10-30 14:54:58 +00:00
@hanzo/ui 8.2.0 moves Grid off the barrel to its own subpath, since it renders a div with display:grid. min/max collapse into one `columns` value; gap is unchanged. Seven call sites across five files.
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import {
|
|
AlertTriangle,
|
|
Building2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Database,
|
|
KeyRound,
|
|
Laptop,
|
|
Network,
|
|
Package,
|
|
Server,
|
|
} from '@hanzogui/lucide-icons-2';
|
|
import { Card } from '@hanzo/ui/primitives/Card';
|
|
import { CardContent } from '@hanzo/ui/primitives/CardContent';
|
|
import { CardFooter } from '@hanzo/ui/primitives/CardFooter';
|
|
import { CardHeader } from '@hanzo/ui/primitives/CardHeader';
|
|
import { CardTitle } from '@hanzo/ui/primitives/CardTitle';
|
|
import { Grid } from '@hanzo/ui/grid';
|
|
import { Text } from '@hanzo/ui/primitives/Text';
|
|
import { XStack } from '@hanzo/ui/primitives/XStack';
|
|
import { YStack } from '@hanzo/ui/primitives/YStack';
|
|
import type { IconLike } from '@hanzo/ui/product/color';
|
|
import { Segmented, SearchInput, type Option } from '@hanzo/ui/product/Filters';
|
|
import { StatusTag } from '@hanzo/ui/product/StatusTag';
|
|
import { CATEGORIES, type Control, type Site, type Status } from '@/lib/trust';
|
|
import { Nothing } from './nothing';
|
|
|
|
/**
|
|
* The register a status reports in. The pill vocabulary knows a hundred
|
|
* lifecycle words and none of these three, and an unknown status renders as
|
|
* "nothing to report" โ which is the wrong thing to say about an absent
|
|
* control. So the three are named here, on the grey ladder, no hue spent.
|
|
*/
|
|
const TONE = { automated: 'settled', partial: 'moving', absent: 'stopped' } as const;
|
|
|
|
/** A control with no group stated belongs to the corporate one. */
|
|
const ALL = '';
|
|
|
|
const caps = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
/**
|
|
* A mark per group, from the one icon set this page already carries.
|
|
*
|
|
* These are concepts rather than brands, so there is no trademark to borrow and
|
|
* no licence to hold โ canonical geometry from a set we ship is the whole of
|
|
* it. Every group has one, including the ones that turn out to be thin: a group
|
|
* rendered without a mark beside its siblings reads as a rendering fault, which
|
|
* is not what we mean to say about it.
|
|
*/
|
|
const GROUP_MARK: Record<string, IconLike> = {
|
|
infrastructure: Server,
|
|
data: Database,
|
|
access: KeyRound,
|
|
network: Network,
|
|
endpoint: Laptop,
|
|
corporate: Building2,
|
|
product: Package,
|
|
incident: AlertTriangle,
|
|
};
|
|
|
|
const STATES: Option<string>[] = [
|
|
{ label: 'All', value: ALL },
|
|
{ label: 'Automated', value: 'automated' },
|
|
{ label: 'Partial', value: 'partial' },
|
|
{ label: 'Absent', value: 'absent' },
|
|
];
|
|
|
|
const Eyebrow = ({ children }: { children: string }) => (
|
|
<Text fontSize="$1" fontWeight="600" color="$color10">
|
|
{children}
|
|
</Text>
|
|
);
|
|
|
|
/** `repo/path:line ยท symbol` โ enough to open the file and find the mechanism. */
|
|
const where = (s: Site) => `${s.repo}/${s.path}${s.line ? `:${s.line}` : ''}${s.symbol ? ` ยท ${s.symbol}` : ''}`;
|
|
|
|
export function Controls({ controls }: { controls: Control[] }) {
|
|
const [group, setGroup] = useState<string>(ALL);
|
|
const [state, setState] = useState<string>(ALL);
|
|
const [find, setFind] = useState('');
|
|
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
|
|
|
const shown = useMemo(() => {
|
|
const needle = find.trim().toLowerCase();
|
|
return controls.filter((c) => {
|
|
if (group !== ALL && (c.category ?? 'corporate') !== group) return false;
|
|
if (state !== ALL && c.status !== state) return false;
|
|
if (!needle) return true;
|
|
return `${c.id} ${c.title ?? ''} ${c.claim} ${c.mechanism} ${c.note ?? ''}`.toLowerCase().includes(needle);
|
|
});
|
|
}, [controls, group, state, find]);
|
|
|
|
// How many controls each group holds โ counted here from the same list the
|
|
// cards below are drawn from, so the tile and the grid cannot disagree.
|
|
const tally = useMemo(() => {
|
|
const n: Record<string, number> = {};
|
|
for (const c of CATEGORIES) n[c] = 0;
|
|
for (const c of controls) n[c.category ?? 'corporate'] = (n[c.category ?? 'corporate'] ?? 0) + 1;
|
|
return n;
|
|
}, [controls]);
|
|
|
|
if (controls.length === 0) return <Nothing>No controls are published.</Nothing>;
|
|
|
|
const toggle = (id: string) =>
|
|
setOpen((was) => {
|
|
const now = new Set(was);
|
|
if (!now.delete(id)) now.add(id);
|
|
return now;
|
|
});
|
|
|
|
return (
|
|
<YStack gap="$4">
|
|
{/* All eight groups, always, each with its mark and its own count.
|
|
A group is a thing this organization ASSERTS a shape for, so one with
|
|
few controls is a fact worth reading and one rendered as a gap in a
|
|
row of tiles is a question nobody asked. Selecting a tile filters;
|
|
selecting it again clears, so there is one control and not two. */}
|
|
<Grid columns={{ min: 128, max: 4 }} gap={12}>
|
|
{CATEGORIES.map((c) => {
|
|
const Icon = GROUP_MARK[c];
|
|
const here = group === c;
|
|
return (
|
|
<YStack
|
|
key={c}
|
|
gap="$1.5"
|
|
p="$3"
|
|
rounded={16}
|
|
borderWidth={1}
|
|
borderColor={here ? '$color8' : '$borderColor'}
|
|
bg={here ? '$color3' : '$color1'}
|
|
cursor="pointer"
|
|
role="button"
|
|
aria-pressed={here}
|
|
onPress={() => setGroup(here ? ALL : c)}
|
|
>
|
|
<XStack gap="$2" items="center" justify="space-between">
|
|
<Icon size={14} color={here ? '$color12' : '$color10'} />
|
|
<Text fontSize="$5" fontWeight="700" color={here ? '$color12' : '$color11'} className="hz-tnum">
|
|
{tally[c]}
|
|
</Text>
|
|
</XStack>
|
|
<Text fontSize="$1" color="$color10">
|
|
{caps(c)}
|
|
</Text>
|
|
</YStack>
|
|
);
|
|
})}
|
|
</Grid>
|
|
|
|
<YStack gap="$3">
|
|
<SearchInput value={find} onChange={setFind} placeholder="Find a control" name="controls" />
|
|
<Segmented options={STATES} value={state} onChange={setState} name="status" />
|
|
</YStack>
|
|
|
|
<Text fontSize="$2" color="$color10" className="hz-tnum">
|
|
{shown.length} of {controls.length} shown
|
|
</Text>
|
|
|
|
{shown.length === 0 ? (
|
|
// Two different facts, and only one of them is about the reader's typing:
|
|
// a group with nothing in it is something the organization ASSERTS, and
|
|
// all eight are listed so an empty one can be seen rather than inferred.
|
|
<Nothing>
|
|
{find.trim()
|
|
? 'No control matches that search.'
|
|
: 'Nothing is published in that group.'}
|
|
</Nothing>
|
|
) : (
|
|
<Grid columns={{ min: 340, max: 2 }} gap={16}>
|
|
{shown.map((c) => {
|
|
const seen = open.has(c.id);
|
|
return (
|
|
<Card key={c.id} rounded={24} gap="$3" bg="$color1" interactive onPress={() => toggle(c.id)}>
|
|
<CardHeader gap="$2">
|
|
<XStack gap="$3" items="flex-start" justify="space-between" width="100%">
|
|
<YStack gap="$1" flex={1}>
|
|
{/* Most controls carry no title โ the id IS the name, and
|
|
printing it twice reads as a rendering bug. */}
|
|
<CardTitle size="$4" className={c.title ? undefined : 'hz-mono'}>
|
|
{c.title ?? c.id}
|
|
</CardTitle>
|
|
<Text fontSize="$1" color="$color10" className="hz-mono">
|
|
{c.title ? `${c.id} ยท ${c.category ?? 'corporate'}` : (c.category ?? 'corporate')}
|
|
</Text>
|
|
</YStack>
|
|
<StatusTag status={c.status} tone={TONE[c.status as Status]} />
|
|
</XStack>
|
|
</CardHeader>
|
|
|
|
<CardContent gap="$2">
|
|
<Text fontSize="$3" color="$color12">
|
|
{c.claim}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color10">
|
|
{c.mechanism}
|
|
</Text>
|
|
</CardContent>
|
|
|
|
{seen ? (
|
|
<CardContent gap="$4" pt="$4" borderTopWidth={1} borderColor="$borderColor">
|
|
{c.note ? (
|
|
<YStack gap="$1">
|
|
<Eyebrow>Note</Eyebrow>
|
|
<Text fontSize="$2" color="$color11">
|
|
{c.note}
|
|
</Text>
|
|
</YStack>
|
|
) : null}
|
|
|
|
<YStack gap="$1">
|
|
<Eyebrow>Enforced in</Eyebrow>
|
|
{c.enforced.length === 0 ? (
|
|
<Text fontSize="$2" color="$color10">
|
|
No path recorded.
|
|
</Text>
|
|
) : (
|
|
c.enforced.map((s) => (
|
|
<Text key={where(s)} fontSize="$2" color="$color11" className="hz-mono path">
|
|
{where(s)}
|
|
</Text>
|
|
))
|
|
)}
|
|
</YStack>
|
|
|
|
<YStack gap="$2">
|
|
<Eyebrow>Checked by</Eyebrow>
|
|
{c.verified.length === 0 ? (
|
|
<Text fontSize="$2" color="$color10">
|
|
Nothing can fail on this control's behalf.
|
|
</Text>
|
|
) : (
|
|
c.verified.map((v) => (
|
|
<YStack key={v.method} gap="$1">
|
|
<Text fontSize="$2" color="$color12">
|
|
{v.method}
|
|
{v.detail ? ` โ ${v.detail}` : ''}
|
|
</Text>
|
|
{v.actions && v.actions.length > 0 ? (
|
|
<XStack gap="$1.5" flexWrap="wrap">
|
|
{v.actions.map((a) => (
|
|
<Text key={a} fontSize="$1" color="$color11" px="$2" py="$1" rounded="$10" bg="$color3" className="hz-mono">
|
|
{a}
|
|
</Text>
|
|
))}
|
|
</XStack>
|
|
) : null}
|
|
</YStack>
|
|
))
|
|
)}
|
|
</YStack>
|
|
|
|
<YStack gap="$2">
|
|
<Eyebrow>Answers</Eyebrow>
|
|
{c.maps.length === 0 ? (
|
|
<Text fontSize="$2" color="$color10">
|
|
No clause is named.
|
|
</Text>
|
|
) : (
|
|
<XStack gap="$1.5" flexWrap="wrap">
|
|
{c.maps.map((m) => (
|
|
<XStack
|
|
key={m.clause}
|
|
gap="$1.5"
|
|
px="$2"
|
|
py="$1"
|
|
rounded="$10"
|
|
bg="$color3"
|
|
items="center"
|
|
>
|
|
<Text fontSize="$1" color="$color12" className="hz-mono">
|
|
{m.clause}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color10">
|
|
{m.strength}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</XStack>
|
|
)}
|
|
</YStack>
|
|
</CardContent>
|
|
) : null}
|
|
|
|
<CardFooter gap="$1.5" items="center">
|
|
{seen ? <ChevronDown size={14} color="$color10" /> : <ChevronRight size={14} color="$color10" />}
|
|
<Text fontSize="$1" color="$color10">
|
|
{seen ? 'Hide evidence' : 'Evidence'}
|
|
</Text>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
})}
|
|
</Grid>
|
|
)}
|
|
</YStack>
|
|
);
|
|
}
|