Skip to content

feat(cms): fill a site's editorial brand context from its own blocks - #6341

Open
aka-sacci-ccr wants to merge 14 commits into
mainfrom
blogpost-generation-cms
Open

feat(cms): fill a site's editorial brand context from its own blocks#6341
aka-sacci-ccr wants to merge 14 commits into
mainfrom
blogpost-generation-cms

Conversation

@aka-sacci-ccr

@aka-sacci-ccr aka-sacci-ccr commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Adds an Autonomous content collection to the Content tab (Blog group), holding the brand context every generated blogpost will be written against: tone of voice, generation dos and don'ts, audience, values, blog categories, competitors.

Persists to the site's own .deco/blocks/blog-manager-brand.json as plain JSON — no new table. Same path and field names Spire writes, so a site it already set up opens here populated.

BLOG_BRAND_EXTRACT fills the empty fields by reading the site's own CMS blocks.

No scraper, no new credential

The first draft called Firecrawl. That was wrong: FIRECRAWL_API_KEY is an instance-wide env var, so someone on the hosted Studio can neither enable it nor bring their own key, and the cost lands on a shared quota. Reading a URL server-side would also have needed an SSRF guard we don't have (httpUrlSchema only rejects non-http schemes).

The blocks are already in the browser's decofile, so the tool takes them as input and only calls the model. Inference runs on the org's own smart tier via resolveTier — same path as suggest-commit-message and judge-requires-review — so it bills to the org, and the org picks the model.

Two things the real data forced

Tested against a real storefront's 1969 blocks:

  • Serialized block size is a bad proxy for prose. One institutional page was 15KB of JSON carrying two sentences — the rest asset URLs, signed video links and device matchers. Ranking 1018 pages by size surfaced product-listing stubs and buried the institutional pages that hold the brand's values. extractBlockProse walks a block and keeps only prop: phrase lines (a phrase = contains a space), deduped, which separates alt: "92% de funcionárias" from site/sections/Layout/Flex.tsx and 20px without a prop allowlist.
  • That site has 1018 pages and zero posts, so "read the existing posts" cannot be the only path. Tiers degrade: posts → categories → pages, and the prompt says so instead of treating a blogless site as the exception.

The prompt also encodes traps found in that data: internal asset annotations ([LP … ] [carrossel …]) are not copy; shipping/return/payment fine print is not editorial voice; brand-specific vocabulary (a site calling the shopping bag mochila) is the most valuable thing to capture verbatim; and casing is part of the voice, including when it's inconsistent.

Won't clobber hand-written rules

Re-running the extract writes only into fields that are still empty, so it can never wipe dos/don'ts someone typed. The toast reports how many it filled, or that there was nothing to fill.

Testing

62 unit tests in blog-data.test.ts cover the prose walker (drops URLs/identifiers/dimensions, keeps phrases and prop names, dedupes exact repeats, preserves casing variants) and the evidence ranking (tier order, prose-density ordering, char budget, empty site, missing page keys).

bun test apps/api/src/tools/ reports 25 failures — pre-existing: the same 25 fail on a clean tree (verified via git stash -u), and each file passes in isolation, so it's cross-file test pollution unrelated to this change.

tsc --noEmit clean in both workspaces, knip clean, lint 0 errors, fmt applied.

Not in this PR

  • The extract has not been exercised end-to-end against a live model yet — org-fs mounts break the daemon's write route off-cluster, so local verification needs DISABLE_ORGFS_MOUNTS=1. Unrelated to this diff.
  • selfhost/examples/dev-hybrid/.env.example still ships STUDIO_SANDBOX_PREVIEW_URL_PATTERN set, which 502s every daemon call off-cluster. Out of scope here; worth its own fix.
  • No sub-navigation inside the new tab — one section today, so a tab bar with one item would be noise. Planner, ideas and drafts land under the same heading later.

🤖 Generated with Claude Code


Summary by cubic

The blog CMS now manages a post's whole lifecycle — idea to published — from one workspace: Context groups brand rules, formats, pillars, authors and categories, and Posts shows the same cards as a board or a grouped list. Generation writes scheduled drafts from a chosen idea and format; it never auto-publishes and is blocked until the brand context that keeps the voice consistent is filled in.

Posts lifecycle

  • Statuses are the blog app's own union (draft, generating, awaiting_review, scheduled, published, archived); legacy idea / in_review values are mapped on read and retired on save.
  • Moving a post across the planning/live boundary renames its block in one atomic patch, so a failed move can't leave the post in two places at once.
  • Deleting soft-deletes into Archived, a planning-form block the site stops rendering; live-state moves are gated by the site's blog-app version.
  • An idea is its own block with an optional pillar; generating from it keeps the idea in place and the pillar drives the draft.

