<script lang="ts">
export interface SeenEntry {
id: string;
name: string;
color?: string;
ts: string | null;
isYou?: boolean;
unseen?: boolean;
}
interface Props {
entries: SeenEntry[];
/** Piece 8b — one of the agent dossier's three named entry points
* ("wire at least: Fleet deck, Overview node, seen-by roster"). Each
* real roster row (never the synthesized "You" row, which has no
* dossier) opens the SAME reusable popover clicking anywhere else an
* agent appears does. */
onOpenAgent?: (id: string) => void;
}
let { entries, onOpenAgent }: Props = $props();
const allSeen = $derived(entries.length > 0 && entries.every((e) => !e.unseen));
const seenCount = $derived(entries.filter((e) => !e.unseen && !e.isYou).length);
const totalCount = $derived(entries.filter((e) => !e.isYou).length);
const youEntry = $derived(entries.find((e) => e.isYou));
const rosterTitle = $derived.by(() => {
if (allSeen) return 'Seen · everyone addressed';
const youPart = youEntry ? (youEntry.unseen ? '' : ' + you') : '';
return `Seen · ${seenCount} of ${totalCount} addressed${youPart}`;
});
let pinned = $state(false);
function toggle(e: MouseEvent) {
e.stopPropagation();
pinned = !pinned;
}
</script>
<span class="seen" class:done={allSeen} class:pin={pinned}>
{#if allSeen}
<button type="button" class="seen-btn" onclick={toggle} aria-expanded={pinned} aria-label="Seen roster">
<span class="chk">✓</span><span class="lab">all seen</span>
</button>
{:else}
<button type="button" class="seen-btn" onclick={toggle} aria-expanded={pinned} aria-label="Seen roster">
<span class="lab">seen</span>
<span class="dots">
{#each entries.filter((e) => !e.isYou) as entry (entry.id)}
<span class="sd" class:un={entry.unseen} style={entry.unseen ? '' : `background:${entry.color}`}></span>
{/each}
{#if youEntry}
<span class="sd you" class:un={youEntry.unseen}></span>
{/if}
</span>
</button>
{/if}
<span class="roster" role="dialog" aria-label="Seen by">
<span class="rt">{rosterTitle}</span>
{#each entries.filter((e) => !e.isYou) as entry (entry.id)}
<button
type="button"
class="rr rr-btn"
class:un={entry.unseen}
onclick={(e) => {
e.stopPropagation();
onOpenAgent?.(entry.id);
}}
title={`Open ${entry.name}'s dossier`}
aria-label={`Open ${entry.name}'s dossier`}
>
<span class="ra" style={entry.unseen ? '' : `color:${entry.color};background:color-mix(in srgb, ${entry.color} 18%, transparent)`}
>{entry.unseen ? '' : entry.name.slice(0, 2).toUpperCase()}</span
>
<span class="rn">{entry.name}</span>
<span class="rtime">{entry.unseen ? 'unseen' : entry.ts}</span>
</button>
{/each}
{#if youEntry}
<span class="rr" class:un={youEntry.unseen}>
<span class="ra eye">◉</span>
<span class="rn">You</span>
<span class="rtime">{youEntry.unseen ? 'unseen' : youEntry.ts}</span>
</span>
{/if}
</span>
</span>
<style>
.seen {
position: relative;
margin-left: auto;
display: flex;
align-items: center;
gap: 7px;
}
.seen-btn {
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
background: transparent;
padding: 0;
color: inherit;
}
.seen .lab {
font: 600 9.5px/1 var(--mono);
letter-spacing: 0.04em;
color: var(--faint);
}
.seen.done .lab {
color: var(--done);
}
.seen .dots {
display: flex;
align-items: center;
gap: 3px;
padding-left: 4px;
}
/* Non-overlapping, so no dot's background-ring can crescent-cut the one before
it. Seen = filled agent color; unseen = clean faint hollow ring; you = filled
accent. Uniform 8px circles. */
.seen .sd {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.seen .sd.un {
background: transparent !important;
box-shadow: inset 0 0 0 1.5px var(--faint);
}
.seen .sd.you {
background: var(--accent) !important;
}
/* Higher specificity than either single-class rule above (needed since
both use !important) — an unseen "you" dot must render hollow like
every other unseen dot, not filled accent just because it's also
.you. */
.seen .sd.you.un {
background: transparent !important;
box-shadow: inset 0 0 0 1.5px var(--faint);
}
.seen .chk {
color: var(--done);
font-size: 11px;
font-weight: 800;
}
.seen .roster {
position: absolute;
top: calc(100% + 7px);
right: 0;
z-index: 30;
display: none;
width: 210px;
background: var(--panel-2);
border: 1px solid var(--border-2);
border-radius: 10px;
padding: 10px 11px;
box-shadow: var(--shadow);
cursor: default;
text-align: left;
}
.seen:hover .roster,
.seen.pin .roster {
display: block;
}
.roster .rt {
font: 700 8.5px/1 var(--mono);
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: 9px;
}
.roster .rr {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
}
.roster .rr-btn {
width: 100%;
border: 0;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
border-radius: 5px;
}
.roster .rr-btn:hover,
.roster .rr-btn:focus-visible {
background: var(--panel-2);
}
.roster .ra {
width: 19px;
height: 19px;
border-radius: 5px;
display: grid;
place-items: center;
font: 700 8.5px/1 var(--mono);
flex: 0 0 auto;
}
.roster .ra.eye {
color: var(--text);
box-shadow: inset 0 0 0 1.5px var(--text);
font-size: 9px;
}
.roster .rn {
flex: 1;
font-size: 12px;
color: var(--text);
}
.roster .rtime {
font: 500 10px/1 var(--mono);
color: var(--faint);
}
.roster .rr.un .rn {
color: var(--muted);
}
.roster .rr.un .rtime {
color: var(--blocked);
}
</style>