Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165<script lang="ts"> import { participates } from '$lib/admin.js' import { account } from '$lib/auth.svelte.js' import { openDraft } from '$lib/compose.svelte.js' import { focusStranded, keepFocus } from '$lib/focus.js' import { registerPrimary } from '$lib/keys.js' import { goalProjects } from '$lib/goal.js' import { requestableTypes } from '$lib/requests.js' import { type Space } from '$lib/space.js' import { closeMenu, ui } from '$lib/ui.svelte.js' import { typeLabel } from '$lib/units.js' import Glyph from './Glyph.svelte'
// ⊕ offers an active member a new goal, and additionally makes an item of the list they are in. // It stays visible but disabled for a signed-in non-member: all of its records are member-only, // while that visitor's one valid write lives in the Community composer on a goal. // // It used to be only the second of those, which meant the one record a human writes from scratch // could only be written from a project's own goal list — seven of the ten routes had no ⊕ at all, // and of the three that did, one offered a goal. So the goal is now the first item of every menu, on // every view of an open space, and the *rest* of the menu is still generated from the registry // (`com.disnetdev.radial.artifactType` records, scoped by the view) and from nothing else. That is // the product's core claim as a piece of UI: an admin writes a registry record and a button // appears, with no deploy and no schema change. interface Props { space: Space /** The project this view belongs to, if it belongs to one — what a new goal starts on. */ here?: string | undefined /** The registry section, on the views that have things to request. */ types?: { scope: 'goal' | 'project'; target: string; where: string } | undefined } const { space, here, types }: Props = $props()
let menu = $state<HTMLDivElement | null>(null) let plus = $state<HTMLButtonElement | null>(null)
const open = $derived(ui.menuOpen) const offered = $derived(types ? requestableTypes(space.index, types.scope) : []) const barred = $derived( account.status === 'signed-in' && !participates(space.directory, account.did), ) // Looked up among the projects a goal may actually be written into, not among all of them: the card // drops an archived `here` and asks instead (`goal.ts:defaultGoalProject`), and `/space` links // archived projects like any other. An unfiltered lookup would let this item promise "New goal in // radial-site" and then open a card headed something else. const project = $derived( here ? goalProjects(space.index).find((candidate) => candidate.target.uri === here) : undefined, )
function toggle(): void { if (barred) return ui.menuOpen = !ui.menuOpen }
$effect(() => { if (barred && ui.menuOpen) closeMenu() })
// ⌘N is this button. Registered rather than handled here, so the map lives in one table // (`keys.ts`). Most browsers keep ⌘N for a new window and never deliver it, which is why a plain // `n` — registered in the table itself, so it does not depend on this being mounted — is the one // that always lands. $effect(() => registerPrimary(toggle))
// The menu is opened to be chosen from, so the first item takes focus. Dismissing it — Escape, a // click outside, ⌘N again — hands focus back to whatever had it, which for a menu opened with ⌘N is // nothing in particular, hence the ⊕. *Choosing* an item is not dismissing: the card it opens puts // the caret in its own first field, and this must not pull it back out. let back: ((stranded?: boolean) => boolean) | undefined let chose = false $effect(() => { if (ui.menuOpen) { back ??= keepFocus() const timer = setTimeout(() => menu?.querySelector('button')?.focus(), 60) return () => clearTimeout(timer) } // Asked before the browser blurs the item that was chosen: a `hidden` menu cannot hold focus, so // an item that still has it here is about to leave it on `<body>`. const stranded = focusStranded(menu) if (!chose && back && stranded && !back(true)) plus?.focus() chose = false back = undefined return undefined })</script>
<svelte:window onclick={(event) => { const target = event.target as HTMLElement | null if (open && !target?.closest('.menu') && !target?.closest('.plus')) closeMenu() }}/>
<button bind:this={plus} class="plus" disabled={barred} aria-expanded={open} aria-haspopup="menu" aria-keyshortcuts="Meta+N Control+N" aria-label="New goal or request an artifact" title={barred ? 'Only active members can create goals or requests in this space' : 'New goal or request an artifact — ⌘N'} onclick={toggle}> <svg width="21" height="21" viewBox="0 0 22 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <path d="M11 4v14M4 11h14" /> </svg></button>
<div class="menu" role="menu" aria-label="New goal or artifact types" hidden={!open} bind:this={menu}> <button class="menu-item" role="menuitem" aria-keyshortcuts="N" onclick={() => { // Same contract as the type items below: the item that was chosen goes away with the menu, so // the card's way back is the ⊕, and `chose` is what stops the effect above pulling the caret // out of the field the card has just put it in. chose = true closeMenu() openDraft({ kind: 'goal', ...(here ? { project: here } : {}) }, plus ?? undefined) }} > <span class="glyph" style="color:var(--accent)"><Glyph name="goal" /></span> <span class="mtxt"> <span class="mname">New goal{project ? ` in ${project.name}` : ''}</span> <span class="mdesc">Start a new piece of work — press <b>n</b> anywhere</span> </span> </button> {#if types} <div class="menu-rule"></div> <div class="menu-head">Request an artifact — {types.where}</div> {#each offered as { name, value } (name)} <button class="menu-item" role="menuitem" aria-haspopup="dialog" onclick={() => { chose = true closeMenu() // The item that was chosen goes away with the menu, so the card's way back is the ⊕. openDraft( { kind: 'request', target: types.target, scope: types.scope, type: name }, plus ?? undefined, plus ?? undefined, ) }} > <span class="glyph"><Glyph {name} /></span> <span class="mtxt"> <span class="mname">{typeLabel(name)}</span> <span class="mdesc">{value.outputSpec.description}</span> </span> </button> {/each} {#if offered.length === 0} <div class="menu-head" style="font-weight:400;line-height:1.4"> This space has nothing to ask for at the {types.scope} level yet. </div> {/if} {/if}</div>