Generation & rollout

  • BLOG_POST_DRAFT builds only text-shaped sections (Heading, Paragraph, List, Quote, Callout, Cta, Divider); the client maps kinds to the site's components and drops unknown ones.
  • @ citations in format briefs and the org-member picker each install only the matching picker, so the two tiptap triggers never conflict.
  • No DB or env changes; state lives in the site's .deco/blocks/ and stays backward compatible with legacy string[] rules.
  • Connect an AI provider in Settings → AI Providers to enable extraction, suggestions and generation; otherwise the AI buttons disable with a tooltip.

Written for commit 144fe42. Summary will update on new commits.

Review in cubic

decobot and others added 4 commits August 20, 2026 17:54
Adds an "Autonomous content" collection to the Content tab, holding the brand
context every generated blogpost will be written against: tone of voice,
generation dos and don'ts, audience, values, blog categories, competitors.

Persists to the site's own `.deco/blocks/blog-manager-brand.json` as plain
JSON — no new table. Same path and field names Spire writes, so a site it
already set up opens here populated.

`BLOG_BRAND_EXTRACT` fills the empty fields by reading the site's own CMS
blocks. No scraper and no new credential: the blocks are already in the
browser's decofile, and the inference runs on the org's own `smart` tier via
`resolveTier`, so it bills to the org rather than to an instance-wide key.

Two things the real data forced:

- Serialized block size is a bad proxy for prose. Farm Rio's "Sobre Farm" page
  is 15KB of JSON carrying two sentences, and ranking its 1018 pages by size
  surfaced product-listing stubs while burying the institutional pages that
  hold the brand's values. `extractBlockProse` walks a block and keeps only
  `prop: phrase` lines, deduped.
- That site has 1018 pages and zero posts, so "read the existing posts" cannot
  be the only path. The tiers degrade: posts, then categories, then pages.

Re-running the extract only writes into fields that are still empty, so it can
never wipe dos/don'ts someone wrote by hand.

Testing: 62 unit tests in `blog-data.test.ts` cover the prose walker and the
evidence ranking (tier order, prose-density ordering, char budget, empty site,
missing page keys). The `25` failures in `bun test apps/api/src/tools/` are
pre-existing — same count on a clean tree, and each file passes in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The brand context was ten fields in one column, four of them `string[]`. That
shape didn't hold up:

- A rule worth writing down doesn't fit one line. "Never print prices in a text
  block, use ProductCard" is a short name plus an explanation, and both were
  fighting over the same 36px input.
- A competitor's name alone says nothing about why it matters or how the brand
  differs from it — which is exactly what a generated post needs to know.
- Identity data, generation instructions and guardrails are read at different
  moments by different people, with no hierarchy separating them.

So `dos`, `avoid`, `values` and `competitors` become `Array<{name, value}>`,
where `value` renders in the markdown editor already used by the task dialog.
`categories` stays `string[]` — it's a taxonomy, not a rule, so it has no body.

No migration. `normalizeBrandRules` reads both shapes: a legacy flat string
becomes the rule's name with an empty body, so a block Spire wrote opens intact
and picks up the new shape on its next save.

Four tabs — Basics, Generation rules, Guardrails, Extra context. The "read this
site's content" card stays outside them, since it fills fields across all four.

`MarkdownEditor` reads `defaultValue` only on mount, so an extract that filled a
rule body would have left the old text on screen. An `editorRevision` counter
keys the editors and remounts them onto the new values.

Competitors now come from web search, because a site's own blocks structurally
cannot answer that one — a brand doesn't name rivals in its own copy. It runs
only when the blocks named none, on the org's own `web_search` tier via
`tryResolveTier`, and returns `[]` when the org has no such tier, when the
search finds nothing, or on any error: this enriches the result and must never
be what makes the extract fail. Scope is deliberately competitors-only; letting
search rewrite `tone` or `values` would trade the brand's own prose for a third
party's summary, which the prompt already forbids.

No import from the chat harness: `mode: "quick"` of the research hook reduces to
a call against the search-capable model, so this does that directly rather than
inventing a `taskId`/`toolCallId` for a durable job it doesn't need.

