diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb
index 27621ced..2c403a2a 100644
--- a/app/views/admin/sessions/new.html.erb
+++ b/app/views/admin/sessions/new.html.erb
@@ -24,7 +24,7 @@
<%= form.hidden_field :response, data: { webauthn_authentication_target: "response" } %>
<%= hidden_field_tag(:redirect, redirect) %>
- <%= form.admin_save "Next" %>
+ <%= form.button("Next", type: :submit, class: "button") %>
<%= form.button(type: :button, class: "button button--secondary", data: { action: "webauthn-authentication#authenticate" }) do %>
<% end %>
diff --git a/app/views/admin/sessions/otp.html.erb b/app/views/admin/sessions/otp.html.erb
index 09615e46..39d3f1df 100644
--- a/app/views/admin/sessions/otp.html.erb
+++ b/app/views/admin/sessions/otp.html.erb
@@ -4,5 +4,5 @@
<%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
<%= form.govuk_text_field :token, autofocus: true, autocomplete: "off" %>
<%= hidden_field_tag(:redirect, params[:redirect]) %>
- <%= form.admin_save "Next" %>
+ <%= form.button("Next", type: :submit, class: "button") %>
<% end %>
diff --git a/app/views/admin/sessions/password.html.erb b/app/views/admin/sessions/password.html.erb
index 54705669..ba8e69eb 100644
--- a/app/views/admin/sessions/password.html.erb
+++ b/app/views/admin/sessions/password.html.erb
@@ -5,7 +5,7 @@
<%# note, autocomplete off is ignored by browsers but required by PCI-DSS %>
<%= form.govuk_password_field :password, autofocus: true, autocomplete: "off" %>
<%= hidden_field_tag(:redirect, params[:redirect]) %>
- <%= form.admin_save "Next" %>
+ <%= form.button("Next", type: :submit, class: "button") %>
<%# init govuk js to provide the show/hide button %>
<%= govuk_formbuilder_init %>
diff --git a/app/views/admin/tokens/show.html.erb b/app/views/admin/tokens/show.html.erb
index 593e5539..4f8dd557 100644
--- a/app/views/admin/tokens/show.html.erb
+++ b/app/views/admin/tokens/show.html.erb
@@ -7,6 +7,6 @@
<%= row.text :email %>
<% end %>
- <%= form.admin_save "Sign in" %>
+ <%= form.button("Sign in", type: :submit, class: "button") %>
<% end %>
diff --git a/docs/admin-module-workflow.md b/docs/admin-module-workflow.md
index 98d07d5a..20bba73a 100644
--- a/docs/admin-module-workflow.md
+++ b/docs/admin-module-workflow.md
@@ -79,7 +79,9 @@ Picking a stable attribute (title, name, slug) keeps UI labels consistent across
- Form templates iterate through the discovered attributes in order and render `govuk_*` helpers (`lib/generators/koi/admin_views/templates/_form.html.erb.tt:3`). Reorder or group fields by rearranging the generated ERB.
- If you pass attribute arguments to `koi:admin`, the generated forms respect the order you provide. When you omit arguments, discovery follows the migration order for columns—i.e., the order you specified when running `koi:model`—then appends attachments, rich text, and `belongs_to` associations in declaration order.
-- `Koi::FormBuilder` injects admin-friendly buttons (`form.admin_save`, `form.admin_delete`) and smart defaults for file hints and ActionText direct uploads (`lib/koi/form/builder.rb:13`).
+- Keep form actions focused on saving form content (plain submit buttons in form sections).
+- Put non-form actions (archive/delete/view/preview) in page header actions via `actions_list`, `link_to_delete`, and `link_to_archive_or_delete`.
+- `Koi::FormBuilder` provides smart defaults for file hints and ActionText direct uploads (`lib/koi/form/builder.rb:13`).
- The first attribute becomes the default index link via `row.link`, so choose something human-readable (title/name) or update the generated `index.html.erb`.
- For structured content components, mix in helpers from `Koi::Form::Content` to reuse heading/style selectors inside custom forms (`lib/koi/form/content.rb:8`).
@@ -98,7 +100,7 @@ Picking a stable attribute (title, name, slug) keeps UI labels consistent across
### Table Layout
- Lists render inside `table_with`, and the first attribute becomes the linked column. Subsequent attributes follow in declaration order (`lib/generators/koi/admin_views/templates/index.html.erb.tt:33`).
-- When the module is archivable, a selection column and bulk archive button appear automatically (`lib/generators/koi/admin_views/templates/index.html.erb.tt:19`).
+- When the module is archivable, selection columns and bulk archive/restore actions should be available on active/archived indexes (`lib/generators/koi/admin_views/templates/index.html.erb.tt:19`).
- Summary pages render every “show attribute” using `row.text`, `row.enum`, etc. (`lib/generators/koi/admin_views/templates/show.html.erb.tt:10`).
### Collections & Filters
@@ -122,7 +124,7 @@ Each controller defines an inner `Collection` class extending `Admin::Collection
Including `Koi::Model::Archivable` in the model adds the `archived_at` scope and helpers (`app/models/concerns/koi/model/archivable.rb:17`). When the generator spots an `archived_at` column:
- Extra routes (`archive`, `restore`, `archived`) are added (`lib/generators/koi/admin_route/admin_route_generator.rb:33`).
-- The index view adds a bulk archive action and a link to the archived list (`lib/generators/koi/admin_views/templates/index.html.erb.tt:21`).
+- Index/archived views include bulk archive/restore via selection controls plus a link between active and archived lists (`lib/generators/koi/admin_views/templates/index.html.erb.tt:21`).
- Destroy actions archive first, then delete once already archived (`lib/generators/koi/admin_controller/templates/controller.rb.tt:64`).
Finish the setup by following the dedicated archiving guide (`archiving.md`) for form wiring, strong parameters, UI surfacing, and testing expectations. That guide captures the manual steps discovered while building the Pages module.
@@ -138,7 +140,7 @@ Every admin module is added to `Koi::Menu.modules` with a label derived from the
## Common Customisations
- **Add custom filters** by extending the inner `Collection` and declaring attributes manually; anything you add shows up in `table_query_with` automatically.
-- **Override form layouts** using ViewComponents or partials if you need multi-column layouts—just ensure the submit buttons continue to call `form.admin_save` so styles remain consistent.
+- **Override form layouts** using ViewComponents or partials if you need multi-column layouts—keep form controls save-focused and move non-form actions to header actions.
- **Additional actions** go inside the controller and can be surfaced in the header via `actions_list`.
- **Non-standard inputs** (e.g., slug sync, toggles) can hook into existing Stimulus controllers such as `sluggable` or `show-hide`.
- **Front-end routes** – when marketing pages should appear at `/slug` instead of `/pages/slug`, use the [`root-level-page-routing.md`](./root-level-page-routing.md) constraint pattern after scaffolding the public controller.
@@ -166,6 +168,7 @@ Every admin module is added to `Koi::Menu.modules` with a label derived from the
- Generators overwrite files without prompting. Keep commits small so you can regenerate confidently.
- `belongs_to` relationships only appear on the show page; add your own form fields/filters if you need to edit them in the UI.
- When handling attachments, always call `save_attachments!` before rendering to avoid losing uploads.
+- In request specs, prefer combined redirect assertions (`have_http_status(:see_other).and(redirect_to(...))`).
- Pagination is disabled for orderable lists. If you have a large dataset, consider a dedicated reorder screen instead of relying on the default drag-and-drop.
- Navigation entries are stored in code, not the database. Remember to adjust `config/initializers/koi.rb` when you rename modules.
- After scaffolding, step through the CRUD screens (create → show → edit → delete) to confirm helpers, validations, and menu wiring behave as expected.
diff --git a/docs/koi-5-admin-upgrade-guide.md b/docs/koi-5-admin-upgrade-guide.md
new file mode 100644
index 00000000..a7e41ec9
--- /dev/null
+++ b/docs/koi-5-admin-upgrade-guide.md
@@ -0,0 +1,133 @@
+# Koi 5 Admin Upgrade Guide
+
+## Purpose
+
+Use this guide to upgrade legacy admin modules to Koi 5 while preserving product behavior and reducing drift.
+
+## Preflight checklist
+
+- Ensure the app and admin shell both load (`https://localhost`, `https://localhost/admin`).
+- Install required engine migrations and migrate before feature work.
+- Confirm admin CSS is pure CSS (Koi 5), not Sass-based admin entrypoints.
+- Create a baseline checkpoint commit before module-by-module uplift.
+
+## Module archetype: decide first
+
+Pick lifecycle semantics before touching controller/view code.
+
+- `Simple CRUD` (eg project types, industries): no lifecycle state; optional ordering.
+- `Archivable` (eg staff, clients): boolean lifecycle with `archive/restore`; optional archived index.
+- `Content stateful` (eg articles, pages, case studies): `publish/unpublish` via content state (`draft_version_id` / `published_version_id`).
+
+Do not mix archetypes inside one module unless there is a strong product reason.
+
+## Canonical module workflow
+
+1. Regenerate with `bin/rails generate koi:admin
--force`.
+2. Fix routes first (dedupe generator inserts, correct collection/member actions, confirm helpers with `rails routes`).
+3. Align controller shape to Koi 5:
+ - locals-based rendering
+ - `params.expect`
+ - explicit collection actions
+ - `:see_other` redirect semantics
+4. Move defaults/business invariants to model + migration (not controller setup).
+5. Rework views to Koi 5 patterns (header/actions, concise table rows, summary table where useful).
+6. Update request specs to match intended behavior.
+7. Run module specs, admin request specs, then full suite.
+8. Checkpoint commit.
+
+## Strong params: `expect` rules
+
+`expect` is stricter than legacy `require(...).permit(...)` for repeated nested structures.
+
+- Scalar arrays: `author_ids: []`
+- Repeated nested hashes: `items_attributes: [[:id, :index, :depth]]`
+- Single nested hash: `seo_metadatum_attributes: %i[id title description keywords _destroy]`
+
+Do not translate repeated nested structures from `permit` without checking shape.
+
+## View conventions
+
+- Keep index pages concise; only high-signal fields for triage/navigation.
+- Avoid images on index/archived unless operationally required.
+- State-scoped pages should not show the state column.
+- Prefer explicit path helpers and avoid polymorphic path indirection where readability suffers.
+- Use plain submit buttons; avoid `form.admin_save` / `form.admin_delete` in new Koi 5 work.
+- Keep `form_with` in `new`/`edit` and use shared fields partials (`_metadata_fields`) for common inputs.
+- Keep custom sections (eg SEO) explicit in `edit`, not hidden behind option locals.
+- In ERB, use parentheses for helper calls with arguments, especially helper calls that also take a block.
+
+### Header action helpers
+
+- Use header action links for lifecycle/destructive actions rather than form action buttons.
+- `link_to_delete(record)` always issues a delete request and confirms by default.
+- `link_to_archive_or_delete(record)` archives active records and only shows delete when already archived.
+- For non-standard routes, pass an explicit `url:` to avoid polymorphic mismatch.
+
+### Archivable table UX
+
+- For Koi 5 archivable modules, include selection checkboxes on active and archived indexes.
+- Provide bulk `archive` and bulk `restore` actions via table selection controls.
+
+## Ordering rules
+
+- Keep ordering on the active/current index only.
+- Do not paginate orderable index pages.
+- Paginate non-orderable archived pages by default.
+- Keep ordering concerns separate from lifecycle actions.
+
+## Content module strategy
+
+Use a phased approach:
+
+- `Phase A (top/tail)`: index/new/edit first.
+- `Phase B (editor)`: show/update content editor workflow.
+
+For show/update in content modules:
+
+- Build editor with `Katalyst::Content::EditorComponent.new(container: record)`.
+- Preserve commit semantics (`publish`, `save`, `revert`).
+- Invalid update from show should return turbo editor errors with `:unprocessable_content`.
+
+## Lifecycle migration safety
+
+When converting legacy lifecycle models (eg `active` -> content state):
+
+- Migrate data first to preserve intent (eg inactive -> unpublished).
+- Then remove legacy columns/scopes/UI paths.
+- Update frontend selectors/scopes so hidden records do not reappear.
+
+## Admin CSS + content icons
+
+- Koi 5 admin stylesheet is CSS (`@import url("koi/index.css")`).
+- Content 3 icon overrides use `[data-icon="..."]`; old `value` / `data-item-type` selectors no longer work.
+- Custom content icons should use `viewBox="0 0 16 16"` and `stroke-width="2"`.
+
+## Testing guidance
+
+- Use tight request-spec loops while upgrading each module.
+- Keep tests focused on app behavior, not Katalyst Tables internals.
+- Avoid query-specific table tests like "finds needle" / "hides chaff".
+- Use combined redirect assertions: `have_http_status(:see_other).and(redirect_to(...))`.
+
+## Done criteria for one module
+
+- Routes/helpers are correct and non-duplicated.
+- Lifecycle behavior matches selected archetype.
+- Index/show/edit UX follows Koi 5 conventions.
+- Strong params `expect` shape is correct (especially repeated nested attributes).
+- Module request specs pass.
+- Admin request suite passes.
+- Full suite passes (excluding known pending tests).
+
+## References
+
+- Koi controller patterns: `koi/app/controllers/admin/admin_users_controller.rb`, `koi/app/controllers/admin/url_rewrites_controller.rb`
+- Koi views: `koi/app/views/admin/admin_users/index.html.erb`, `koi/app/views/admin/admin_users/archived.html.erb`
+- Real-world archive/order pattern: `frg/app/controllers/admin/merchandise_controller.rb`, `frg/app/views/admin/merchandise/index.html.erb`
+- Content editor contract: `koi/docs/skills/content-admin.md`
+- Local upgrade examples in this repo:
+ - `kat/app/controllers/admin/staff_controller.rb`
+ - `kat/app/controllers/admin/articles_controller.rb`
+ - `kat/app/controllers/admin/pages_controller.rb`
+ - `kat/app/controllers/admin/case_studies_controller.rb`
diff --git a/docs/koi-user-guide.md b/docs/koi-user-guide.md
index 2f039301..9a201943 100644
--- a/docs/koi-user-guide.md
+++ b/docs/koi-user-guide.md
@@ -174,15 +174,17 @@ In views, use the provided helpers:
- `row.link` outputs a link to the record’s show (or a custom URL) (`app/components/concerns/koi/tables/cells.rb:18`).
- `row.attachment` displays ActiveStorage attachments as downloads/thumbnails (`app/components/concerns/koi/tables/cells.rb:44`).
- Use `table_selection_with` for bulk actions, as shown in `app/views/admin/admin_users/index.html.erb`.
+- For archivable resources, include selection controls on active and archived tables so users can bulk archive and bulk restore.
### Forms
-`Koi::FormBuilder` combines `GOVUKDesignSystemFormBuilder` with helper shortcuts (`lib/koi/form_builder.rb`). Key helpers include:
+`Koi::FormBuilder` combines `GOVUKDesignSystemFormBuilder` with helper shortcuts (`lib/koi/form_builder.rb`).
-- `form.admin_save` / `form.admin_delete` / `form.admin_archive` / `form.admin_discard` for consistent action buttons (`lib/koi/form/builder.rb:13`).
- Automatic admin routes in `form_with` via `Koi::FormHelper` (`app/helpers/koi/form_helper.rb`).
- File field helpers use size limits from configuration.
- `Koi::Form::Content` provides macros for content block editors (heading fields, target selectors, etc.) used with `Katalyst::Content`.
+- In module forms, keep submit controls focused on saving form content (plain submit buttons).
+- Put non-form lifecycle actions in page header actions (`actions_list`) with `link_to_delete(record)` or `link_to_archive_or_delete(record)`.
Remember to call `govuk_formbuilder_init` once when you render password fields so the GOV.UK show/hide toggle initialises (`app/views/admin/sessions/password.html.erb:12`).
diff --git a/docs/skills/content-admin.md b/docs/skills/content-admin.md
new file mode 100644
index 00000000..84018c20
--- /dev/null
+++ b/docs/skills/content-admin.md
@@ -0,0 +1,402 @@
+# Skill: Content Admin (Existing Model)
+
+Use this guide when adding Katalyst Content editing to an already-existing admin resource in a Koi app.
+
+This is the canonical v3 pattern the planned content-admin generator should produce.
+
+## Scope
+
+- Existing model only (hard requirement).
+- Targets `katalyst-content` v3 behavior.
+- Focuses on admin CRUD + editor wiring, not frontend page rendering.
+
+## Generator Contract Checklist
+
+Use this as the short, testable contract for generator templates and generator specs.
+
+1. **Controller helper and editor wiring**
+ - Generated controller includes `helper Katalyst::Content::EditorHelper`.
+ - `show` builds `Katalyst::Content::EditorComponent.new(container: resource)`.
+
+2. **Strong params include content tree**
+ - Generated params include `items_attributes: [%i[id index depth]]`.
+
+3. **Create builds initial draft**
+ - Successful `create` calls `build_draft_version` and persists it.
+
+4. **Update supports content workflow commits**
+ - Handles `commit` values: `publish`, `save`, `revert`.
+
+5. **Update invalid from show renders turbo errors**
+ - Invalid update path can render `editor.errors` with `:unprocessable_content` for turbo stream requests.
+
+6. **Bulk publish/unpublish actions exist**
+ - Generated controller includes collection `publish` and `unpublish` actions.
+ - Generated routes include matching collection endpoints.
+
+7. **Views include editor shell on show**
+ - `show.html.erb` renders `editor.status_bar` and `render editor`.
+
+8. **Expected template set exists**
+ - Generated views include: `index`, `show`, `new`, `edit`, and fields partial.
+
+9. **Force overwrite supported for upgrades**
+ - Generator supports force-overwrite workflows for upgrade diffing.
+
+10. **Existing model only guard**
+ - Generator does not create model/migration files.
+
+## Style expectations
+
+- Use parentheses for method calls with multiple arguments.
+- In views, use parentheses for helper calls that take arguments, including calls that take a block.
+- In DSL-style Ruby code (for example request-spec `get/post/patch` flows), omitting parentheses is acceptable when it reads more clearly.
+- In request specs, prefer combined redirect assertions:
+ - `expect(response).to have_http_status(:see_other).and(redirect_to(...))`
+
+## Prerequisites
+
+Before wiring actions/views, confirm all of the following.
+
+1. **Gem version**
+ - App is on `katalyst-content` v3.
+ - Verify in `Gemfile.lock`.
+
+2. **Model setup**
+ - Model includes `Katalyst::Content::Container`.
+ - Nested `Version` model includes `Katalyst::Content::Version`.
+ - Model has `draft_version_id` and `published_version_id` references.
+
+ Example:
+
+ ```ruby
+ class Page < ApplicationRecord
+ include Katalyst::Content::Container
+
+ class Version < ApplicationRecord
+ include Katalyst::Content::Version
+ end
+ end
+ ```
+
+3. **Admin base controller**
+ - Resource controller inherits from your admin base (typically `Admin::ApplicationController`).
+ - Admin base includes Koi controller concerns.
+
+4. **Content editor mount/config**
+ - Content engine is mounted under admin scope (Koi default: `/admin/content`).
+ - Koi content initializer behavior is active (`base_controller`, errors component).
+
+5. **Resource routing shape**
+ - Decide whether resource lookup is by `id` or `slug`.
+ - Keep this consistent in controller, routes, and link helpers.
+
+6. **Display identity**
+ - Model implements meaningful `to_s` (for headers, breadcrumbs, links).
+
+## Expected Actions
+
+The resource controller should expose these actions and behavior.
+
+### `index`
+
+- Render standard admin collection/list view.
+- Include bulk publish/unpublish actions when supported by routes.
+
+### `show`
+
+- Build editor with `Katalyst::Content::EditorComponent.new(container: record)`.
+- Render locals with both record and editor.
+
+### `new` / `edit`
+
+- Render metadata form only (title/slug/etc), not the content tree.
+- For project-specific metadata concerns (for example SEO), build nested data before rendering edit.
+
+### `create`
+
+- Build and save resource from strong params.
+- On success, create the initial draft:
+ - `record.build_draft_version`
+ - `record.save!`
+- Redirect to admin show.
+- On failure, render `:new` with `:unprocessable_content`.
+
+### `update`
+
+- Assign params first (`record.attributes = ...`).
+- If invalid:
+ - If request came from `show`, return turbo-frame errors from `editor.errors`.
+ - If request came from `edit`, render `:edit` with `:unprocessable_content`.
+- If valid, branch by submit intent:
+ - `publish`: `save!` then `publish!`
+ - `save`: `save!`
+ - `revert`: `revert!`
+- Redirect back to show (or back to previous page) using existing app convention.
+
+### `publish` / `unpublish`
+
+- Collection actions updating selected ids:
+ - `publish!` for each selected record.
+ - `unpublish!` for each selected record.
+- Redirect to index with `:see_other`.
+
+### `destroy`
+
+- If currently published: unpublish and redirect to edit.
+- Else: destroy and redirect to index.
+
+## Controller Example
+
+Adapt this to your resource naming and lookup strategy:
+
+```ruby
+# app/controllers/admin/pages_controller.rb
+module Admin
+ class PagesController < ApplicationController
+ helper Katalyst::Content::EditorHelper
+
+ before_action :set_page, only: %i[show edit update destroy]
+
+ def show
+ editor = Katalyst::Content::EditorComponent.new(container: page)
+ render(locals: { page:, editor: })
+ end
+
+ def create
+ @page = Page.new(page_params)
+
+ if page.save
+ page.build_draft_version
+ page.save!
+ redirect_to(admin_page_path(page), status: :see_other)
+ else
+ render(:new, locals: { page: }, status: :unprocessable_content)
+ end
+ end
+
+ def update
+ page.attributes = page_params
+
+ unless page.valid?
+ case previous_action
+ when "show"
+ editor = Katalyst::Content::EditorComponent.new(container: page)
+ return respond_to do |format|
+ format.turbo_stream { render(editor.errors, status: :unprocessable_content) }
+ end
+ when "edit"
+ return render(:edit, locals: { page: }, status: :unprocessable_content)
+ end
+ end
+
+ case params[:commit]
+ when "publish"
+ page.save!
+ page.publish!
+ when "save"
+ page.save!
+ when "revert"
+ page.revert!
+ end
+
+ if previous_action == "edit"
+ redirect_to(admin_page_path(page), status: :see_other)
+ else
+ redirect_back_or_to(admin_page_path(page), status: :see_other)
+ end
+ end
+
+ def publish
+ Page.where(id: params.expect(id: [])).each(&:publish!)
+ redirect_back_or_to(admin_pages_path, status: :see_other)
+ end
+
+ def unpublish
+ Page.where(id: params.expect(id: [])).each(&:unpublish!)
+ redirect_back_or_to(admin_pages_path, status: :see_other)
+ end
+
+ def destroy
+ if page.published?
+ page.unpublish!
+ redirect_to(edit_admin_page_path(page), status: :see_other)
+ else
+ page.destroy!
+ redirect_to(admin_pages_path, status: :see_other)
+ end
+ end
+
+ private
+
+ attr_reader :page
+
+ def set_page
+ @page = Page.find_by!(slug: params.expect(:slug))
+ end
+
+ def previous_action
+ previous = request.referer&.split("/")&.last
+ %w[show edit].include?(previous) ? previous : "show"
+ end
+
+ def page_params
+ return {} if params[:page].blank?
+
+ params.expect(page: [:title, :slug, { items_attributes: [%i[id index depth]] }])
+ end
+ end
+end
+```
+
+## Expected Views
+
+At minimum, generate and maintain these templates:
+
+- `index.html.erb`
+- `show.html.erb`
+- `new.html.erb`
+- `edit.html.erb`
+- `_fields.html.erb` (or `_form_fields.html.erb`, project convention)
+
+### `show.html.erb`
+
+`show` is where content editing lives. It should render the status bar and editor shell.
+
+```erb
+<%# locals: (page:, editor:) %>
+
+<%= render(editor.status_bar) %>
+<%= render(editor) do |editor_component| %>
+ <% editor_component.with_new_items do |component| %>
+ Content
+
+ <%= component.item(:content) %>
+ <%= component.item(:figure) %>
+
+
+ Layout
+
+ <%= component.item(:section) %>
+ <%= component.item(:group) %>
+ <%= component.item(:column) %>
+ <%= component.item(:aside) %>
+
+ <% end %>
+<% end %>
+```
+
+Notes:
+
+- The item palette is intentionally project-specific.
+- Keep group headings and ordering explicit so upgrades are easy to diff.
+
+### `new` / `edit`
+
+- Render metadata fields only.
+- Keep the form actions focused on saving form content (plain submit button).
+- Place non-form actions (for example view/preview/delete/unpublish) in the page header actions section.
+- Keep content tree editing in `show`.
+
+### Header action link helpers
+
+Use Koi header helpers for destructive/lifecycle links in page-header actions.
+
+- `link_to_delete(record)`
+ - Renders a delete link for persisted records.
+ - Uses `turbo_method: :delete` with a confirmation prompt by default.
+ - Override URL when polymorphic admin paths are not correct for the module.
+
+- `link_to_archive_or_delete(record)`
+ - For archivable models only (`record` must respond to `archived?`).
+ - If record is active, renders an **Archive** action.
+ - If record is already archived, renders a **Delete** action with confirmation.
+
+Example:
+
+```erb
+<% content_for :header do %>
+ <%= actions_list do %>
+ <%= link_to("View", page_path(page), target: "_blank") %>
+ <%= link_to_archive_or_delete(page) %>
+ <% end %>
+<% end %>
+```
+
+### `index`
+
+- Include search/query + table list.
+- Include bulk publish/unpublish buttons when routes/actions exist.
+- Include state column where available.
+- For archivable modules, include row selection with bulk archive/restore actions.
+
+## Tests (First Draft)
+
+Cover behavior with request specs first. These tests define the expected generator contract.
+
+### Required request spec coverage
+
+1. Auth protection for admin endpoints.
+2. `GET show` renders editor UI.
+3. `POST create` creates record and initial draft version.
+4. `PATCH update` invalid from show returns turbo error frame (`:unprocessable_content`).
+5. `PATCH update` with `commit=save` persists changes.
+6. `PATCH update` with `commit=publish` publishes.
+7. `PATCH update` with `commit=revert` reverts draft to published.
+8. `PUT publish` collection action publishes selected records.
+9. `PUT unpublish` collection action unpublishes selected records.
+10. `DELETE destroy` unpublishes when published, destroys when unpublished.
+
+### Example spec snippets
+
+```ruby
+describe "POST /admin/pages" do
+ it "creates an initial draft version" do
+ post admin_pages_path, params: { page: { title: "About", slug: "about" } }
+
+ page = Page.find_by!(slug: "about")
+ expect(response).to have_http_status(:see_other).and(redirect_to(admin_page_path(page)))
+ expect(page.draft_version).to be_present
+ end
+end
+```
+
+```ruby
+describe "PATCH /admin/pages/:slug" do
+ it "returns inline editor errors for turbo requests" do
+ page = create(:page, :with_content_draft, slug: "about")
+
+ patch admin_page_path(page),
+ params: { page: { title: "" }, commit: "save" },
+ headers: { "ACCEPT" => "text/vnd.turbo-stream.html" }
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(response.body).to include("_errors")
+ end
+end
+```
+
+```ruby
+describe "PATCH /admin/pages/:slug" do
+ it "publishes when commit is publish" do
+ page = create(:page, :with_content_draft)
+
+ patch admin_page_path(page), params: { page: { title: page.title }, commit: "publish" }
+
+ expect(response).to have_http_status(:see_other).and(redirect_to(admin_page_path(page)))
+ expect(page.reload).to be_published
+ end
+end
+```
+
+## Force Regeneration Workflow
+
+Force-overwrite is expected for upgrade diffs.
+
+1. Re-run generator with force enabled.
+2. Review generated diff against current implementation.
+3. Keep canonical controller/view contracts from this skill.
+4. Re-apply project-specific customizations (item palette, custom fields, headers).
+5. Run request specs and a manual content editor smoke test.
+
+This keeps upgrades intentional while preserving local behavior where it is deliberately custom.