2026 10 22 town hall site powers - xopherdeep/do-it-for-the-xp GitHub Wiki
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
- Chemical X only:
chemx test tests/unit/<Name>.spec.tsfor the one spec you touch; neverchemx typecheck,chemx verify,pnpm test,npx jestor a build while building (the gate runs once after the last task). - Read with
chemx read <file> --outlineor--lines, search withchemx q, never cat whole files. - Audit ratchet: every compound boolean is named before use (
const isReady = a && b; if (isReady) ...), never inlinea && bin anif, a ternary or a template attribute; no em dash in code or strings (the one insignature()is allowed and marked); at most 5 directuse*()calls per function (TownHall.ts already has 5: add nothing there; the desk composable is created insideuseTownHallClerk). - Sounds, when any:
play$fx(key)with keys fromsrc/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):
DELETEnever carriesforce; publish isPOST .../{id}{ status: "publish" }. - Branch
feat/town-hall-site-powersin 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.
- 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). - A site answer whose
capabilitiesis missing, null or not an object (a Contributor'scontext=editanswer can omit it) must give no powers, not throw (Task 1). - 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).
- 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).
- 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).
Files:
- Create:
src/lib/services/townhall/townHallDesk.ts - Test:
tests/unit/townHallDesk.spec.ts
Interfaces:
- Consumes:
PostDraft,TITLE_MAX,charCountfromtownHallPosts.ts;TownHallPostKindfromtownHallTypes.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.tswith the signatures above.powersOfreadscapabilitiesonly when it is a non-null object; a power is true only when its capability is exactlytrue.wordsProblemcounts withcharCountand usesTITLE_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"Files:
- Modify:
src/lib/services/townhall/townHallFeed.ts - Test:
tests/unit/townHallFeed.spec.ts(extend; it hassite(),reply(),post()helpers andROOT)
Interfaces:
- Consumes:
NewsCategory,EditableType,Words,REST_PATHS,isOpenBugfrom Task 1;patchNotesCategoryId,toNewsItem,toTopicItem,totalPagesOfalready 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 atotalOf(headers)next tototalPagesOf(theX-WP-Totalheader, 0 when absent).fetchBugsmapsbug_status(default"new"),bug_url,bug_environmentand filters withisOpenBug.fetchWordsreadstitle.raw ?? title.renderedandcontent.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"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;PostedOutcomegains"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!linkedreturn,const isPublishedNow = canPublish && post.kind !== "bug"; if (isPublishedNow) return "published";.POSTED_LINES.publishedas 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"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 mockstownHallFetchand the database asmockDb, withstoredLink(),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. IncheckAndStore, readme.datawhole and setpowers: powersOf(me.data, Date.now())on the new link.refreshPowers():await init(); return whenauth.value.kind === "none"; fetchwp/v2/users/mewithquery: { context: "edit" }; on success save{ ...activeLink, powers }through the db andlink.value, or setsessionPowers; catch everything anddebug.warn.dropPowers(): same two branches withNO_POWERSandreadAt: Date.now(). InsendOne: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"Files:
- Modify:
src/lib/services/hometown/hometownPrompts.ts - Test:
tests/unit/HometownPrompts.spec.ts
Interfaces:
-
Produces:
ClerkMenuIdgains'write-news' | 'write-patch-note';CLERK_ERRANDSgains'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(choicestake-downicontrashtoneprimary,leaveicontimestoneghost,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 beforenever; prompts useclerkNoticeand the clerk'sspeaker: CLERK, accent: 'townHall', sprite: 'tablet'shape ascourierLatePromptdoes. -
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"Files:
- Create:
src/app/Console/MyPortal/HomeTown/TownHall/components/useTownHallDesk.ts - Test:
tests/unit/useTownHallDesk.spec.ts(rig it likeuseTownHallPosting.spec.ts: afakeReveal(), a fake store,jest.mockoftownHallFeedreaders and oftownHallClient'stownHallFetch)
Interfaces:
- Consumes: Task 1 requests and types; Task 2 readers (
fetchHeldComments,fetchBugs,countDrafts,countPendingTopics,fetchWords,patchNotesCategoryId); Task 5 prompts (binPrompt,keysTakenNotice,courierLatePrompt);townHallFetch,TownHallError;isCourierTroublefromuseTownHallLoad.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.tswith these cases (assert ontownHallFetchcalls, the reveal'saskedprompts 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
publishcalls → one fetch). - "asks before binning something published, and leaves it on Leave it" (
bin("post", 7, true), answerleave→ no fetch, false; answertake-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.dropPowerscalled once,keysTakenNoticeasked, false, and a secondpublishafter 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 answersretry, 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
refreshwith one held comment andwaitingCount1 →approve(3)→POST wp/v2/comments/3 {status:"approved"},heldCommentsempty,waitingCount0). - "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" (
createNewswithpatchNotesCategoryIdmocked 7 → request body hascategories: [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".
- "publishes a draft with one request and says nothing" (
-
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.tsper the rules above. Keep one privateact(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"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 fromPostDraft, which still fits it);patchNotesAvailable?: boolean(default false);newsCategory?: NewsCategory(default"news"); emitsend(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.
HEADINGSandPLACEHOLDERSbecomeRecord<ComposeKind, string>(news: "Write news" / "What's new in the city?", edit: "Edit" / "").max: news and edit →NEWS_BODY_MAX.draftbecomes acomputed<ComposeDraft>by kind: news →{ kind: "news", title, body, category: pickedCategory, status }wherestatusis 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 }), elsedraftProblem. The dock: the category picker (twoth-compose__boardbuttons,role="radio",aria-checked) whenkind === "news"andpatchNotesAvailable; the Draft button (th-save-draft, classth-compose__send is-secondary, outline look: transparent background, cream text) before the main button whenkind === "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"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 mocksuseTownHallPosting,useTownHallLinkandregistrationOfferand will need a mock ofuseTownHallDesk)
Interfaces:
-
Consumes: Task 5 prompts and menu ids; Task 6
useTownHallDesk; Task 1 types; Task 3postedOutcome(..., canPublish). -
Produces:
-
PostingStoregainscanPublish: boolean(the one source for the menu and for the posted outcome). -
PostingDepsgainsdesk: { 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 }. -
ComposeSpecgainsnewsCategory?: NewsCategoryandediting?: { type: EditableType; id: number };kind: ComposeKind;initial?: ComposeWords(Task 1;openEditdropskindfrom the literal it builds). -
useTownHallPostingreturns, in addition:openNews(category: NewsCategory),openSiteEdit(target: { type: EditableType; id: number }): Promise<void>(reads the words throughdeps.desk.readWords; opens nothing when null, the desk has already said the courier is late), andsendDraft(draft: ComposeDraft). -
TownHallClerkOptionsgainsreloadTab(id: "news" | "board" | "updates"): Promise<void>;useTownHallClerkreturnsdesk(the Task 6 composable) as well, andcompleteLinkReturnrunsawait store.refreshPowers(); await desk.refresh();before the queue is sent. The clerk'sdesk.changedmaps:"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; insendDraft, branch first ondraft.kind === "news"and"edit": call the desk, on a problem settroubleand keep the sheet open, else close,await reveal.showPrompt(deskNotice(outcome))(publishedwhenstatus === "publish",draftedfor a draft,savedfor an edit), thendeps.desk.changed(what)wherewhatis the news category or the edited type; the hero path is unchanged exceptpostedOutcome(post, store.isLinked, deps.isGameMaster(), store.canPublish). InuseTownHallClerk:const desk = useTownHallDesk({ store, reveal }); passdesk(withchangedbuilt fromoptions.reloadTab) into the posting deps; extendcompleteLinkReturn; returndesk. -
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"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 likeTownHallWaiting.spec.ts, mocktownHallFeed'sfetchNews,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 blockdata-cy="th-held"with onedata-cy="th-held-comment"per comment (author, date, sanitized content, chip "Held"data-cy="th-held-chip"), buttonsth-approve("Approve", callsdesk.approve(id)) andth-bin-comment("Bin", callsdesk.bin("comment", id, false)), both disabled whiledesk.isBusy("comment", id), and the item'sdesk.troubleOf("comment", id)under it when set. -
TownHallNews.vueprops:canPublish?: boolean,canModerate?: boolean,heldComments?: HeldComment[](default[]),desk?: DeskActions; emits addedit(target: { type: EditableType; id: number }). WithcanPublish:draftsread byfetchDrafts("news")inreload(its own try/catch: a failed draft read leavesdraftsempty and never marks the tab failed), rendered first with classth-post is-draft,data-cy="th-draft", chip "Draft" (data-cy="th-chip"), buttonsth-publish("Publish" →desk.publish("post", id)thenreload()on true),th-edit("Edit" →emit("edit", { type: "post", id })),th-bin("Bin" →desk.bin("post", id, false)then remove fromdraftson true); published cards gainth-editandth-bin(desk.bin("post", id, true), remove fromitemson true). WithcanModerate: the comments toggle readsComments (N held)whenheldFor(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 whiledesk.isBusy("post", id); the item's trouble shows under the actions asdata-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-chipblock in_TownHall.scsswithis-draft,is-pending,is-held,is-newcolours: draft$eb-color-pale-yellow, pending$eb-color-minty-blue, held#ffb4b4, new#ffd27f; text$eb-color-dark-purple). Buttons areion-button size="small" color="rpg", Binfill="outline", in the existingth-post__actionsrow. -
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"Files:
- Modify:
src/app/Console/MyPortal/HomeTown/TownHall/components/TownHallBoard.vue - Test:
tests/unit/TownHallBoard.spec.ts(new; mockfetchBoards,fetchTopics,fetchPendingTopics)
Interfaces:
-
Consumes: Task 6
DeskActions, Task 2fetchPendingTopics, Task 9TownHallHeldComments.vue. -
Produces: props
canPublish?: boolean,canModerate?: boolean,heldComments?: HeldComment[],desk?: DeskActions; emitedit(target: { type: EditableType; id: number })(the existingeditemit carries aTownHallPostfor the hero's failed post; rename the new oneedit-siteto keep both:(event: "edit-site", target: { type: EditableType; id: number })). WithcanPublish:pendingread byfetchPendingTopics(board.id)with the topics (own try/catch, same stale-ticket guard), rendered between the board's About card and the published topics, classth-topic is-pending,data-cy="th-pending-topic", chip "Waiting", buttonsth-publish(→desk.publish("topic", id)thenreload()),th-edit(→emit("edit-site", { type: "topic", id })),th-bin(→desk.bin("topic", id, false), remove on true); published topics gainth-editandth-bin(published: true). WithcanModerate:<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" (fetchPendingTopicscalled with 11 then 12 after theboardSlugprop 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"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; mockfetchPatchNotes,fetchDrafts)
Interfaces:
-
Consumes:
BugItem(Task 2),DeskActions(Task 6). -
Produces:
-
TownHallBugNet.vue: props{ bugs: BugItem[]; desk: DeskActions }; anion-carddata-cy="th-bugnet"titled "Bug Net" with "No bug reports waiting." when empty, else onedata-cy="th-bug"per bug: title (sanitized), date (formatTownDate), chip "New" whenstatus === "new"else the status as given, the route, the environment's first line; buttonsth-resolve("Resolve" →desk.resolve(id)) andth-bin("Bin" →desk.bin("bug", id, false)), disabled while busy, trouble under them. -
TownHallUpdates.vueprops:canPublish?: boolean,bugs?: BugItem[](default[]),desk?: DeskActions; emitedit-site(target). WithcanPublish:draftsbyfetchDrafts("patch-notes")rendered first with the Draft chip andth-publish/th-edit/th-binas in Task 9; published notes gainth-editandth-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"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.tsonly 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 fromuseTownHallClerk(Task 8). -
Step 1: Wire the page. In
TownHall.ts: passreloadTab: (id) => tabRefs[id].value?.reload() ?? Promise.resolve()intouseTownHallClerk(declaretabRefsbefore the clerk, or pass a function that reads it lazily); returncanPublish: 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. InTownHall.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@editstays 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'sion-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__countabsolutely 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. InstubSite()nothing changes (a hero's run must make none of the new reads: assertcy.get("@drafts.all").should("have.length", 0)in the existing News case after adding the stub below as a top-level alias). Add adescribe("The owner's desk")inside the registration describe that uses the return-path rig (stubLinkSite,returnPath,forgetDeviceLink), with themestub answeringjson({ 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" })asdrafts; -
GET **/wp/v2/cafeteria_topic?status=pending*→json([topic(303, "Pending: pet dragon", "Please.", "Max")], { "X-WP-Total": "1" })aspending; -
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" })asheld; -
GET **/wp/v2/compass_bug*→json([{ ...post(501, "Dragon stuck", "It is stuck."), bug_status: "new", bug_url: "/temple", bug_environment: "App: dev" }])asbugs; -
POST **/wp/v2/posts/103→json({ id: 103, status: "publish" })aspublishDraft. 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-countreads "4"; the firstth-drafthasth-chip"Draft" andth-publish;th-comments-toggleof post 101 contains "(1 held)";th-held-commentunder it withth-approve); "publishes the draft from the card" (clickth-publish,cy.wait("@publishDraft"), then@newscalled 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" (themestub answers{ name: "Dad" }→ noth-clerk-count, noth-draft,@draftsnever requested).
-
-
Step 3: Run the Cypress spec against the shared dev server (start none:
curl -s localhost:8100must 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 thepatch-notescategory there if missing; in the app atlocalhost:8100setlocalStorage["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"- One gate, once:
chemx verify --buildin the worktree (typecheck, the audit ratchet, every spec; the build type-checks the.vuescripts). Fix what it names, commit. - Push the branch and open the PR against
masterwith the spec and plan paths in the body;gh pr merge <n> --auto --squash. The Tests check is the gate. - Bump the docs submodule pointer on
masterafter the PR lands if the PR did not carry it.