2026 10 22 town hall site powers - xopherdeep/do-it-for-the-xp GitHub Wiki

Town Hall Site Powers Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: An administrator of doit.forthexp.com manages the site's posts (news, patch notes, pending topics, held comments, bug reports) from inside the Town Hall, in place on the cards, through the family's existing link.

Architecture: The link learns the account's powers from the site's users/me capabilities and keeps them (store). A pure desk module builds every admin request; the feed gains edit-context readers; a desk composable runs the actions and holds the held comments, the bug reports and the waiting count; the three tab components grow chips and buttons that show only with the power; the compose sheet gains a news kind and an edit kind; a topic approved on a device that can publish goes up published.

Tech Stack: Vue 3 + Ionic 7 (<script setup lang="ts"> components, defineComponent page), Pinia store, jest + @vue/test-utils, Cypress 8, WordPress REST (wp/v2) through townHallFetch.

Spec: docs/superpowers/specs/2026-10-22-town-hall-site-powers-design.md

Global Constraints

  • Chemical X only: chemx test tests/unit/<Name>.spec.ts for the one spec you touch; never chemx typecheck, chemx verify, pnpm test, npx jest or a build while building (the gate runs once after the last task).
  • Read with chemx read <file> --outline or --lines, search with chemx q, never cat whole files.
  • Audit ratchet: every compound boolean is named before use (const isReady = a && b; if (isReady) ...), never inline a && b in an if, a ternary or a template attribute; no em dash in code or strings (the one in signature() is allowed and marked); at most 5 direct use*() calls per function (TownHall.ts already has 5: add nothing there; the desk composable is created inside useTownHallClerk).
  • Sounds, when any: play$fx(key) with keys from src/assets/fx/index.ts. None are added by this plan.
  • Inside the building no Ionic alerts: every question or notice is a reveal prompt from hometownPrompts.ts (noticeOf, clerkNotice).
  • Copy for the admin tools is in the clerk's classic voice only; no catalogue (voice) variants.
  • Exact strings from the spec: Draft chip "Draft", pending topic chip "Waiting", held comment chip "Held", bug chip "New"; buttons "Publish", "Edit", "Bin", "Approve", "Resolve", "Draft", "Save"; menu choices "Write news", "Write a patch note"; bin question "Take it down? It goes to the city's bin; the site can bring it back for thirty days." with "Take it down" and "Leave it"; keys line "The city has taken back your keys. Register again if that's a mistake."; posted line "It's up on the wall already."; limits TITLE_MAX = 100, NEWS_BODY_MAX = 8000.
  • WordPress REST shapes are fixed by the spec's table (section 4): DELETE never carries force; publish is POST .../{id} { status: "publish" }.
  • Branch feat/town-hall-site-powers in its own worktree; ordinary commits there (the shared checkout's private-index rule applies only to the shared checkout); one PR with auto-merge at the end.
  • Data-cy names in this plan are the contract between the components, their specs and the Cypress run; keep them exactly.

Review Focus

  1. A stored link from before this feature (no powers) must read as no powers, never crash, and gain powers on the first good refresh (Task 4 pins it).
  2. A site answer whose capabilities is missing, null or not an object (a Contributor's context=edit answer can omit it) must give no powers, not throw (Task 1).
  3. A 403 on one admin action must hide every tool at once and not be retried in a loop, while a 403 on a hero's own flush keeps today's registration prompt (Task 6 and Task 4).
  4. A draft list and a published list can both hold the same post for a moment after Publish; the News tab must not show it twice (Task 9 reloads the lists after a publish and pins it).
  5. Bin on a published item asks first; "Leave it" must send nothing, and a second tap while the first call is in flight must send nothing (Task 6).

Task 1: The desk module (powers, requests, drafts, the waiting total)

Files:

  • Create: src/lib/services/townhall/townHallDesk.ts
  • Test: tests/unit/townHallDesk.spec.ts

Interfaces:

  • Consumes: PostDraft, TITLE_MAX, charCount from townHallPosts.ts; TownHallPostKind from townHallTypes.ts.
  • Produces (every later task uses these names):
export interface SitePowers { publish: boolean; moderate: boolean; readAt: number }
export const NO_POWERS: SitePowers; // { publish: false, moderate: false, readAt: 0 }
export function powersOf(me: unknown, now: number): SitePowers;
export type DeskItemType = "post" | "topic" | "comment" | "bug";
export type EditableType = "post" | "topic";
export const REST_PATHS: Record<DeskItemType, string>; // post: "wp/v2/posts", topic: "wp/v2/cafeteria_topic", comment: "wp/v2/comments", bug: "wp/v2/compass_bug"
export interface DeskRequest { path: string; method: "POST" | "DELETE"; body?: Record<string, unknown> }
export interface Words { title: string; body: string }
export type NewsCategory = "news" | "patch-notes";
export interface NewsDraft { kind: "news"; title: string; body: string; category: NewsCategory; status: "publish" | "draft" }
export interface EditDraft { kind: "edit"; title: string; body: string }
export type ComposeDraft = PostDraft | NewsDraft | EditDraft;
export type ComposeKind = TownHallPostKind | "news" | "edit";
/** The words a compose sheet opens with: a hero's failed post, or an item's raw words for an edit. */
export interface ComposeWords { title?: string; body: string; board?: TownHallBoardSlug; postId?: number }
export const NEWS_BODY_MAX = 8000;
export function wordsProblem(words: Words): string | null;
export function publishRequest(type: EditableType, id: number): DeskRequest;
export function trashRequest(type: DeskItemType, id: number): DeskRequest;
export function saveRequest(type: EditableType, id: number, words: Words): DeskRequest;
export function createNewsRequest(draft: NewsDraft, patchNotesId: number | null): DeskRequest; // throws Error("The site has no Patch Notes category yet.") for patch-notes with null
export function approveCommentRequest(id: number): DeskRequest;
export function resolveBugRequest(id: number): DeskRequest;
export function isOpenBug(status: string): boolean; // false for "resolved" and "closed"
export interface WaitingCounts { drafts: number | null; topics: number | null; held: number | null; bugs: number | null }
export function waitingTotal(counts: WaitingCounts): number; // null counts as 0
  • Step 1: Write the failing spec tests/unit/townHallDesk.spec.ts
describe("powersOf", () => {
  it("gives an administrator both powers", () => {
    const me = { capabilities: { publish_posts: true, edit_others_posts: true, moderate_comments: true } };
    expect(powersOf(me, 5)).toEqual({ publish: true, moderate: true, readAt: 5 });
  });
  it("gives an author neither: publish needs edit_others_posts too", () => {
    expect(powersOf({ capabilities: { publish_posts: true } }, 5)).toEqual({ publish: false, moderate: false, readAt: 5 });
  });
  it("gives a contributor, an answer without capabilities, a null and a string nothing", () => {
    for (const me of [{ capabilities: { edit_posts: true } }, { name: "Mom" }, { capabilities: null }, null, "no"]) {
      expect(powersOf(me, 5)).toEqual({ publish: false, moderate: false, readAt: 5 });
    }
  });
});
describe("requests", () => {
  it("publishes a draft and a topic", () => {
    expect(publishRequest("post", 7)).toEqual({ path: "wp/v2/posts/7", method: "POST", body: { status: "publish" } });
    expect(publishRequest("topic", 8)).toEqual({ path: "wp/v2/cafeteria_topic/8", method: "POST", body: { status: "publish" } });
  });
  it("bins to the trash, never with force", () => {
    expect(trashRequest("comment", 3)).toEqual({ path: "wp/v2/comments/3", method: "DELETE" });
    expect(trashRequest("bug", 4)).toEqual({ path: "wp/v2/compass_bug/4", method: "DELETE" });
  });
  it("saves words, approves a comment, resolves a bug", () => {
    expect(saveRequest("topic", 8, { title: "T", body: "B" })).toEqual({ path: "wp/v2/cafeteria_topic/8", method: "POST", body: { title: "T", content: "B" } });
    expect(approveCommentRequest(3)).toEqual({ path: "wp/v2/comments/3", method: "POST", body: { status: "approved" } });
    expect(resolveBugRequest(4)).toEqual({ path: "wp/v2/compass_bug/4", method: "POST", body: { bug_status: "resolved" } });
  });
  it("creates news published or as a draft, in the Patch Notes category when picked", () => {
    const draft: NewsDraft = { kind: "news", title: "0.4.0", body: "Doors.", category: "patch-notes", status: "draft" };
    expect(createNewsRequest(draft, 7)).toEqual({ path: "wp/v2/posts", method: "POST", body: { title: "0.4.0", content: "Doors.", status: "draft", categories: [7] } });
    expect(createNewsRequest({ ...draft, category: "news", status: "publish" }, 7).body).toEqual({ title: "0.4.0", content: "Doors.", status: "publish" });
    expect(() => createNewsRequest(draft, null)).toThrow("The site has no Patch Notes category yet.");
  });
});
describe("wordsProblem", () => {
  it("wants a title, then a title under 100, then words, then words under 8,000", () => {
    expect(wordsProblem({ title: " ", body: "x" })).toBe("Give it a title.");
    expect(wordsProblem({ title: "a".repeat(101), body: "x" })).toBe("Keep the title under 100 characters.");
    expect(wordsProblem({ title: "T", body: " " })).toBe("Write something first.");
    expect(wordsProblem({ title: "T", body: "x".repeat(8001) })).toBe("Keep it under 8,000 characters.");
    expect(wordsProblem({ title: "T", body: "🐉".repeat(8000) })).toBeNull();
  });
});
describe("waitingTotal and isOpenBug", () => {
  it("adds what it knows and treats an unknown count as nothing", () => {
    expect(waitingTotal({ drafts: 2, topics: null, held: 3, bugs: 1 })).toBe(6);
    expect(waitingTotal({ drafts: null, topics: null, held: null, bugs: null })).toBe(0);
  });
  it("keeps a bug open until it is resolved or closed", () => {
    expect(["new", "", "in-progress"].map(isOpenBug)).toEqual([true, true, true]);
    expect(["resolved", "closed"].map(isOpenBug)).toEqual([false, false]);
  });
});
  • Step 2: Run it to make sure it fails

Run: chemx test tests/unit/townHallDesk.spec.ts Expected: FAIL, the module does not exist.

  • Step 3: Implement townHallDesk.ts with the signatures above. powersOf reads capabilities only when it is a non-null object; a power is true only when its capability is exactly true. wordsProblem counts with charCount and uses TITLE_MAX. Module comment: "What an administrator's link may do at the Town Hall, and the requests that do it. All pure."

  • Step 4: Run the spec to make sure it passes

Run: chemx test tests/unit/townHallDesk.spec.ts Expected: PASS

  • Step 5: Commit
git add src/lib/services/townhall/townHallDesk.ts tests/unit/townHallDesk.spec.ts
git commit -m "feat(town-hall): the desk module: site powers from capabilities, the admin requests, news and edit drafts"

Task 2: Feed readers in edit context

Files:

  • Modify: src/lib/services/townhall/townHallFeed.ts
  • Test: tests/unit/townHallFeed.spec.ts (extend; it has site(), reply(), post() helpers and ROOT)

Interfaces:

  • Consumes: NewsCategory, EditableType, Words, REST_PATHS, isOpenBug from Task 1; patchNotesCategoryId, toNewsItem, toTopicItem, totalPagesOf already in the file.
  • Produces:
export interface HeldComment extends CommentItem { postId: number }
export interface BugItem { id: number; date: string; title: string; status: string; route: string; environment: string }
export async function fetchDrafts(category: NewsCategory, req?: TownHallReadRequest): Promise<NewsItem[]>;
export async function fetchPendingTopics(boardId: number, req?: TownHallReadRequest): Promise<TopicItem[]>;
export async function fetchHeldComments(req?: TownHallReadRequest): Promise<{ items: HeldComment[]; total: number }>;
export async function fetchBugs(req?: TownHallReadRequest): Promise<BugItem[]>;
export async function fetchWords(type: EditableType, id: number, req?: TownHallReadRequest): Promise<Words>;
export async function countDrafts(req?: TownHallReadRequest): Promise<number>;
export async function countPendingTopics(req?: TownHallReadRequest): Promise<number>;
  • Step 1: Write the failing tests in tests/unit/townHallFeed.spec.ts
describe("the desk's readers", () => {
  it("reads news drafts outside the Patch Notes, and patch-note drafts inside them", async () => {
    const { req, urls } = site([["categories?slug=patch-notes", reply([{ id: 7 }])], ["posts?", reply([post(5)])]]);
    expect((await fetchDrafts("news", req)).map((d) => d.id)).toEqual([5]);
    expect(urls()[1]).toBe(`${ROOT}/wp/v2/posts?status=draft%2Cpending&context=edit&_embed=true&per_page=5&categories_exclude=7`);
    await fetchDrafts("patch-notes", req);
    expect(urls()[3]).toBe(`${ROOT}/wp/v2/posts?status=draft%2Cpending&context=edit&_embed=true&per_page=5&categories=7`);
  });
  it("reads no patch-note drafts while the site has no Patch Notes category", async () => {
    const { req, urls } = site([["categories?slug=patch-notes", reply([])]]);
    expect(await fetchDrafts("patch-notes", req)).toEqual([]);
    expect(urls()).toHaveLength(1);
  });
  it("reads a board's pending topics", async () => {
    const { req, urls } = site([["cafeteria_topic", reply([post(9, { _embedded: { author: [{ name: "Max" }] } })])]]);
    expect((await fetchPendingTopics(11, req)).map((t) => t.author)).toEqual(["Max"]);
    expect(urls()).toEqual([`${ROOT}/wp/v2/cafeteria_topic?status=pending&context=edit&cafeteria_board=11&_embed=true&per_page=10`]);
  });
  it("reads the held comments with the post each answers and the site's total", async () => {
    const { req, urls } = site([["comments", reply([{ id: 3, post: 101, author_name: "Ana", date: "2026-10-02T11:00:00", content: { rendered: "<p>Hi</p>" } }], { "X-WP-Total": "12" })]]);
    expect(await fetchHeldComments(req)).toEqual({ items: [{ id: 3, postId: 101, author: "Ana", date: "2026-10-02T11:00:00", content: "<p>Hi</p>" }], total: 12 });
    expect(urls()).toEqual([`${ROOT}/wp/v2/comments?status=hold&context=edit&per_page=20`]);
  });
  it("reads the open bug reports with Bug Net's fields, newest first, leaving out the resolved", async () => {
    const bug = (id: number, status: string) => ({ ...post(id), bug_status: status, bug_url: "/temple", bug_environment: "App: 1.0" });
    const { req, urls } = site([["compass_bug", reply([bug(1, "new"), bug(2, "resolved")])]]);
    expect(await fetchBugs(req)).toEqual([{ id: 1, date: "2026-10-01T09:30:00", title: "Post 1", status: "new", route: "/temple", environment: "App: 1.0" }]);
    expect(urls()).toEqual([`${ROOT}/wp/v2/compass_bug?status=draft%2Cpending%2Cpublish&context=edit&per_page=10&orderby=date&order=desc`]);
  });
  it("reads an item's raw words for editing", async () => {
    const { req, urls } = site([["cafeteria_topic/8", reply({ id: 8, title: { raw: "T", rendered: "T" }, content: { raw: "B", rendered: "<p>B</p>" } })]]);
    expect(await fetchWords("topic", 8, req)).toEqual({ title: "T", body: "B" });
    expect(urls()).toEqual([`${ROOT}/wp/v2/cafeteria_topic/8?context=edit`]);
  });
  it("counts drafts and pending topics from the site's total header, zero without one", async () => {
    const { req, urls } = site([["posts?", reply([], { "X-WP-Total": "3" })], ["cafeteria_topic", reply([])]]);
    expect(await countDrafts(req)).toBe(3);
    expect(await countPendingTopics(req)).toBe(0);
    expect(urls()).toEqual([`${ROOT}/wp/v2/posts?status=draft%2Cpending&context=edit&per_page=1`, `${ROOT}/wp/v2/cafeteria_topic?status=pending&context=edit&per_page=1`]);
  });
});

Check how townHallFetch encodes a comma in query (read townHallClient.ts --outline then the query lines): if it leaves , as is, change the expected URLs above to status=draft,pending.

  • Step 2: Run it to make sure it fails

Run: chemx test tests/unit/townHallFeed.spec.ts -t "the desk's readers" Expected: FAIL, the readers do not exist.

  • Step 3: Implement the readers in townHallFeed.ts. Add a totalOf(headers) next to totalPagesOf (the X-WP-Total header, 0 when absent). fetchBugs maps bug_status (default "new"), bug_url, bug_environment and filters with isOpenBug. fetchWords reads title.raw ?? title.rendered and content.raw ?? content.rendered.

  • Step 4: Run the whole feed spec

Run: chemx test tests/unit/townHallFeed.spec.ts Expected: PASS

  • Step 5: Commit
git add src/lib/services/townhall/townHallFeed.ts tests/unit/townHallFeed.spec.ts
git commit -m "feat(town-hall): edit-context readers: drafts, pending topics, held comments, bug reports, raw words, counts"

Task 3: Publish on approval, and the published outcome

Files:

  • Modify: src/lib/services/townhall/townHallPosts.ts (requestFor)
  • Modify: src/lib/services/townhall/townHallOutbox.ts (PostedOutcome, postedOutcome)
  • Modify: src/lib/services/hometown/hometownPrompts.ts (POSTED_LINES)
  • Test: tests/unit/townHallPosts.spec.ts, tests/unit/townHallOutbox.spec.ts, tests/unit/HometownPrompts.spec.ts

Interfaces:

  • Produces: requestFor(post: TownHallPost, boardIds: Partial<Record<SiteBoardSlug, number>>, options: { canPublish?: boolean } = {}): PostRequest; PostedOutcome gains "published"; postedOutcome(post, linked, isGameMaster, canPublish = false).

  • Step 1: Write the failing tests

In townHallPosts.spec.ts, inside describe("requestFor"):

it("sends a topic published when the link can publish, pending otherwise", () => {
  expect(requestFor(post(), { "feature-requests": 3 }, { canPublish: true }).body.status).toBe("publish");
  expect(requestFor(post(), { "feature-requests": 3 }, { canPublish: false }).body.status).toBe("pending");
  expect(requestFor(post(), { "feature-requests": 3 }).body.status).toBe("pending");
});
it("leaves a bug report pending whatever the link can do", () => {
  expect(requestFor(post({ kind: "bug" }), {}, { canPublish: true }).body.status).toBe("pending");
});

In townHallOutbox.spec.ts (find its describe("postedOutcome"), add):

it("says a topic or a comment of a link that can publish is up already; a bug and a waiting post are as before", () => {
  const sent = (over: Partial<TownHallPost>) => postedOutcome(aPost({ status: "queued", ...over }), true, true, true);
  expect(sent({ kind: "topic" })).toBe("published");
  expect(sent({ kind: "comment", postId: 7 })).toBe("published");
  expect(sent({ kind: "bug" })).toBe("bug");
  expect(postedOutcome(aPost({ status: "awaiting-gm" }), true, false, true)).toBe("to-gm");
  expect(postedOutcome(aPost({ status: "queued" }), true, true)).toBe("to-site");
});

(use that spec's own post factory name if it is not aPost.) In HometownPrompts.spec.ts:

it("tells a publishing link its post is up already", () => {
  expect(postedNotice("published").lines).toEqual(["*pins it to the wall* It's up on the wall already."]);
});
  • Step 2: Run them to make sure they fail

Run: chemx test tests/unit/townHallPosts.spec.ts -t "can publish" then the other two specs with -t "up already". Expected: FAIL (a type error on the third argument, or the wrong status).

  • Step 3: Implement. requestFor: const status = options.canPublish ? "publish" : "pending"; for the topic body only. postedOutcome: after the !linked return, const isPublishedNow = canPublish && post.kind !== "bug"; if (isPublishedNow) return "published";. POSTED_LINES.published as above.

  • Step 4: Run the three specs

Run: chemx test tests/unit/townHallPosts.spec.ts, chemx test tests/unit/townHallOutbox.spec.ts, chemx test tests/unit/HometownPrompts.spec.ts Expected: PASS each.

  • Step 5: Commit
git add src/lib/services/townhall/townHallPosts.ts src/lib/services/townhall/townHallOutbox.ts src/lib/services/hometown/hometownPrompts.ts tests/unit/townHallPosts.spec.ts tests/unit/townHallOutbox.spec.ts tests/unit/HometownPrompts.spec.ts
git commit -m "feat(town-hall): a link that can publish sends approved topics published, and the clerk says so"

Task 4: Powers on the link (store)

Files:

  • Modify: src/lib/services/townhall/townHallTypes.ts (TownHallLink.powers?: SitePowers)
  • Modify: src/lib/store/stores/townHall.ts
  • Test: tests/unit/townHallStore.spec.ts (extend; it mocks townHallFetch and the database as mockDb, with storedLink(), siteAnswers(), linkedQuery(), expectReturn() helpers)

Interfaces:

  • Consumes: SitePowers, NO_POWERS, powersOf (Task 1); requestFor(..., { canPublish }) (Task 3).

  • Produces on the store: powers: ComputedRef<SitePowers>, canPublish: ComputedRef<boolean>, canModerate: ComputedRef<boolean>, refreshPowers(): Promise<void>, dropPowers(): Promise<void>.

  • Step 1: Write the failing tests in townHallStore.spec.ts

describe("site powers", () => {
  const ADMIN = { name: "Mom", capabilities: { publish_posts: true, edit_others_posts: true, moderate_comments: true } };
  it("keeps the powers the site grants when the link is made", async () => {
    mockDb.getLink.mockResolvedValue(null); mockDb.getPosts.mockResolvedValue([]);
    siteAnswers({ me: ADMIN }); expectReturn();
    const store = useTownHallStore();
    expect(await store.completeLink(linkedQuery())).toBe("linked");
    expect(mockDb.saveLink.mock.calls[0][0].powers).toMatchObject({ publish: true, moderate: true });
    expect(store.canPublish).toBe(true);
  });
  it("reads an older link without powers as none, and refreshes them from the site", async () => {
    mockDb.getLink.mockResolvedValue(storedLink()); mockDb.getPosts.mockResolvedValue([]);
    siteAnswers({ me: ADMIN });
    const store = useTownHallStore();
    await store.init();
    expect(store.powers).toEqual(NO_POWERS);
    await store.refreshPowers();
    expect(store.canPublish).toBe(true);
    expect(store.canModerate).toBe(true);
    expect(requests()[0]).toMatchObject({ path: "wp/v2/users/me", query: { context: "edit" } });
    expect(mockDb.saveLink).toHaveBeenCalledWith(expect.objectContaining({ powers: expect.objectContaining({ publish: true }) }));
  });
  it("keeps the stored powers when the refresh fails", async () => {
    const powers = { publish: true, moderate: true, readAt: 1 };
    mockDb.getLink.mockResolvedValue(storedLink({ powers })); mockDb.getPosts.mockResolvedValue([]);
    siteAnswers({ me: new TownHallError("server") });
    const store = useTownHallStore();
    await store.refreshPowers();
    expect(store.powers).toEqual(powers);
    expect(mockDb.saveLink).not.toHaveBeenCalled();
  });
  it("drops the powers and saves the link without them", async () => {
    mockDb.getLink.mockResolvedValue(storedLink({ powers: { publish: true, moderate: true, readAt: 1 } })); mockDb.getPosts.mockResolvedValue([]);
    const store = useTownHallStore();
    await store.dropPowers();
    expect(store.canPublish).toBe(false);
    expect(mockDb.saveLink.mock.calls[0][0].powers).toMatchObject({ publish: false, moderate: false });
  });
  it("holds a website session's powers in memory only", async () => {
    mockDb.getLink.mockResolvedValue(null); mockDb.getPosts.mockResolvedValue([]);
    window.wpApiSettings = { root: "https://doit.forthexp.com/wp-json/", nonce: "n1" } as never;
    siteAnswers({ me: ADMIN });
    const store = useTownHallStore();
    await store.refreshPowers();
    expect(store.canPublish).toBe(true);
    expect(mockDb.saveLink).not.toHaveBeenCalled();
    delete (window as { wpApiSettings?: unknown }).wpApiSettings;
  });
  it("does nothing for a guest", async () => {
    mockDb.getLink.mockResolvedValue(null); mockDb.getPosts.mockResolvedValue([]);
    const store = useTownHallStore();
    await store.refreshPowers();
    expect(fetchMock).not.toHaveBeenCalled();
  });
  it("sends an approved topic published when the link can publish", async () => {
    mockDb.getLink.mockResolvedValue(storedLink({ powers: { publish: true, moderate: true, readAt: 1 } }));
    mockDb.getPosts.mockResolvedValue([aPost({ status: "queued" })]);
    fetchMock.mockImplementation(async (path: string) => ({ data: path === BOARDS_PATH ? [{ id: 3, slug: "feature-requests" }] : { id: 77 }, headers: { get: () => null } }));
    const store = useTownHallStore();
    await store.flush();
    expect(requests().find((r) => r.path === "wp/v2/cafeteria_topic")?.body.status).toBe("publish");
  });
});

Match the file's existing beforeEach (it sets the Pinia and resets mocks); read the first 120 lines with chemx read tests/unit/townHallStore.spec.ts --lines 1-120 before writing.

  • Step 2: Run it to make sure it fails

Run: chemx test tests/unit/townHallStore.spec.ts -t "site powers" Expected: FAIL.

  • Step 3: Implement in the store: sessionPowers = ref<SitePowers | null>(null); powers = computed(() => activeLink.value?.powers ?? sessionPowers.value ?? NO_POWERS); canPublish, canModerate. In checkAndStore, read me.data whole and set powers: powersOf(me.data, Date.now()) on the new link. refreshPowers(): await init(); return when auth.value.kind === "none"; fetch wp/v2/users/me with query: { context: "edit" }; on success save { ...activeLink, powers } through the db and link.value, or set sessionPowers; catch everything and debug.warn. dropPowers(): same two branches with NO_POWERS and readAt: Date.now(). In sendOne: requestFor(post, run.boardIds ?? {}, { canPublish: canPublish.value }). Export the five new members from the store's return.

  • Step 4: Run the whole store spec

Run: chemx test tests/unit/townHallStore.spec.ts Expected: PASS

  • Step 5: Commit
git add src/lib/services/townhall/townHallTypes.ts src/lib/store/stores/townHall.ts tests/unit/townHallStore.spec.ts
git commit -m "feat(town-hall): the link keeps the site's powers, refreshed each visit and dropped on a refusal"

Task 5: The clerk's new lines and menu choices

Files:

  • Modify: src/lib/services/hometown/hometownPrompts.ts
  • Test: tests/unit/HometownPrompts.spec.ts

Interfaces:

  • Produces: ClerkMenuId gains 'write-news' | 'write-patch-note'; CLERK_ERRANDS gains 'write-news': 'Writing the news', 'write-patch-note': 'Writing a patch note'; clerkMenuChoices(isGameMaster: boolean, canPublish = false); clerkMenuPrompt(isGameMaster: boolean, voice: Voice = 'adventure', canPublish = false); binPrompt(): RevealPromptOptions (choices take-down icon trash tone primary, leave icon times tone ghost, cancelId: 'leave'); keysTakenNotice(): RevealPromptOptions; type DeskOutcome = 'published' | 'drafted' | 'saved'; deskNotice(outcome: DeskOutcome): RevealPromptOptions.

  • Step 1: Write the failing tests

describe("the clerk's desk", () => {
  it("offers the writers only to a link that can publish, after the hero's errands", () => {
    expect(clerkMenuChoices(true).map((c) => c.id)).toEqual(["board", "bug", "registration", "never"]);
    expect(clerkMenuChoices(true, true).map((c) => c.id)).toEqual(["board", "bug", "registration", "write-news", "write-patch-note", "never"]);
    expect(clerkMenuChoices(false, true).map((c) => c.label)).toContain("Write news");
    expect(clerkMenuChoices(false, true).find((c) => c.id === "write-patch-note")).toMatchObject({ label: "Write a patch note", icon: "pen" });
    expect(clerkMenuPrompt(true, "adventure", true).choices.map((c) => c.id)).toContain("write-news");
  });
  it("asks once before taking something down", () => {
    const prompt = binPrompt();
    expect(prompt.lines).toEqual(["Take it down? It goes to the city's bin; the site can bring it back for thirty days."]);
    expect(prompt.choices.map((c) => [c.id, c.label])).toEqual([["take-down", "Take it down"], ["leave", "Leave it"]]);
    expect(prompt.cancelId).toBe("leave");
  });
  it("says when the keys are taken back and what became of a desk job", () => {
    expect(keysTakenNotice().lines).toEqual(["The city has taken back your keys. Register again if that's a mistake."]);
    expect(deskNotice("published").lines).toEqual(["*pins it to the wall* It's up for everyone."]);
    expect(deskNotice("drafted").lines).toEqual(["*files it* Kept as a draft on the site."]);
    expect(deskNotice("saved").lines).toEqual(["*stamps it* Saved."]);
  });
});
  • Step 2: Run it to make sure it fails

Run: chemx test tests/unit/HometownPrompts.spec.ts -t "the clerk's desk" Expected: FAIL.

  • Step 3: Implement next to clerkMenuChoices; the writers are inserted before never; prompts use clerkNotice and the clerk's speaker: CLERK, accent: 'townHall', sprite: 'tablet' shape as courierLatePrompt does.

  • Step 4: Run the spec

Run: chemx test tests/unit/HometownPrompts.spec.ts Expected: PASS

  • Step 5: Commit
git add src/lib/services/hometown/hometownPrompts.ts tests/unit/HometownPrompts.spec.ts
git commit -m "feat(town-hall): the clerk offers the writers to a publishing link and has lines for the desk"

Task 6: The desk composable

Files:

  • Create: src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallDesk.ts
  • Test: tests/unit/useTownHallDesk.spec.ts (rig it like useTownHallPosting.spec.ts: a fakeReveal(), a fake store, jest.mock of townHallFeed readers and of townHallClient's townHallFetch)

Interfaces:

  • Consumes: Task 1 requests and types; Task 2 readers (fetchHeldComments, fetchBugs, countDrafts, countPendingTopics, fetchWords, patchNotesCategoryId); Task 5 prompts (binPrompt, keysTakenNotice, courierLatePrompt); townHallFetch, TownHallError; isCourierTrouble from useTownHallLoad.ts.
  • Produces:
export interface DeskStore { auth: TownHallAuth; canPublish: boolean; canModerate: boolean; dropPowers(): Promise<void> }
export interface DeskDeps { store: DeskStore; reveal: { showPrompt(options: RevealPromptOptions): Promise<string>; prompt: Ref<unknown> } }
export interface DeskActions {
  publish(type: EditableType, id: number): Promise<boolean>;
  bin(type: DeskItemType, id: number, published: boolean): Promise<boolean>;
  approve(commentId: number): Promise<boolean>;
  resolve(bugId: number): Promise<boolean>;
  isBusy(type: DeskItemType, id: number): boolean;
  troubleOf(type: DeskItemType, id: number): string | null;
}
export function useTownHallDesk(deps: DeskDeps): DeskActions & {
  heldComments: Ref<HeldComment[]>;
  bugs: Ref<BugItem[]>;
  waitingCount: Ref<number>;
  patchNotesAvailable: Ref<boolean>;
  refresh(): Promise<void>;
  createNews(draft: NewsDraft): Promise<string | null>;   // the problem, or null when the site took it
  saveEdit(target: { type: EditableType; id: number }, words: Words): Promise<string | null>;
  readWords(type: EditableType, id: number): Promise<Words | null>;
};

Rules: every action is one townHallFetch(request.path, { auth: store.auth, method, body }). isBusy keys on ${type}:${id}; a tap while busy returns false and sends nothing. On TownHallError with code forbidden: await store.dropPowers(), show keysTakenNotice(), return false. On isCourierTrouble(error): show courierLatePrompt(); retry runs the same action once more, never returns false. On code invalid: troubleOf returns the error's message for that item; return false. On unauthorized: return false and leave the store's own 401 handling to the next flush (do not drop powers). bin(..., published: true) shows binPrompt() first and returns false on leave. approve and bin("comment", ...) remove the comment from heldComments and decrement waitingCount; resolve and bin("bug", ...) remove the bug from bugs and decrement. refresh(): when neither power, clears everything and returns; with canPublish: countDrafts, countPendingTopics, fetchBugs, patchNotesCategoryId; with canModerate: fetchHeldComments; each read is its own try/catch (a failed read counts null); waitingCount = waitingTotal({ drafts, topics, held: held?.total ?? null, bugs: bugs?.length ?? null }). createNews builds the request with the id from patchNotesCategoryId (read fresh) and answers the thrown message when it throws; saveEdit uses saveRequest; both answer null on success and the site's message (invalid) or "That didn't reach the site. Try again." on any other failure, after the forbidden/courier handling above.

  • Step 1: Write the failing spec tests/unit/useTownHallDesk.spec.ts with these cases (assert on townHallFetch calls, the reveal's asked prompts and the refs):

    • "publishes a draft with one request and says nothing" (publish("post", 7) → fetch called with ("wp/v2/posts/7", { auth, method: "POST", body: { status: "publish" } }), resolves true, no prompt).
    • "sends nothing while the same item is busy" (two concurrent publish calls → one fetch).
    • "asks before binning something published, and leaves it on Leave it" (bin("post", 7, true), answer leave → no fetch, false; answer take-down → DELETE wp/v2/posts/7).
    • "bins a draft without asking" (bin("topic", 8, false) → one DELETE, no prompt).
    • "drops the powers and tells about the keys on a refusal" (fetch rejects new TownHallError("forbidden") → store.dropPowers called once, keysTakenNotice asked, false, and a second publish after it still sends one request: no loop).
    • "tries once more when the courier is late and the admin asks" (first fetch rejects new TownHallError("offline"), reveal answers retry, second resolves → true, two fetches).
    • "keeps the site's complaint on the item" (rejects new TownHallError("invalid", "Too short") → false, troubleOf("post", 7) is "Too short").
    • "approving a held comment takes it off the list and the count" (after refresh with one held comment and waitingCount 1 → approve(3) → POST wp/v2/comments/3 {status:"approved"}, heldComments empty, waitingCount 0).
    • "resolving a bug takes it off the Bug Net card" (same shape with resolve(4)).
    • "refresh counts drafts, topics, held comments and open bugs for a publishing moderator, and nothing for a contributor".
    • "creates news in the Patch Notes when the site has them, and names the problem when it has not" (createNews with patchNotesCategoryId mocked 7 → request body has categories: [7], answers null; mocked null → answers "The site has no Patch Notes category yet.", no fetch).
    • "reads the words for an edit and answers null when the courier is late".
  • Step 2: Run it to make sure it fails

Run: chemx test tests/unit/useTownHallDesk.spec.ts Expected: FAIL, the composable does not exist.

  • Step 3: Implement useTownHallDesk.ts per the rules above. Keep one private act(request: DeskRequest, key: string): Promise<{ ok: true } | { ok: false; message?: string }> that every action uses, so the forbidden, courier, invalid and busy rules live once.

  • Step 4: Run the spec

Run: chemx test tests/unit/useTownHallDesk.spec.ts Expected: PASS

  • Step 5: Commit
git add src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallDesk.ts tests/unit/useTownHallDesk.spec.ts
git commit -m "feat(town-hall): the desk composable: one call per admin action, the held comments, the bugs and the waiting count"

Task 7: The compose sheet writes news and edits

Files:

  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallCompose.vue
  • Test: tests/unit/TownHallCompose.spec.ts (extend; compose(), cy(), sendButton(), sent() helpers exist)

Interfaces:

  • Consumes: ComposeKind, ComposeDraft, NewsCategory, NEWS_BODY_MAX, wordsProblem (Task 1).

  • Produces: props kind: ComposeKind; initial?: ComposeWords (Task 1; the type widened from PostDraft, which still fits it); patchNotesAvailable?: boolean (default false); newsCategory?: NewsCategory (default "news"); emit send(draft: ComposeDraft). Data-cy: th-pick-news, th-pick-patch-notes, th-save-draft, th-send (unchanged name for Publish/Save/Send).

  • Step 1: Write the failing tests

describe("news and edits", () => {
  it("writes news with a title, 8,000 characters, and two sends: Draft and Publish", async () => {
    const wrapper = compose({ kind: "news" });
    expect(wrapper.find("ion-title").text()).toBe("Write news");
    expect(cy(wrapper, "th-count").text()).toBe("0 / 8000");
    expect(cy(wrapper, "th-board-picker").exists()).toBe(false);
    expect(cy(wrapper, "th-pick-news").exists()).toBe(false);
    await cy(wrapper, "th-title").setValue(" Harvest ");
    await cy(wrapper, "th-body").setValue("Bring chores.");
    await cy(wrapper, "th-save-draft").trigger("click");
    await sendButton(wrapper).trigger("click");
    expect(sent(wrapper)).toEqual([
      { kind: "news", title: "Harvest", body: "Bring chores.", category: "news", status: "draft" },
      { kind: "news", title: "Harvest", body: "Bring chores.", category: "news", status: "publish" },
    ]);
    expect(sendButton(wrapper).text()).toContain("Publish");
  });
  it("offers Patch Notes only when the site has them, and starts on the category it was opened for", async () => {
    const wrapper = compose({ kind: "news", patchNotesAvailable: true, newsCategory: "patch-notes" });
    expect(cy(wrapper, "th-pick-patch-notes").attributes("aria-checked")).toBe("true");
    await cy(wrapper, "th-pick-news").trigger("click");
    await cy(wrapper, "th-title").setValue("T");
    await cy(wrapper, "th-body").setValue("B");
    await sendButton(wrapper).trigger("click");
    expect(sent(wrapper)[0]).toMatchObject({ category: "news" });
  });
  it("wants a title for news before it lets anything go", async () => {
    const wrapper = compose({ kind: "news" });
    await cy(wrapper, "th-body").setValue("B");
    expect(isDisabled(wrapper)).toBe(true);
    expect(cy(wrapper, "th-problem").text()).toBe("Give it a title.");
  });
  it("edits the words it was given, with one Save and no picker", async () => {
    const wrapper = compose({ kind: "edit", initial: { title: "Old", body: "Words" } });
    expect(wrapper.find("ion-title").text()).toBe("Edit");
    expect(cy(wrapper, "th-board-picker").exists()).toBe(false);
    expect(cy(wrapper, "th-save-draft").exists()).toBe(false);
    expect(sendButton(wrapper).text()).toContain("Save");
    await cy(wrapper, "th-title").setValue("New");
    await sendButton(wrapper).trigger("click");
    expect(sent(wrapper)).toEqual([{ kind: "edit", title: "New", body: "Words" }]);
  });
});
  • Step 2: Run them to make sure they fail

Run: chemx test tests/unit/TownHallCompose.spec.ts -t "news and edits" Expected: FAIL.

  • Step 3: Implement. HEADINGS and PLACEHOLDERS become Record<ComposeKind, string> (news: "Write news" / "What's new in the city?", edit: "Edit" / ""). max: news and edit → NEWS_BODY_MAX. draft becomes a computed<ComposeDraft> by kind: news → { kind: "news", title, body, category: pickedCategory, status } where status is set by which button was tapped (send("publish") / send("draft")); edit → { kind: "edit", title, body }; else as today. problem: news or edit → wordsProblem({ title, body }), else draftProblem. The dock: the category picker (two th-compose__board buttons, role="radio", aria-checked) when kind === "news" and patchNotesAvailable; the Draft button (th-save-draft, class th-compose__send is-secondary, outline look: transparent background, cream text) before the main button when kind === "news"; the main button's label by kind: news "Publish", edit "Save", else "Send" (sending: "Sending...").

  • Step 4: Run the whole compose spec

Run: chemx test tests/unit/TownHallCompose.spec.ts Expected: PASS

  • Step 5: Commit
git add src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallCompose.vue tests/unit/TownHallCompose.spec.ts
git commit -m "feat(town-hall): the compose sheet writes news (Draft or Publish, News or Patch Notes) and edits an item's words"

Task 8: The clerk's counter: writers, edits and the desk

Files:

  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallPosting.ts
  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallClerk.ts
  • Test: tests/unit/useTownHallPosting.spec.ts, tests/unit/useTownHallClerk.spec.ts (extend; the clerk spec mocks useTownHallPosting, useTownHallLink and registrationOffer and will need a mock of useTownHallDesk)

Interfaces:

  • Consumes: Task 5 prompts and menu ids; Task 6 useTownHallDesk; Task 1 types; Task 3 postedOutcome(..., canPublish).

  • Produces:

    • PostingStore gains canPublish: boolean (the one source for the menu and for the posted outcome).
    • PostingDeps gains desk: { createNews(draft: NewsDraft): Promise<string | null>; saveEdit(target: { type: EditableType; id: number }, words: Words): Promise<string | null>; readWords(type: EditableType, id: number): Promise<Words | null>; changed(what: NewsCategory | EditableType): void }.
    • ComposeSpec gains newsCategory?: NewsCategory and editing?: { type: EditableType; id: number }; kind: ComposeKind; initial?: ComposeWords (Task 1; openEdit drops kind from the literal it builds).
    • useTownHallPosting returns, in addition: openNews(category: NewsCategory), openSiteEdit(target: { type: EditableType; id: number }): Promise<void> (reads the words through deps.desk.readWords; opens nothing when null, the desk has already said the courier is late), and sendDraft(draft: ComposeDraft).
    • TownHallClerkOptions gains reloadTab(id: "news" | "board" | "updates"): Promise<void>; useTownHallClerk returns desk (the Task 6 composable) as well, and completeLinkReturn runs await store.refreshPowers(); await desk.refresh(); before the queue is sent. The clerk's desk.changed maps: "news" → reload news; "patch-notes" → reload updates; "post" → reload news and updates; "topic" → reload board.
  • Step 1: Write the failing tests

In useTownHallPosting.spec.ts (extend fakeStore with canPublish: false; rig({ canPublish, desk }) sets the fake store's canPublish and merges desk over jest-fn defaults createNews → null, saveEdit → null, readWords → { title: "Old", body: "W" }, changed; the rig exposes desk):

describe("the writers", () => {
  it("offers Write news and Write a patch note only to a publishing link, and opens the sheet on them", async () => {
    const r = rig({ canPublish: true });
    const opening = r.posting.openClerkMenu();
    expect(r.reveal.asked[0].choices.map((c) => c.id)).toContain("write-patch-note");
    r.reveal.answer("write-patch-note");
    await opening;
    expect(r.posting.composeSpec.value).toEqual({ kind: "news", newsCategory: "patch-notes" });
    expect(r.posting.composeOpen.value).toBe(true);
  });
  it("sends news to the desk, closes the sheet, says what became of it and refreshes the tab", async () => {
    const r = rig({ canPublish: true });
    r.posting.openNews("news");
    const sending = r.posting.sendDraft({ kind: "news", title: "T", body: "B", category: "news", status: "draft" });
    await flushPromises();
    expect(r.desk.createNews).toHaveBeenCalledWith({ kind: "news", title: "T", body: "B", category: "news", status: "draft" });
    expect(r.posting.composeOpen.value).toBe(false);
    expect(r.reveal.asked.at(-1)).toEqual(deskNotice("drafted"));
    r.reveal.answer();
    await sending;
    expect(r.desk.changed).toHaveBeenCalledWith("news");
  });
  it("keeps the sheet open with the desk's problem", async () => {
    const r = rig({ canPublish: true, desk: { createNews: jest.fn(async () => "The site has no Patch Notes category yet.") } });
    r.posting.openNews("patch-notes");
    await r.posting.sendDraft({ kind: "news", title: "T", body: "B", category: "patch-notes", status: "publish" });
    expect(r.posting.composeOpen.value).toBe(true);
    expect(r.posting.trouble.value).toBe("The site has no Patch Notes category yet.");
  });
  it("opens an edit with the item's words and saves them back", async () => {
    const r = rig({ canPublish: true, desk: { readWords: jest.fn(async () => ({ title: "Old", body: "W" })) } });
    await r.posting.openSiteEdit({ type: "topic", id: 8 });
    expect(r.posting.composeSpec.value).toEqual({ kind: "edit", editing: { type: "topic", id: 8 }, initial: { title: "Old", body: "W" } });
    const saving = r.posting.sendDraft({ kind: "edit", title: "New", body: "W" });
    await flushPromises();
    expect(r.desk.saveEdit).toHaveBeenCalledWith({ type: "topic", id: 8 }, { title: "New", body: "W" });
    expect(r.reveal.asked.at(-1)).toEqual(deskNotice("saved"));
    r.reveal.answer();
    await saving;
    expect(r.desk.changed).toHaveBeenCalledWith("topic");
  });
  it("opens nothing when the words cannot be read", async () => {
    const r = rig({ canPublish: true, desk: { readWords: jest.fn(async () => null) } });
    await r.posting.openSiteEdit({ type: "post", id: 5 });
    expect(r.posting.composeOpen.value).toBe(false);
  });
  it("tells a publishing link its topic is up already", async () => {
    const r = rig({ canPublish: true });
    r.posting.openTopic("feature-requests");
    const sending = r.posting.sendDraft(topicDraft);
    await flushPromises();
    expect(r.reveal.asked.at(-1)).toEqual(postedNotice("published"));
    r.reveal.answer();
    await sending;
  });
});

In useTownHallClerk.spec.ts: mock useTownHallDesk to return { refresh: mockDeskRefresh, changed: undefined } plus whatever the clerk reads; add store.refreshPowers = jest.fn(), reloadTab = jest.fn() to build(); cases: "refreshes the powers and the desk before sending the queue on a return" (order of calls: refreshPowers, mockDeskRefresh, mockSendQueue), "reloads the tabs a desk change touches" (call the desk.changed the clerk handed to posting through mockPostingOptions.desk.changed("post") → reloadTab called with "news" and "updates"; "topic" → "board"; "patch-notes" → "updates").

  • Step 2: Run them to make sure they fail

Run: chemx test tests/unit/useTownHallPosting.spec.ts -t "the writers" and chemx test tests/unit/useTownHallClerk.spec.ts Expected: FAIL.

  • Step 3: Implement. In useTownHallPosting: clerkMenuPrompt(deps.isGameMaster(), voice.value, store.canPublish); the two new picks; openNews; openSiteEdit; in sendDraft, branch first on draft.kind === "news" and "edit": call the desk, on a problem set trouble and keep the sheet open, else close, await reveal.showPrompt(deskNotice(outcome)) (published when status === "publish", drafted for a draft, saved for an edit), then deps.desk.changed(what) where what is the news category or the edited type; the hero path is unchanged except postedOutcome(post, store.isLinked, deps.isGameMaster(), store.canPublish). In useTownHallClerk: const desk = useTownHallDesk({ store, reveal }); pass desk (with changed built from options.reloadTab) into the posting deps; extend completeLinkReturn; return desk.

  • Step 4: Run both specs whole

Run: chemx test tests/unit/useTownHallPosting.spec.ts then chemx test tests/unit/useTownHallClerk.spec.ts Expected: PASS

  • Step 5: Commit
git add src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallPosting.ts src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallClerk.ts tests/unit/useTownHallPosting.spec.ts tests/unit/useTownHallClerk.spec.ts
git commit -m "feat(town-hall): the clerk writes news and patch notes, edits the site's words and refreshes the tabs the desk changed"

Task 9: Held comments under their cards, and the News tab's tools

Files:

  • Create: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallHeldComments.vue
  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallNews.vue
  • Test: tests/unit/TownHallHeldComments.spec.ts, tests/unit/TownHallNews.spec.ts (new; mount like TownHallWaiting.spec.ts, mock townHallFeed's fetchNews, fetchDrafts, fetchComments)

Interfaces:

  • Consumes: DeskActions (Task 6), HeldComment, fetchDrafts (Task 2), EditableType.

  • Produces:

    • TownHallHeldComments.vue: props { comments: HeldComment[]; desk: DeskActions }; renders nothing with no comments; else a block data-cy="th-held" with one data-cy="th-held-comment" per comment (author, date, sanitized content, chip "Held" data-cy="th-held-chip"), buttons th-approve ("Approve", calls desk.approve(id)) and th-bin-comment ("Bin", calls desk.bin("comment", id, false)), both disabled while desk.isBusy("comment", id), and the item's desk.troubleOf("comment", id) under it when set.
    • TownHallNews.vue props: canPublish?: boolean, canModerate?: boolean, heldComments?: HeldComment[] (default []), desk?: DeskActions; emits add edit(target: { type: EditableType; id: number }). With canPublish: drafts read by fetchDrafts("news") in reload (its own try/catch: a failed draft read leaves drafts empty and never marks the tab failed), rendered first with class th-post is-draft, data-cy="th-draft", chip "Draft" (data-cy="th-chip"), buttons th-publish ("Publish" → desk.publish("post", id) then reload() on true), th-edit ("Edit" → emit("edit", { type: "post", id })), th-bin ("Bin" → desk.bin("post", id, false) then remove from drafts on true); published cards gain th-edit and th-bin (desk.bin("post", id, true), remove from items on true). With canModerate: the comments toggle reads Comments (N held) when heldFor(id).length > 0, and <town-hall-held-comments> renders under the card's actions (always, not only when the comments are open). Every desk button is disabled while desk.isBusy("post", id); the item's trouble shows under the actions as data-cy="th-trouble".
  • Step 1: Write the failing specs

TownHallHeldComments.spec.ts: "draws nothing without held comments"; "lists each held comment with its chip and approves or bins it through the desk" (calls asserted on a fake desk with jest fns); "waits while the desk is busy with that comment and shows its trouble".

TownHallNews.spec.ts: "shows no tools to a hero" (no th-draft, no th-edit, no th-bin, no th-held, fetchDrafts not called); "shows drafts first with Publish, Edit and Bin to a publishing link, and publishing one reloads the news" (fetchDrafts resolves one draft, fetchNews two posts; after th-publish click with desk.publish resolving true, fetchNews called twice and fetchDrafts twice); "a bin on a published post asks the desk with published true and the card goes on true"; "counts and lists the held comments under their post for a moderator" (heldComments with postId 101 → the first card's toggle text contains "(1 held)" and th-held-comment exists inside that card; the second card has none).

  • Step 2: Run them to make sure they fail

Run: chemx test tests/unit/TownHallHeldComments.spec.ts and chemx test tests/unit/TownHallNews.spec.ts Expected: FAIL.

  • Step 3: Implement both components. Chip style: reuse the look of TownHallWaiting.vue's .th-waiting__status (copy its rules into a shared .th-chip block in _TownHall.scss with is-draft, is-pending, is-held, is-new colours: draft $eb-color-pale-yellow, pending $eb-color-minty-blue, held #ffb4b4, new #ffd27f; text $eb-color-dark-purple). Buttons are ion-button size="small" color="rpg", Bin fill="outline", in the existing th-post__actions row.

  • Step 4: Run both specs

Run: chemx test tests/unit/TownHallHeldComments.spec.ts and chemx test tests/unit/TownHallNews.spec.ts Expected: PASS

  • Step 5: Commit
git add src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallHeldComments.vue src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallNews.vue src/app/Console/MyPortal/HomeTown/TownHall/_TownHall.scss tests/unit/TownHallHeldComments.spec.ts tests/unit/TownHallNews.spec.ts
git commit -m "feat(town-hall): News shows drafts first with Publish, Edit and Bin, and held comments under their post"

Task 10: The Board tab's tools

Files:

  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallBoard.vue
  • Test: tests/unit/TownHallBoard.spec.ts (new; mock fetchBoards, fetchTopics, fetchPendingTopics)

Interfaces:

  • Consumes: Task 6 DeskActions, Task 2 fetchPendingTopics, Task 9 TownHallHeldComments.vue.

  • Produces: props canPublish?: boolean, canModerate?: boolean, heldComments?: HeldComment[], desk?: DeskActions; emit edit(target: { type: EditableType; id: number }) (the existing edit emit carries a TownHallPost for the hero's failed post; rename the new one edit-site to keep both: (event: "edit-site", target: { type: EditableType; id: number })). With canPublish: pending read by fetchPendingTopics(board.id) with the topics (own try/catch, same stale-ticket guard), rendered between the board's About card and the published topics, class th-topic is-pending, data-cy="th-pending-topic", chip "Waiting", buttons th-publish (→ desk.publish("topic", id) then reload()), th-edit (→ emit("edit-site", { type: "topic", id })), th-bin (→ desk.bin("topic", id, false), remove on true); published topics gain th-edit and th-bin (published: true). With canModerate: <town-hall-held-comments> under each topic that has held comments, and the Reply button reads "Reply (N held)".

  • Step 1: Write the failing spec TownHallBoard.spec.ts: "shows a hero the board as before: no pending read, no tools"; "shows a publishing link the waiting topics first with Publish, Edit and Bin, and switching boards reads that board's pending ones" (fetchPendingTopics called with 11 then 12 after the boardSlug prop changes); "emits edit-site for a topic and asks the desk to bin a published one"; "lists held comments under their topic for a moderator".

  • Step 2: Run it to make sure it fails

Run: chemx test tests/unit/TownHallBoard.spec.ts Expected: FAIL.

  • Step 3: Implement in TownHallBoard.vue.

  • Step 4: Run the spec

Run: chemx test tests/unit/TownHallBoard.spec.ts Expected: PASS

  • Step 5: Commit
git add src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallBoard.vue tests/unit/TownHallBoard.spec.ts
git commit -m "feat(town-hall): the Board shows pending topics first with Publish, Edit and Bin, and held replies under their topic"

Task 11: The Updates tab's tools and the Bug Net card

Files:

  • Create: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallBugNet.vue
  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallUpdates.vue
  • Test: tests/unit/TownHallBugNet.spec.ts, tests/unit/TownHallUpdates.spec.ts (new; mock fetchPatchNotes, fetchDrafts)

Interfaces:

  • Consumes: BugItem (Task 2), DeskActions (Task 6).

  • Produces:

    • TownHallBugNet.vue: props { bugs: BugItem[]; desk: DeskActions }; an ion-card data-cy="th-bugnet" titled "Bug Net" with "No bug reports waiting." when empty, else one data-cy="th-bug" per bug: title (sanitized), date (formatTownDate), chip "New" when status === "new" else the status as given, the route, the environment's first line; buttons th-resolve ("Resolve" → desk.resolve(id)) and th-bin ("Bin" → desk.bin("bug", id, false)), disabled while busy, trouble under them.
    • TownHallUpdates.vue props: canPublish?: boolean, bugs?: BugItem[] (default []), desk?: DeskActions; emit edit-site(target). With canPublish: drafts by fetchDrafts("patch-notes") rendered first with the Draft chip and th-publish / th-edit / th-bin as in Task 9; published notes gain th-edit and th-bin (published: true); <town-hall-bug-net> renders between the notes and Game Statistics.
  • Step 1: Write the failing specs: TownHallBugNet.spec.ts ("says when nothing waits"; "lists a bug with its chip, route and first environment line and resolves or bins it through the desk"); TownHallUpdates.spec.ts ("shows a hero the notes and the statistics only"; "shows a publishing link the patch-note drafts first and the Bug Net card"; "publishing a draft reloads the notes").

  • Step 2: Run them to make sure they fail

Run: chemx test tests/unit/TownHallBugNet.spec.ts and chemx test tests/unit/TownHallUpdates.spec.ts Expected: FAIL.

  • Step 3: Implement both.

  • Step 4: Run both specs

Expected: PASS

  • Step 5: Commit
git add src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallBugNet.vue src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallUpdates.vue tests/unit/TownHallBugNet.spec.ts tests/unit/TownHallUpdates.spec.ts
git commit -m "feat(town-hall): Updates shows patch-note drafts first and a Bug Net card with Resolve and Bin"

Task 12: The page: wiring, the clerk's count, Cypress, rehearsal

Files:

  • Modify: src/app/Console/MyPortal/HomeTown/TownHall/TownHall.vue, TownHall.ts, _TownHall.scss
  • Modify: tests/e2e/specs/town-hall.spec.js
  • Test: tests/unit/HomeTown.spec.ts only if it mounts the Town Hall page (read its outline first); otherwise the Cypress run is this task's test.

Interfaces:

  • Consumes: everything above. TownHall.ts keeps its five use* calls: the desk comes back from useTownHallClerk (Task 8).

  • Step 1: Wire the page. In TownHall.ts: pass reloadTab: (id) => tabRefs[id].value?.reload() ?? Promise.resolve() into useTownHallClerk (declare tabRefs before the clerk, or pass a function that reads it lazily); return canPublish: computed(() => townHall.canPublish), canModerate: computed(() => townHall.canModerate), heldComments: clerk.desk.heldComments, bugs: clerk.desk.bugs, waitingCount: clerk.desk.waitingCount, patchNotesAvailable: clerk.desk.patchNotesAvailable, desk: clerk.desk, openSiteEdit: clerk.posting.openSiteEdit. In TownHall.vue: <town-hall-news ... :can-publish="canPublish" :can-moderate="canModerate" :held-comments="heldComments" :desk="desk" @edit="openSiteEdit" />; the board gets the same plus @edit-site="openSiteEdit" (its @edit stays for the hero's failed post); updates gets :can-publish :bugs :desk @edit-site; the compose gets :patch-notes-available="patchNotesAvailable" :news-category="composeSpec.newsCategory"; inside the clerk's ion-fab-button, <ion-badge v-if="waitingCount > 0" class="th-clerk__count" data-cy="th-clerk-count">{{ waitingCount }}</ion-badge>. In _TownHall.scss: .th-clerk__count absolutely placed at the button's top right (position: absolute; top: -4px; right: -4px;), background $eb-color-pale-yellow, colour $eb-color-dark-purple, font-family: $eb-font-status, min width 20px, round.

  • Step 2: Extend the Cypress spec tests/e2e/specs/town-hall.spec.js. In stubSite() nothing changes (a hero's run must make none of the new reads: assert cy.get("@drafts.all").should("have.length", 0) in the existing News case after adding the stub below as a top-level alias). Add a describe("The owner's desk") inside the registration describe that uses the return-path rig (stubLinkSite, returnPath, forgetDeviceLink), with the me stub answering json({ name: "Mom", capabilities: { publish_posts: true, edit_others_posts: true, moderate_comments: true } }), and these stubs:

    • GET **/wp/v2/posts?status=draft* → json([post(103, "Draft: Winter Market", "Soon.")], { "X-WP-Total": "1" }) as drafts;
    • GET **/wp/v2/cafeteria_topic?status=pending* → json([topic(303, "Pending: pet dragon", "Please.", "Max")], { "X-WP-Total": "1" }) as pending;
    • GET **/wp/v2/comments?status=hold* → json([{ id: 402, post: 101, author_name: "Ana", date: "2026-10-02T11:00:00", content: { rendered: "<p>Held words</p>" } }], { "X-WP-Total": "1" }) as held;
    • GET **/wp/v2/compass_bug* → json([{ ...post(501, "Dragon stuck", "It is stuck."), bug_status: "new", bug_url: "/temple", bug_environment: "App: dev" }]) as bugs;
    • POST **/wp/v2/posts/103 → json({ id: 103, status: "publish" }) as publishDraft. Cases: "shows the owner what waits: the count on the clerk, the draft first with its tools, the held comment under its post" (th-clerk-count reads "4"; the first th-draft has th-chip "Draft" and th-publish; th-comments-toggle of post 101 contains "(1 held)"; th-held-comment under it with th-approve); "publishes the draft from the card" (click th-publish, cy.wait("@publishDraft"), then @news called again); "offers the writers in the clerk's menu" (open the clerk, the menu lists "Write news"); "shows a family's link none of it" (the me stub answers { name: "Dad" } → no th-clerk-count, no th-draft, @drafts never requested).
  • Step 3: Run the Cypress spec against the shared dev server (start none: curl -s localhost:8100 must answer first).

Run: pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/town-hall.spec.js Expected: every case passes; screenshots of failures land in tests/e2e/screenshots.

  • Step 4: Rehearse on the local WordPress (owner's machine, http://localhost): make the patch-notes category there if missing; in the app at localhost:8100 set localStorage["xp:townHallSite"] = "http://localhost", register the family through the clerk with the local admin, then: write a patch note as a draft, publish it from Updates, post a topic as the Game Master and see it up at once, bin it (the question appears), approve a held comment made by a second local user. Note anything off in the PR.

  • Step 5: Commit

git add src/app/Console/MyPortal/HomeTown/TownHall/TownHall.vue src/app/Console/MyPortal/HomeTown/TownHall/TownHall.ts src/app/Console/MyPortal/HomeTown/TownHall/_TownHall.scss tests/e2e/specs/town-hall.spec.js
git commit -m "feat(town-hall): the page hands the desk to the tabs, counts what waits on the clerk, and the owner's run in Cypress"

After the last task

  1. One gate, once: chemx verify --build in the worktree (typecheck, the audit ratchet, every spec; the build type-checks the .vue scripts). Fix what it names, commit.
  2. Push the branch and open the PR against master with the spec and plan paths in the body; gh pr merge <n> --auto --squash. The Tests check is the gate.
  3. Bump the docs submodule pointer on master after the PR lands if the PR did not carry it.
⚠️ **GitHub.com Fallback** ⚠️