diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 81c4e0b..f929138 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,12 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm codegen
- run: git diff --exit-code
+ # Build FIRST, before the checks that read a built package. `@radial/core` resolves through
+ # `exports.import` to `dist/`, and the UI's `vite.config.ts` reaches it via `client-metadata.ts`
+ # — so with no `dist/` the config fails to load, and `svelte-check` fails any component that
+ # has a `
diff --git a/packages/ui/src/lib/components/Community.svelte.test.ts b/packages/ui/src/lib/components/Community.svelte.test.ts
index 84b9930..b7caf6e 100644
--- a/packages/ui/src/lib/components/Community.svelte.test.ts
+++ b/packages/ui/src/lib/components/Community.svelte.test.ts
@@ -28,7 +28,7 @@ const GOAL = 'at://did:plc:member/com.disnetdev.radial.goal/goal'
const GUEST = 'did:plc:guest'
const HOSTILE = '
**not bold**'
-const bless = (rkey: string, body: string, comment: string) => ({
+const bless = (rkey: string, body: string, comment: string, createdAt = '2026-01-01T00:05:00Z') => ({
did: 'did:plc:member',
uri: `at://did:plc:member/${COLLECTIONS.blessComment}/${rkey}`,
cid: `cid-${rkey}`,
@@ -40,7 +40,7 @@ const bless = (rkey: string, body: string, comment: string) => ({
goal: { uri: GOAL, cid: 'cid-goal' },
comment: { uri: `at://${GUEST}/${COLLECTIONS.message}/${comment}`, cid: `cid-${comment}` },
body,
- createdAt: '2026-01-01T00:05:00Z',
+ createdAt,
},
})
@@ -78,7 +78,12 @@ afterEach(() => {
host.remove()
})
-const render = (props: { view: GoalView; space: Space; reader?: ForeignReader }): void => {
+const render = (props: {
+ view: GoalView
+ space: Space
+ discover?: boolean
+ reader?: ForeignReader
+}): void => {
component = mount(Community, { target: host, props }) as Record
flushSync()
}
@@ -112,6 +117,54 @@ describe('the community section', () => {
expect(host.textContent).toContain('No comments from outside this space')
})
+ it('offers withdrawal, not a third blessing, once a withdrawn comment is blessed again', () => {
+ render({
+ view: view(
+ [bless('b2', 'Blessed again', 'c1', '2026-01-01T00:09:00Z')],
+ [bless('b1', 'First blessing', 'c1', '2026-01-01T00:05:00Z')],
+ ),
+ space: space(),
+ })
+ const labels = [...host.querySelectorAll('button')].map((entry) => entry.textContent?.trim())
+ expect(labels).toContain('Withdraw blessing')
+ expect(labels).not.toContain('Bless it again')
+ expect(host.textContent).toContain('blessed')
+ expect(host.textContent).not.toContain('blessing withdrawn')
+ })
+
+ // The switch governs solicitation. Prose a member signed for is in the bundle of every turn on
+ // this goal until it is withdrawn, so it stays on screen with the control that removes it —
+ // hiding the section would leave agents reading text nobody could see or retract.
+ it('keeps a blessed comment and its withdrawal control when discovery is off', () => {
+ render({
+ view: view([bless('b1', 'Still in every bundle', 'c1')]),
+ space: space(),
+ discover: false,
+ })
+ expect(host.textContent).toContain('Still in every bundle')
+ expect(
+ [...host.querySelectorAll('button')].map((entry) => entry.textContent?.trim()),
+ ).toContain('Withdraw blessing')
+ // …and it does not claim a lookup happened.
+ expect(host.textContent).not.toContain('found through a public backlink index')
+ expect(host.textContent).toContain('no longer asking for comments')
+ })
+
+ it('does not query the index at all when discovery is off', () => {
+ const fetched = vi.fn(async () => new Response('{}', { status: 200 }))
+ const read = vi.fn()
+ vi.stubGlobal('fetch', fetched)
+ render({
+ view: view([bless('b1', 'Blessed', 'c1')]),
+ space: space(),
+ discover: false,
+ reader: { getForeignRecord: read as unknown as ForeignReader['getForeignRecord'] },
+ })
+ expect(fetched).not.toHaveBeenCalled()
+ expect(read).not.toHaveBeenCalled()
+ vi.unstubAllGlobals()
+ })
+
it('makes blessing a two-step, and writes only on the second press', async () => {
const rkey = 'c9'
const uri = `at://${GUEST}/${COLLECTIONS.message}/${rkey}`
diff --git a/packages/ui/src/lib/guests.test.ts b/packages/ui/src/lib/guests.test.ts
index 6feb7d2..4ffd13c 100644
--- a/packages/ui/src/lib/guests.test.ts
+++ b/packages/ui/src/lib/guests.test.ts
@@ -8,6 +8,7 @@ import {
didOf,
discoverGuestComments,
getBacklinks,
+ showsCommunity,
unblessArgs,
unblessable,
type BacklinkLocator,
@@ -180,7 +181,13 @@ describe('discovering guest comments', () => {
// ── the section's own rows ──────────────────────────────────────────────────────────────────────
-const bless = (did: string, rkey: string, comment: { uri: string; cid: string }, body: string) => ({
+const bless = (
+ did: string,
+ rkey: string,
+ comment: { uri: string; cid: string },
+ body: string,
+ createdAt = '2026-01-01T00:05:00Z',
+) => ({
did,
uri: `at://${did}/${COLLECTIONS.blessComment}/${rkey}`,
cid: `cid-${rkey}`,
@@ -192,7 +199,7 @@ const bless = (did: string, rkey: string, comment: { uri: string; cid: string },
goal: { uri: GOAL, cid: 'cid-goal' },
comment,
body,
- createdAt: '2026-01-01T00:05:00Z',
+ createdAt,
},
})
@@ -236,6 +243,90 @@ describe('what the section draws', () => {
])
expect(rows[0]?.edited).toBe(false)
})
+
+ // The workflow the section itself offers: bless, withdraw, then "Bless it again". The comment now
+ // carries a live blessing AND a tombstoned one, and the live blessing is what every turn bundle on
+ // this goal reads — so a row that reported "withdrawn" would be telling a member their comment is
+ // out of agent reach while agents were still reading it, and offering the wrong button besides.
+ it('reports a re-blessed comment as blessed, not as the withdrawal it replaced', () => {
+ const withdrawn = bless(MEMBER, 'b1', commentRef, 'First blessing', '2026-01-01T00:05:00Z')
+ const again = bless(MEMBER, 'b2', commentRef, 'Blessed again', '2026-01-01T00:09:00Z')
+ for (const rows of [
+ blessedRows(view([again], [withdrawn])),
+ communityRows(view([again], [withdrawn])),
+ ]) {
+ expect(rows).toHaveLength(1)
+ expect(rows[0]?.retracted).toBe(false)
+ // And it names the LIVE blessing, so `Withdraw blessing` writes a tombstone against the record
+ // that is actually in the bundle rather than re-tombstoning a dead one.
+ expect(rows[0]?.bless?.uri).toBe(again.uri)
+ expect(rows[0]?.body).toBe('Blessed again')
+ }
+ })
+
+ it('still reports withdrawn when every blessing on a comment is withdrawn', () => {
+ const rows = communityRows(
+ view([], [
+ bless(MEMBER, 'b1', commentRef, 'First', '2026-01-01T00:05:00Z'),
+ bless(MEMBER, 'b2', commentRef, 'Second', '2026-01-01T00:09:00Z'),
+ ]),
+ )
+ expect(rows).toHaveLength(1)
+ expect(rows[0]?.retracted).toBe(true)
+ expect(rows[0]?.bless?.rkey).toBe('b2')
+ })
+
+ it('gives two comments two rows, and never merges across them', () => {
+ const other = { uri: `at://${GUEST}/${COLLECTIONS.message}/two`, cid: 'cid-two' }
+ const rows = communityRows(
+ view([bless(MEMBER, 'b2', other, 'Live elsewhere')], [bless(MEMBER, 'b1', commentRef, 'Gone')]),
+ )
+ expect(rows.map((row) => [row.uri, row.retracted])).toEqual(
+ expect.arrayContaining([
+ [commentRef.uri, true],
+ [other.uri, false],
+ ]),
+ )
+ expect(rows).toHaveLength(2)
+ })
+
+ // Discovery running over a re-blessed comment must not undo the merge either: `communityRows`
+ // takes the live body for display, and the blessing it carries has to stay the live one.
+ it('keeps the live blessing when discovery supplies the same comment', () => {
+ const withdrawn = bless(MEMBER, 'b1', commentRef, 'First blessing', '2026-01-01T00:05:00Z')
+ const again = bless(MEMBER, 'b2', commentRef, 'Blessed again', '2026-01-01T00:09:00Z')
+ const rows = communityRows(view([again], [withdrawn]), [
+ { ...commentRef, did: GUEST, body: 'Blessed again', createdAt: '2026-01-01T00:00:00Z' },
+ ])
+ expect(rows[0]?.retracted).toBe(false)
+ expect(rows[0]?.bless?.uri).toBe(again.uri)
+ expect(rows[0]?.edited).toBe(false)
+ })
+})
+
+// The switch is solicitation, not revocation, and the section has to outlive it: a blessing stays in
+// the bundle of every turn on the goal until a `retractBless` withdraws it, so a page that hid the
+// section would leave agents reading prose with nowhere on screen to see it or take it back.
+describe('whether the section is drawn at all', () => {
+ const commentRef = { uri: `at://${GUEST}/${COLLECTIONS.message}/one`, cid: 'cid-one' }
+
+ it('draws it whenever the space is asking for comments', () => {
+ expect(showsCommunity(view([]), true)).toBe(true)
+ })
+
+ it('keeps drawing it with the switch off while a live blessing stands', () => {
+ expect(showsCommunity(view([bless(MEMBER, 'b1', commentRef, 'In every bundle')]), false)).toBe(true)
+ })
+
+ it('keeps drawing it with the switch off for a withdrawn blessing too', () => {
+ // Not in any bundle any more, but it is the space's own record of a decision, and the row is
+ // where "this was withdrawn" is said at all.
+ expect(showsCommunity(view([], [bless(MEMBER, 'b1', commentRef, 'Withdrawn')]), false)).toBe(true)
+ })
+
+ it('drops it only when the switch is off and the space blessed nothing here', () => {
+ expect(showsCommunity(view([]), false)).toBe(false)
+ })
})
describe('who may, and what they write', () => {
diff --git a/packages/ui/src/lib/guests.ts b/packages/ui/src/lib/guests.ts
index 0709127..27aa10a 100644
--- a/packages/ui/src/lib/guests.ts
+++ b/packages/ui/src/lib/guests.ts
@@ -240,19 +240,60 @@ export interface CommunityRow {
edited: boolean
}
-/** The blessings this space holds for a goal, live and withdrawn, as rows. */
+/**
+ * Whether the goal page draws the Community section at all.
+ *
+ * `setGuestComments` governs SOLICITATION: with it off, the page stops querying the backlink index
+ * and stops the composer inviting anybody. It is deliberately NOT revocation, and this is where the
+ * difference has teeth — a blessing already in the fold is in the bundle of every turn on this goal
+ * until a `retractBless` withdraws it. Hiding the section on the switch would take away the only
+ * screen that prose appears on and the only control that removes it, while agents went on reading
+ * it. So the section outlives the switch for as long as the space holds a blessing on this goal.
+ */
+export const showsCommunity = (view: GoalView, enabled: boolean): boolean =>
+ enabled || view.blessedComments.length > 0 || view.retractedBlessings.length > 0
+
+/**
+ * The blessings this space holds for a goal, live and withdrawn, as rows — one row per COMMENT,
+ * never one per blessing record.
+ *
+ * A comment can carry several blessings, and the section itself is what produces them: "Bless it
+ * again" on a withdrawn row writes a second `blessComment` beside the tombstoned first, and two
+ * members can independently bless the same words. So the row has to answer one question about the
+ * comment — is anything the space signed for it still live? — and a LIVE blessing always wins,
+ * because a live blessing is what the turn bundle carries. Letting a stale tombstone speak for a
+ * comment that is in every bundle right now would invert the one fact this section exists to report.
+ *
+ * Among several of the same kind the last wins: the fold orders blessings (createdAt, uri), so that
+ * is the space's most recent decision, and it is the one `Withdraw blessing` should name.
+ */
export function blessedRows(view: GoalView): CommunityRow[] {
- const rows = (blessings: Array>, retracted: boolean) =>
- blessings.map((bless) => ({
- uri: bless.value.comment.uri,
+ const live = new Map>()
+ const withdrawn = new Map>()
+ for (const bless of view.blessedComments) live.set(bless.value.comment.uri, bless)
+ for (const bless of view.retractedBlessings) withdrawn.set(bless.value.comment.uri, bless)
+ const rows: CommunityRow[] = []
+ for (const uri of new Set([...live.keys(), ...withdrawn.keys()])) {
+ const bless = live.get(uri) ?? withdrawn.get(uri)
+ if (!bless) continue
+ rows.push({
+ uri,
cid: bless.value.comment.cid,
- did: didOf(bless.value.comment.uri),
+ did: didOf(uri),
body: bless.value.body,
bless,
- retracted,
+ retracted: !live.has(uri),
edited: false,
- }))
- return [...rows(view.blessedComments, false), ...rows(view.retractedBlessings, true)]
+ })
+ }
+ // (createdAt, uri) over the BLESSING, since a row built from a snapshot has no guest timestamp of
+ // its own — enough to make the list stable, and `communityRows` re-sorts once discovery supplies
+ // the real ones.
+ return rows.sort(
+ (left, right) =>
+ (left.bless?.value.createdAt ?? '').localeCompare(right.bless?.value.createdAt ?? '') ||
+ left.uri.localeCompare(right.uri),
+ )
}
/**
diff --git a/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte b/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte
index c38dc39..7484ef1 100644
--- a/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte
+++ b/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte
@@ -14,6 +14,7 @@
import UriChip from '$lib/components/UriChip.svelte'
import Who from '$lib/components/Who.svelte'
import { shortDate } from '$lib/format.js'
+ import { showsCommunity } from '$lib/guests.js'
import { currentSpace } from '$lib/session.svelte.js'
import type { ThreadMessage } from '$lib/replies.js'
import { goalByRoute, unitsOf } from '$lib/space.js'
@@ -34,6 +35,8 @@
const ending = $derived(goal ? endingBadge(goal) : undefined)
/** Whether this space asks non-members for comments — an admin's `setGuestComments` overlay. */
const community = $derived(space.index.guestCommentsEnabled)
+ /** …and whether the section is drawn at all, which the switch alone does not decide. */
+ const section = $derived(goal ? showsCommunity(goal, community) : false)
/** The message the composer is answering, when Reply pointed it at one. */
let replyTo = $state(undefined)
@@ -123,13 +126,14 @@
same record with one more field on it. -->
(replyTo = message)} />
(replyTo = undefined)} />
-
+
+ {#if section}
+
+ {/if}
{#if community}
-
{/if}