Testing: 6 new unit tests for `normalizeBrandRules` (legacy string, well-formed
rule, half-migrated mixed list, body-only rule, entries with no text, non-array).
217 pass across the content suite. `tsc` clean in both workspaces, `knip` clean,
`lint` 0 errors. Also drops `dosLabel`/`dontsLabel`, orphaned by the tab titles.

Not yet exercised against a live model: the editor remount, and whether this org
has a `web_search` tier at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nests the three screens behind the single "Autonomous content" collection row
instead of promoting them to siblings of Posts and Authors in the Content
sidebar. Planner and Ideas ship as placeholders that say what will land there;
the inner rail is what makes room for them without touching navigation again.

Library keeps the brand context, now under Content-OS-style underlined tabs
(Context / Formats) with a fixed four-item rail: basics, generation rules,
guardrails, extra context. The rail is sticky, so it stays put while a long
guardrail list scrolls.

The rule lists were a column of stacked markdown editors, which is unreadable
past two rules. Now a row shows the rule's name and clicking it opens that
rule's body, one at a time. Deleting a rule closes the editor when the indices
below it shift, so the wrong rule can't end up open.

`MarkdownEditor` grows an `attachments` prop, off for these fields: a brand
guardrail is text, and the picker plus the paste/drop upload handlers were
offering an image flow that has no meaning here.

The extract button says "Fill" rather than describing its mechanism, and while
it runs a status line names the step: reading the site, inferring the voice,
searching for competitors. Those are the pipeline's real phases but timed on
the client — the tool is one round trip, so the client cannot know the server's
step. That's also why there's no progress bar: the design system's `Progress` is
determinate, and any percentage here would be invented.

Both prompts now pin the output language to the site's own. A Portuguese site
was getting an English profile, which is unusable twice over: the people who
maintain it work in that language, and the model that later reads it copies the
language it sees. Fixed one instance of the same bug inside the prompt, where an
English example illustrated Portuguese output.

Also translates the pt-BR entries left in English (library, planner) and drops
the `useT` briefly added to `content-browser`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing onto main landed this branch on a tree where `use-blog-mutations.ts`
no longer exists — #6271 deleted it after blog writes started 404'ing, folding
the key-to-file mapping into the server. Git replayed the commits cleanly
because the import target was simply absent rather than conflicting, so only
`tsc` caught it.

`useSaveBlock` is the replacement and no longer takes `packagePath`; it resolves
that itself. The `blog-manager-brand` key has no slashes, which is the only
reason `use-blog-mutations` existed separately, so there is nothing else to port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aka-sacci-ccr
aka-sacci-ccr force-pushed the blogpost-generation-cms branch from a153839 to 22104fe Compare August 20, 2026 21:01
decobot and others added 10 commits August 21, 2026 11:45
Replaces the "Ideias" placeholder in Conteúdo autônomo with Temas, the
first step of the generation flow (Temas -> draft -> format -> post).

A theme is a title plus a markdown brief, one block per theme under
`blog-manager/themes/`, with no `__resolveType` so the site never
resolves it. One block each rather than an array in one block: appending
five suggestions can't clobber the one being edited.

BLOG_THEME_SUGGEST proposes them from the brand context, the titles
already covered, an operator's guidance and an optional web_search hop
(which also asks what the named competitors published). Doesn't persist
— the web writes the blocks, same contract as BLOG_BRAND_EXTRACT.
BlogBrandSchema moves to blog/schema.ts, now shared by both tools.

Two concurrency fixes found while wiring it:

- The suggestion's writes are sequential. Fired in parallel, each one
  replaces the whole decofile cache with the server's snapshot in
  fast-preview mode, so the loser's theme vanished from the list until
  the next refetch.
- Each row owns its own autosave draft. One draft held by the screen for
  whichever theme is open would let a save still in flight land on the
  block of the theme selected next, because the callback reads the open
  key when it fires, not when the edit happened.

Batch timestamps are staggered by index so the list keeps the model's
ranking instead of re-sorting alphabetically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the Library's Formats tab real. A format is a name plus a markdown
brief that gets injected into the generation prompt — deliberately loose,
so it describes intent and lets the model decide the actual sequence.

The `@` picker is the discovery mechanism: it lists the sections this
site can render, with what each one does, and inserts the component name.
That replaces both a block-sequence builder (which would defeat the point)
and a checkbox allowlist — a citation is positional, so "opens with a
@Heading, closes with a @ProductShelf" says something a set cannot.

