Feature: Create a custom content type in the Schema builder As an owner or admin I want to define a new content type (name, key and ordered fields) on /schema/new So that users can write entries of that type Background: Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie And API errors are plain text And POST /v1/content-type takes {key, name, fields[{key, label, type, required, unique, default, min, max, options}]} And the builder never sends an "icon" key And POST answers 201 with the content type, 400 invalid_content_type_key, 409 content_type_key_taken, 422 invalid_field, 403 or 500 And the route /schema/new is declared before /schema/:key, has meta auth "verified" and title "Schema", and renders the builder And a route meta flag, not a path pattern, tells the shell that /schema/new needs the custom list only And only owners and admins can use Schema; the SPA decides from the session role, the API still enforces it And the six field types are text "Text", richtext "Rich text", number "Number", boolean "Yes or no", date "Date" and select "Choice" And the limits are a type name of up to 100 characters, a type key of 2 to 40, 1 to 50 fields, a field key of up to 40 characters and 1 to 50 choices And the layout is: Name and Key with the status chip, Discard and Save on top; the FieldList on the left; the FieldInspector on the right; the ApiPreview at the bottom And below 48rem the FieldList is the page, the FieldInspector is a 92% bottom sheet closed with "Done", the ApiPreview is a bottom sheet, and Discard and Save sit in a bottom bar And every string goes through t() and has a French entry in i18n/messages.ts And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change Scenario: Reaching the builder Given the Schema list is Populated or Empty When the user activates "New content type" (header button, empty-state button or the mobile floating button) Then the router navigates to /schema/new And the builder is shown, not the "Content type not found" page And /schema/events and /schema/unknown still show "Content type not found" for a key that is not built-in Scenario: Roles and sessions Given opens /schema/new Then Examples: | who | result | | a verified owner or admin | the builder | | a verified user who is neither | "You don't have access to Schema" with "Go to Home", URL unchanged | | an unverified user | the redirect to /register/pending | | a signed-out visitor | the redirect to /login | Scenario: The builder needs no built-in list Given an owner opens /schema/new as the first page of the session Then GET /v1/content-type is called once for the sidebar And GET /v1/builtin-type is never called And the builder is shown as soon as the session is ready, with no skeleton Scenario: New type, desktop (design state 1) Given an owner opens /schema/new on a 1440 px viewport Then the breadcrumb is "Schema" (a link to /schema) then "New content type" And "Name" is an empty text input with the placeholder "For example, Events", marked required, with maxlength 100 And "Key" is a read-only mono input with the placeholder "events" and the help "Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save." And the list header reads "Fields" and "1 of 50" And one row is shown with the label "Title" and the key "title" in mono, selected, with the badge "New" And "Add field" is shown under the list And the inspector title is "Field: Title" with the badge "New" And "Label" holds "Title" And "Key" holds "title" with the Auto badge and the help "The name used in the API. Starts with a letter. Lowercase letters, digits and underscores." And the type tiles show Text selected And "Required" is off with "An entry can't be saved with this field empty." And "Unique values" is off And "Default value" is empty with "Used when an entry is created and this field is left out." And "Shortest" and "Longest" are empty with "Counted in characters. Entries show a counter." And "Save" and "Discard" are disabled And no status chip is shown And no "entries use this content type" bar is shown And the API preview bar is collapsed Scenario: New type, mobile (design state 1) Given the same page on a 390 px viewport Then the topbar shows an arrow-left and the title "New content type" And Name and Key are stacked above the list, then the rows, then the "API preview" row with a chevron And no inspector is shown until a row is activated And the bottom bar holds Discard and Save Scenario Outline: The key follows the name until it is edited Given an empty new type When the user types the name "" Then the Key holds "" with the Auto badge, read-only Examples: | name | key | | Events | events | | Press mentions | press-mentions | | Café & Bar | cafe-bar | | Job openings! | job-openings | | 2026 Awards | 2026-awards | | !!! | (empty, mode empty) | | a name of 60 letters | the first 40 characters, without a trailing hyphen | Scenario: Editing and resetting the key Given the name "Events" and the Auto key "events" When the user activates the pencil Then the Key becomes editable, the Auto badge goes and "Reset to match the name" is shown When the user types the key "agenda" then edits the name to "Events and talks" Then the Key stays "agenda" When the user activates "Reset to match the name" Then the Key is "events-and-talks" with the Auto badge Scenario Outline: Type-level rules (design states 6 and 14) Given a new type with a valid field When the user sets to and activates Save Then is shown under with an alert icon And POST /v1/content-type is not called Examples: | input | value | message | | Name | (empty or spaces) | Give the content type a name. | | Key | e | Use 2 to 40 lowercase letters, digits or hyphens. | | Key | Events | Use 2 to 40 lowercase letters, digits or hyphens. | | Key | my_type | Use 2 to 40 lowercase letters, digits or hyphens. | | Key | -events | Use 2 to 40 lowercase letters, digits or hyphens. | | Key | events--2 | Use 2 to 40 lowercase letters, digits or hyphens. | | Key | new | The key "new" is reserved. Choose a different key. (proposed copy) | Scenario Outline: Accepted type keys Given a new type with a valid field and the name "X" When the user types the key "" and activates Save Then POST /v1/content-type is called with that key Examples: | key | | ab | | press-mentions | | 2026-awards | | 40 lowercase letters | Scenario: Type-level errors together (design state 6) Given a new type with no name, the key "e" and no field When the user activates Save Then "Give the content type a name.", "Use 2 to 40 lowercase letters, digits or hyphens." and "A content type needs at least one field." are shown And the empty list shows "No fields yet" with the message in the error colour And the chip reads "3 problems to fix" And Save is disabled until all three are fixed, and Discard stays enabled And the inspector shows "Field settings" and "Add a field, then choose its type and rules here." Scenario: When errors are shown Given a new type whose name is empty Then no error is shown while nothing was edited When the user edits an input, an error for that input shows as they type And when the user activates Save, every error of the form shows at once And errors are tied to their input with aria-describedby and aria-invalid And a row with an error shows an alert icon, not only the red border Scenario Outline: The status chip Given Then the chip reads "" Examples: | situation | chip | | a pristine new type | (no chip) | | an edit with no error | Unsaved changes | | three inputs in error | 3 problems to fix | | one input in error | 1 problem to fix | | the POST in flight | Saving… | | the POST failed with 500 | Save failed | | navigator.onLine is false | Waiting for connection | Scenario Outline: Counting problems Given Then the chip counts Examples: | errors | count | | no name, a one-letter key, no field | 3 | | a field with no label, a field with min above max, a choice list with a blank and a duplicate | 3 | | one choice list with a blank row and a duplicate row | 1 | | one field with a bad key | 1 | Scenario: Field list rows Given a type with the fields Title, Starts on, Online Then each row shows a grip button labelled "Move