A mention lands as PLAIN TEXT, not a node. The field round-trips as
markdown, and a node would need both renderMarkdown and parseMarkdown —
the latter a token matcher for arbitrary `@word`, since the parser runs
only the first handler per token. Plain text round-trips for free and
stays readable in the block JSON. This needs no schema change:
`Suggestion()` is a bare ProseMirror plugin attached with registerPlugin,
so the markdown round-trip test is untouched.

BLOG_FORMAT_SUGGEST names the formats a blog already writes in. Its
load-bearing input is the *shape* of each post — the sequence of section
component names — not the prose: 40 of those sequences are a tiny input
and the only part that answers "how is this post built". With no `smart`
tier it reports fallback:true and the web writes a starter format of its
own, which needs no model and so can be localized.

Three things found while wiring it:

- `discoverBlogBlockTypes` dedupes by resolveType, so app and site
  variants of one component both survive — which collided on the picker's
  React key, and is meaningless anyway once a citation is the bare name.
  `mentionableSections` collapses them.
- The orphan-citation warning used `text-warning-foreground`, which is
  near-white (it's for text *on* a warning fill) and would have rendered
  invisible. Every other call site uses `text-warning`.
- That warning also ran its callback twice per open row.

The chat's Suggestion gains optional renderItem/emptyLabel; without them
its behavior is unchanged, which its own tests cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in post scheduling (#6421), which lands as its own Scheduling row
under Blog — outside Conteúdo autônomo.

Conflicts, both additive:
- collections-sidebar: keep both new rows, Scheduling then Autonomous.
- blog-data.test.ts: import block only, keep both sides' symbols.

main replaced `isPostPublished` with `postStatus` and `PostMeta.published`
with `PostMeta.status`; nothing on this branch read either, so the rewrite
came through untouched.

Regenerated the tool contracts: the auto-merge of tool-io.ts had dropped
`threadId` from RepoFileParams (#6383).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its placeholder promised "generated posts on a calendar, so you can
schedule them" — which #6421 now delivers as the Scheduling row under
Blog. A placeholder that describes a feature shipped somewhere else is
worse than no placeholder.

Conteúdo autônomo is Themes and Library, opening on Themes. Deleted the
three orphaned sandbox.planner.* / collectionsSidebar.planner keys in both
locales; tsc caught the Calendar and EmptyMessage imports it orphaned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…input reading as search

Five fixes to what shipped in the last two commits.

The three AI buttons behaved three different ways with no provider
connected. Brand extract and theme suggest threw TierUnavailableError,
which tools-rest maps to a 500 and the web surfaced as a raw English
toast; format suggest silently returned a starter template instead. All
three now check `useHostedAiProviderKeys()` and disable with a title
pointing at Settings → AI Providers.

`resolveTier` never checks a balance, so an org with a provider but no
credits still fails inside `generateObject` with whatever the provider
says. That is unchanged — there is no cheap pre-flight for it — but it is
now the only unguarded case rather than one of several.

The starter format becomes its own always-available button. It is static
text needing no model, so making it a hidden consequence of billing state
was wrong: the same click did different things depending on something the
user can't see. BLOG_FORMAT_SUGGEST drops its `fallback` output field and
goes back to `resolveTier`, so all three tools now fail identically.

The theme guidance input sat alone above the list, empty, with a grey
placeholder — exactly where a filter would live. It read as a search box,
and typing in it fired a generation. It moves into a popover on the button,
where it only appears at the moment it applies.

Also: SaveStatus had three hardcoded English strings and is used by nine
sandbox editors, and the pt-BR themes subtitle said "virá a ser".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RuleList`'s add button appends `{ name: "", value: "" }`, and the render
pipes the list back through `normalizeBrandRules`, which dropped any entry
with no text — so the new row was deleted before it ever painted. The
button did nothing.

The Formats tab is where this surfaced, because adding by hand is the only
way to get a row there. It was equally broken for the four brand-context
rule lists; nobody hit it because those get filled by the extract.

That `untitledRule` label already existed for a row with no name yet, so
showing the blank row was always the intent — the normalizer was quietly
defeating it.

An object entry now survives with both fields empty. Only a non-object, or
a blank legacy string, is still junk. The two callers that mean substance
rather than editor state — the tool inputs, and the extract's
"is this field still empty?" guard — go through the new `filledBrandRules`;
without that, adding a blank row would have made the extract skip that
field in silence.

Inverted the test that pinned the old behavior rather than adding beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New Gerar tab, and the tool behind it. Novo walks four steps — theme,
format, when it goes live, any last instruction — and writes a post block
with `status: "scheduled"`. Never published: a human reviews it, and the
schedule is what they opted into.

Brand context blocks generation rather than warning about it. Without the
basics, the generation rules and the guardrails, the model falls back on
what a brand in this category usually sounds like, which is the one
outcome this whole feature exists to avoid. The blocked screen names the
missing fields. `values`, `categories` and `competitors` stay optional.

A step with nothing to pick from shows the writing surface plus a Suggest
button, so a site with no themes and no formats can still answer it. What
gets written there is persisted as a theme / format, since it is the same
thing those screens hold.

The design decision underneath: the model names section kinds and the
client owns everything site-specific. Only the site knows whether Heading
is `blog/sections/blocks/Heading.tsx` or its own
`site/sections/Blog/Post/Heading.tsx`, so the model never sees a
resolveType and cannot invent one.

Three traps found by reading the block editors, all of which produce a
block that saves fine and renders empty:

- `List` stores its items as one newline-joined string, while Checklist,
  StatGroup, Steps and Comparison store JSON — `str()` on a real array
  yields "". Each kind is written out explicitly, and tested.
- Paragraph holds `html`, not markdown.
- Heading/List/Callout enums are defaulted rather than left undefined.

Generation covers only text-shaped sections. An image or a product shelf
needs data no model can invent, so those stay for the reviewer — which is
also why `image` is left empty and `missingPostFields` reports it.

The draft section schema is a flat object with a `type` enum rather than a
discriminated union: the caller has to validate and drop sections anyway,
so the union bought no safety while pushing `anyOf` through structured
output, which works on three providers and fails on the fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four conflicts. Three were textual; one was not.

main added its own `@` picker to MarkdownEditor — org members, inserted as
a node that serializes to a markdown link, with a store bridging the
ProseMirror plugin to a cmdk menu and three follow-up fixes for focus,
portalling and dismissal.

Both answer to `@`, and main installs its store unconditionally, so in the
format editor the two would have fought over the trigger. Resolution:
`markdownEditorExtensions` gets the store only when no item list was
passed, so a field listing its own items installs one picker and a task
description installs the other. Mine is renamed SectionMentionMenu and
carries a `ponytail:` note — folding the two together means making main's
item source, item row and insert all injectable, which is worth doing but
not mid-merge in a file under active change.

The section citations still round-trip: main's mention parses from a link
href, and a bare `@Name` is not a link.

i18n was additive on both sides. tool-io.ts is generated, so it was
resolved by regenerating rather than by editing — 191 contracts now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No conflicts: main's three markdown-editor fixes (ordered lists, table and
checklist schema, caret-is-not-an-edit) landed in files and hunks this
branch doesn't touch, and my SectionMentionMenu came through intact.

`node_modules` needed a clean reinstall, not a code change. main's #6663
consolidated the lockfile onto @tiptap/pm 3.30.5, but the 3.20.2 tree was
still on disk, so two copies of prosemirror-model resolved at once and tsc
failed on main's own mention-suggestion.tsx with an unassignable Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st (#6622)

* feat(cms): restructure blog into Context, a lifecycle board, and a list

Reorganize the blog CMS around the user's mental model:

- Context (was "Autonomous content"): Brand, Formats, Content pillars
  (reconceived themes), plus Authors and Categories folded in as tabs. Sidebar
  Blog group is now Context, Posts, Scheduling.
- Posts is one lifecycle the same card travels: Idea → Generating → In review →
  Scheduled → Published. Board (drag to advance) and a grouped List (by status /
  format / pillar) are two views; opening a card floats a right-anchored panel
  with an expand-to-full-page toggle, list mode is master-detail.
- Idea/Generating/In-review posts are planning-only blocks (no __resolveType);
  scheduling promotes them to real collections/blog/posts blocks so the site
  never renders unfinished work. Editing preserves the block's form.
- New BLOG_PILLAR_SUGGEST tool; the idea suggester takes pillar/format/seed
  context. Generate ideas is a modal.

Status labels stay English in every locale. Data helpers + 5-status lifecycle
are unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(cms): align post statuses with the blog app and soft-delete into Archived

Studio was writing `status: "idea"` / `"in_review"` into `payload.status` —
values the blog app's own `PostStatus` union does not have, so the app could
not read back what the CMS wrote. A test even documented `awaiting_review` as
an *unrecognized* value.

`PostStatus` is now the app's union, in lifecycle order: draft, generating,
awaiting_review, scheduled, published, archived. `postStatus()` reads all six
and maps the legacy Studio-only `idea` / `in_review` onto their app
equivalents; `stampPostModified()` retires those two names on any save, so
decofiles written by earlier commits on this branch heal themselves. Nothing
in the app was touched.

Deleting a post is a soft delete: it moves to Archived, which is a
planning-form block with no `__resolveType`, so the site stops rendering it
without depending on the app to filter by status. The delete affordance is on
both the board card and the list row, and is hidden for a post already
archived. Archived is a sixth board lane that collapses to a rail (still a
drop target), with the toggle persisted in localStorage.

Also cleans up the i18n this branch left behind: 19 pt-BR entries that were
still English (including a regression on statusScheduled/statusPublished), 68
keys orphaned when generate.tsx / themes.tsx / autonomous.tsx were deleted,
new keys reordered into place, and the two hardcoded toasts in
content-browser.tsx routed through `t()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cms): make every post-status surface agree, and move a post atomically

A post's status could be changed from four places — dragging a board lane, the
delete-into-Archived action, the drawer opened from the board, and the same
drawer as the list's right pane — and they did not agree with each other.

A status move is special: it renames the block. Planning states live at
`blog-manager/posts/<id>` with no `__resolveType`, live states at
`collections/blog/posts/<id>`. Crossing that boundary is a write AND a delete,
and it was done as two sequential requests by two divergent copies of the same
code. Between the two awaits both keys existed, so `listAllPostsWithMeta`
rendered the post in two lanes at once — and a failed delete left that
duplicate committed with nothing to roll it back.

`movePostToStatus` already returned a `PostMove { writes, deletes }` and its
own doc said the caller should apply it "as one atomic patchDecofile". That API
existed and commits set+delete in a single commit; nothing consumed both
fields. `useMoveBlocks` now does, and patches the cache synchronously before
dispatching rather than from `onMutate` — so a caller that follows the post to
its new key re-points in the same tick, and no render sees the old key already
gone and the new one not yet there. That was the "Untitled post" flash.

`usePostStatusMove` is now the single path. Every surface asks the same
`refuse` before offering a move and applies it through the same mutation, so
none of them can disagree or leave a post half-moved. It reports a rename as
(from, to), so the board — which drags posts it has not opened — only re-points
a selection that was already on that post instead of popping the drawer.

The editor hands its live draft to the move and cancels the pending autosave,
which would otherwise land on the key the move just retired and resurrect the
post's old form. It is keyed by post id rather than block key, so a move no
longer remounts it and resets the open tab.

The capability gate applies to the board too: a lane the site's blog app cannot
honour is dimmed and refuses the drop with the version it needs, the same
answer the editor's control gives. That control now offers the full lifecycle
instead of two switches, with an exhaustive refusal reason.

Also deletes the posts surface in `ItemList` — filter bar, sort, bulk selection
and the bulk category panel — unreachable since ff990f3, along with the
helpers, tests and 49 translation keys it owned. That retires the second set of
status labels, leaving one map shared by the board and the editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(cms): make an idea its own object, and write posts from it

An idea and a content pillar were the same shape in code — a title and a
markdown body, both suggested the same way, both deduped by the same helper —
so nothing could tell them apart, and the pillar the wizard collected never
even reached the generator.

They are different things. A pillar is a territory the brand returns to across
many posts ("Electrolux product advantages"); an idea is one angle inside it
("why Inverter is worth it"). The test that separates them: can you write ten
posts from it? Then it is a pillar.

That distinction had an origin. `blog-manager/themes/*` was the ideas queue;
ff990f3 renamed themes to pillars and had `scanPillars` union the old prefix,
which turned every stored idea into a pillar. This restores the split: ideas
own that prefix again — it is what they always were — and pillars stop reading
it, so old sites get their ideas back with no migration.

An idea is now its own block with an optional `pillarKey`, and it is NOT a
post: it has no status, never moves through the lifecycle, and one idea is
worth several posts in several formats. On the board it sits in a tray beside
the lanes rather than in them, and writing from it produces a new post while
the idea stays put. Generating ideas takes a saved pillar, so ideas are born
inside a territory and carry the link.

The wizard drops its pillar step: the chosen idea carries the pillar, and
asking twice would let the two disagree. `BLOG_POST_DRAFT` gains that pillar
plus the authors it never had, and the system prompt now separates the ground
from the angle — a post that restates the pillar is the article the brand
already published.

Also: every board column collapses, not just Archived, keyed by status so a
reordered board cannot reopen the wrong one; and the Brand tab's "Blog
categories" field is gone — it duplicated the Categories collection and nothing
but its own input ever read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: decobot <capy@deco.cx>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants