@bettercms-ai/mcp 0.73.0 → 0.73.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2989,6 +2989,8 @@ var PARAM_TEXT = {
2989
2989
  recommendedMax: "a SOFT limit (characters, items or value): the editor warns past it, never refuses",
2990
2990
  openMode: "object/array/group/repeater: how the value (or each row) opens: 'inline', 'drill', 'popover' or 'dialog'",
2991
2991
  fromBuiltin: "a builtin:* id from list_components: copies it into the project; fields you send win",
2992
+ componentPreview: "a slot row's look: {title?, subtitle?, media?} TOP-LEVEL prop keys, fallback?, icon?; null clears. \xA73 of bettercms://playbook/schema",
2993
+ componentIcon: "the block icon, a dock icon name; null clears. \xA73 of bettercms://playbook/schema",
2992
2994
  describes: "for editors: what it is and holds; omitted: written for you",
2993
2995
  runtimeBundle: "instead of pageId: the server-app bundle cleanup",
2994
2996
  pageRef: "page id or path ('/de/about'); a bare slug only when one page has it",
@@ -3034,7 +3036,7 @@ var DETACHED_COPY_NOTE = "{overrides?}: a rich-text embed's separate-copy `field
3034
3036
  var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
3035
3037
  var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
3036
3038
  var SLOT_MULTIPLE_NOTE = "'slot' with config.multiple holds a list [{componentId, overrides?}] (config.maxItems, default 12, at most 50): target.path 'props.children' on a section. V2 slots, 'table' mode 'types': playbook \xA75.";
3037
- var PROP_V2_NOTE = "Props take the fields' V2 keys (\xA714): 'object' = ONE {child: value} from config.fields, \u226410 levels (field_depth_exceeded); datetime|slug|email|phone|json|location|tags|rating|code|reference|multi-reference as that field (a reference names contentModelId, or polymorphic + allowedModelSlugs; page reads hydrate its entries); helpMore, advanced, recommendedMax, openMode, config.scale, ui.layout rows|chips|gallery, ui.preview.fallback, showIf notEmpty|empty.";
3039
+ var PROP_V2_NOTE = "Props take the fields' V2 keys (\xA714): 'object' = ONE {child: value} from config.fields, \u226410 levels (field_depth_exceeded); datetime|slug|email|phone|json|location|tags|rating|code|reference|multi-reference as that field (a reference names contentModelId, or polymorphic + allowedModelSlugs; page reads hydrate its entries); helpMore, advanced, recommendedMax, openMode, config.scale, ui.layout rows|chips|gallery, ui.preview.fallback, showIf notEmpty|empty. Send helpText: null for a prop whose /kit schema has no description (omitted, the platform writes one).";
3038
3040
  var LAYOUT_LOCK_NOTE = "A Global field with `overridable:false` + `lockReason` (add-field `field`, update-field `patch`) refuses page overrides (LAYOUT_FIELD_NOT_OVERRIDABLE); get_layout scope page lists stored ones as `shadowed`.";
3039
3041
 
3040
3042
  // src/entry-query.ts
@@ -3268,10 +3270,10 @@ var propUiObject = uiObject.extend({
3268
3270
  }).optional().describe("the group this prop (or sub-field) sits in. The FIRST member defines {id, label, kind?, icon?, collapsed?, description?, columns?, collapsible?, layout?, order?}; later ones send only {id}."),
3269
3271
  tab: z.strictObject({
3270
3272
  id: z.string().min(1).max(40),
3271
- label: z.string().min(1).max(20).optional(),
3273
+ label: z.string().min(1).max(40).optional(),
3272
3274
  icon: uiObject.shape.icon,
3273
3275
  order: z.number().int().min(0).max(99).optional()
3274
- }).optional().describe("TOP-LEVEL props only: the dock tab this prop sits in (a V2 kit schema `groups` entry: Content, Design, \u2026). The FIRST prop in a tab defines {id, label, icon?, order?}; later ones send only {id}. \u22648 tabs, labels unique; a group's members share one tab.")
3276
+ }).optional().describe("TOP-LEVEL props only: the dock tab this prop sits in (a V2 kit schema `groups` entry: Content, Design, \u2026). The FIRST prop in a tab defines {id, label, icon?, order?}; later ones send only {id}. \u22648 tabs, labels \u226440 chars and unique; a group's members share one tab.")
3275
3277
  });
3276
3278
  var choiceOption = z.union([z.string(), z.object({ label: z.string(), value: z.string(), icon: z.string().optional(), description: z.string().optional() })]);
3277
3279
  var fieldShape = {
@@ -3664,7 +3666,7 @@ var withProjectId = (def) => ({
3664
3666
  return currentProject.run(arg("projectId"), () => currentBranch.run(arg("branchId"), () => def.handler(args)));
3665
3667
  }
3666
3668
  });
3667
- var autoHelpNote = (args) => typeof args.helpText === "string" && args.helpText.trim() ? "" : " No helpText was sent, so the platform wrote it (helpSource: auto); send helpText with update_field to write your own.";
3669
+ var autoHelpNote = (args) => args.helpText === null || typeof args.helpText === "string" && args.helpText.trim() ? "" : " No helpText was sent, so the platform wrote it (helpSource: auto); send helpText with update_field to write your own.";
3668
3670
  function buildToolDefs(deps) {
3669
3671
  async function withClient(fn) {
3670
3672
  const token = await deps.auth.getAccessToken();
@@ -3722,7 +3724,7 @@ function buildToolDefs(deps) {
3722
3724
  ]).describe("block type; section/slider/tabs/columns nest child blocks"),
3723
3725
  id: z.string().min(1).describe("stable unique block id"),
3724
3726
  props: z.record(z.string(), z.unknown()).describe(
3725
- "per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href, variant? (hosted pages; else a Button component), size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean, rel? nofollow|sponsored|ugc}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, source? {modelId}, limit?, filters?, sort?, pinnedIds?, excludeCurrent?}: a collection's published entries as cards (every prop: skill content-modeling)"
3727
+ "per-type props by block type: \xA75 of bettercms://playbook/schema (text/richtext {html}, NOT {text}; button {text, href, variant?, size? sm|md|lg, fullWidth?}; component {componentId, overrides?}). Containers nest blocks to any depth."
3726
3728
  ),
3727
3729
  style: z.record(z.string(), z.unknown()).optional().describe(
3728
3730
  "design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom, border (all four sides, in the text colour), hideOn [mobile|tablet|desktop] (all three hides it on the page: kept in the draft, never published). A real marketing band is a `section` block carrying bg + padding + contentWidth."
@@ -3971,6 +3973,7 @@ function buildToolDefs(deps) {
3971
3973
  // 'select' — required, choices
3972
3974
  // 'group'/'table'/'object' sub-shape. Typed loosely here (the server validates the full
3973
3975
  // recursive shape, 10 levels at most) so a deep tree needs no Zod mirror in the client.
3976
+ // A sub-field may be a 'slot' (its value at its key in the object or row), with the slot keys above.
3974
3977
  fields: z.array(z.record(z.string(), z.unknown())).optional(),
3975
3978
  min: z.number().optional(),
3976
3979
  // 'number'
@@ -3982,7 +3985,7 @@ function buildToolDefs(deps) {
3982
3985
  // override that omits it still saves — so it is a marker the dashboard renders and
3983
3986
  // get_site_composition counts, never a refusal. Parity: componentPropDefSchema on the server.
3984
3987
  required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
3985
- helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
3988
+ helpText: z.string().max(200).nullable().optional().describe("hint shown under the control, muted (\u2264 200 chars). null = no help on purpose (a /kit schema with no description); omitted = the platform writes it"),
3986
3989
  placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
3987
3990
  // Stored markers on a prop: nothing refuses a prop value for them. Declared so an echo keeps them.
3988
3991
  readOnly: z.union([z.literal(true), z.literal("developer")]).optional(),
@@ -4071,6 +4074,13 @@ function buildToolDefs(deps) {
4071
4074
  "section family, e.g. 'Hero' \u2014 set this with a library category to make the component selectable in the page editor's 'Add a section' picker. Components sharing one are layout variants of one section."
4072
4075
  );
4073
4076
  const allowedOn = z.array(z.string().regex(/^(\*|slug:[a-z0-9-]+|type:(singleton|dynamic|template))$/)).max(100).describe("placement allowlist: *, slug:<page-slug>, and/or type:singleton|dynamic|template");
4077
+ const componentPreviewArg = z.strictObject({
4078
+ title: z.string().min(1).max(64).optional(),
4079
+ subtitle: z.string().min(1).max(64).optional(),
4080
+ media: z.string().min(1).max(64).optional(),
4081
+ fallback: z.string().min(1).max(120).optional(),
4082
+ icon: dockIconArg.optional()
4083
+ }).nullable().describe(PARAM_TEXT.componentPreview);
4074
4084
  const createComponentInput = z.object({
4075
4085
  name: z.string().min(1),
4076
4086
  slug: slug.describe("url-safe unique slug (lowercase letters/numbers/hyphens)"),
@@ -4082,6 +4092,8 @@ function buildToolDefs(deps) {
4082
4092
  projectId: z.string().nullable().optional().describe("owning project id; null creates a workspace-wide global component"),
4083
4093
  allowedOn: allowedOn.optional(),
4084
4094
  description: z.string().optional().describe(PARAM_TEXT.describes),
4095
+ preview: componentPreviewArg.optional(),
4096
+ icon: dockIconArg.nullable().optional().describe(PARAM_TEXT.componentIcon),
4085
4097
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
4086
4098
  props: z.array(componentPropObject).default([]).describe(
4087
4099
  "overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all. 'button' is ONE unit {label, href, variant?, size?, arrow?, icon?, newTab?} on a button block's 'props', never a text prop plus a url prop. 'link' an href, 'date' YYYY-MM-DD, 'color' a hex, 'file' {url, name?}, 'icon' an icon name or an image, 'multiselect' a string[] from config.options (required). " + SLOT_MULTIPLE_NOTE + " " + PROP_V2_NOTE
@@ -4098,6 +4110,8 @@ function buildToolDefs(deps) {
4098
4110
  variantOf: z.string().min(1).max(255).optional().describe("see create_component"),
4099
4111
  allowedOn: allowedOn.optional().describe("REPLACES the placement allowlist"),
4100
4112
  description: z.string().optional(),
4113
+ preview: componentPreviewArg.optional(),
4114
+ icon: dockIconArg.nullable().optional().describe(PARAM_TEXT.componentIcon),
4101
4115
  blockJson: z.array(blockObject).optional().describe("REPLACES the block tree"),
4102
4116
  props: z.array(componentPropObject).optional().describe(
4103
4117
  "REPLACES the prop array \u2014 include every prop you want to keep, WITH its stored `ui`, or that prop's authoring chrome is dropped along with the prop. Read get_component first."
@@ -4106,7 +4120,7 @@ function buildToolDefs(deps) {
4106
4120
  propGroup: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe("prop key \u2192 its ui.group ({id, label?, kind?, icon?, collapsed?}), null takes it out"),
4107
4121
  propTab: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe("prop key \u2192 its ui.tab ({id, label?, icon?, order?}), the V2 schema group it sits in. The FIRST top-level prop in a tab defines {id, label, icon?, order?}, later ones send {id}; \u22648 tabs; one group's members share one tab. null takes it out"),
4108
4122
  propLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe("prop key \u2192 label"),
4109
- propHelpText: z.record(z.string(), z.string().max(200).nullable()).optional().describe("prop key \u2192 help text, null clears it"),
4123
+ propHelpText: z.record(z.string(), z.string().max(200).nullable()).optional().describe("prop key \u2192 help text; null = no help on purpose (stays empty, never refilled)"),
4110
4124
  override: z.boolean().optional().describe(OVERRIDE_NOTE),
4111
4125
  ifMatch: z.number().int().min(0).optional().describe(COMPONENT_IF_MATCH_NOTE),
4112
4126
  confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
@@ -6529,8 +6543,14 @@ Then declare what an editor may change, via \`props\`:
6529
6543
  ]
6530
6544
 
6531
6545
  Each prop also takes \`required\`, \`helpText\` (\u2264 200 chars) and \`placeholder\` (\u2264 120), which the
6532
- dashboard's prop editor renders. They are HINTS: nothing on the write path refuses a placement
6546
+ dashboard's prop editor renders. \`helpText\` is three-state: a string is yours, \`null\` is no help on
6547
+ purpose (send it for a prop whose /kit schema has no description; nothing refills it), and omitted
6548
+ lets the platform write one. They are HINTS: nothing on the write path refuses a placement
6533
6549
  that leaves a required prop empty, so \`get_site_composition\` is what reports one.
6550
+ A /kit schema's document-level \`preview\` \u2192 the component's \`preview\`: how ONE instance reads as a ROW in a parent's slot list. {title?, subtitle?, media?} each name a TOP-LEVEL prop key (422 \`preview_key_unknown\` otherwise), \`fallback?\` is a literal row label (\u2264 120) and \`icon?\` a dock icon name; null clears it.
6551
+ A /kit schema's document-level \`icon\` \u2192 the component's \`icon\` (a dock icon name from bettercms://icons/dock, null clears): the block icon on the root crumb, slot rows and slot child frames.
6552
+
6553
+ **Prop types, one rule each.** 'select' REQUIRES \`config.options\` (a default must be one of them). 'group'/'table'/'object' REQUIRE \`config.fields\` (\`[{key,label,type,options?,fields?}]\`); a group writes an object, a table an array of them (\`config.mode: 'data'\`: ONE V2 table value, no fields). 'number' takes optional \`config.min/max/step\`. 'slot' holds ONE nested component instance: \`target.path: 'props'\` on an EMPTY component block (\`props.componentId: ""\`), \`config.componentIds\` restricts fills, defaultValue \`{componentId, overrides?}\`. 'form' holds ONE Form document: \`target.path: 'props'\` on an EMPTY form block (\`props.formId: ""\`), defaultValue \`{formId}\`, never the hydrated \`{formId, form}\` (400); do NOT re-declare the form's FIELDS as props, the Form document owns them. 'button' is ONE unit \`{label, href, variant?, size?, arrow?, icon?, newTab?}\` on a button block's 'props', never a text prop plus a url prop. 'link' an href, 'date' YYYY-MM-DD, 'color' a hex, 'file' \`{url, name?}\`, 'icon' an icon name or an image, 'multiselect' a string[] from \`config.options\` (required).
6534
6554
 
6535
6555
  \u{1F534} **On a components-first page, \`props\` is the ONLY editing surface.** Click-to-edit binds
6536
6556
  \`heading\`/\`text\`/\`button\`/\`image\` blocks; it does **not** bind a \`component\` block, because
@@ -6582,9 +6602,16 @@ A \`slot\` field (V2 blocks inside a block) takes the same \`config.blockSlugs\`
6582
6602
  but stores each child INLINE: \`[{ _key, _type: '<block slug>', _variant?, _settings?, ...values }]\`,
6583
6603
  its values judged by its block's fields. A component's \`slot\` prop with \`config.multiple\`
6584
6604
  takes the same child shape with \`_type\` the COMPONENT id (no \`_variant\`: a variant is its own
6585
- component). A list of several item shapes on a component is a \`table\` prop with
6586
- \`config.mode: 'types'\` and \`config.itemTypes: [{ name, label?, fields }]\`, rows
6587
- \`[{ _key?, _type: '<name>', ...values }]\`.
6605
+ component). A \`slot\` may also be a SUB-FIELD: inside a \`group\` / \`object\` prop's
6606
+ \`config.fields\` or a \`table\` row (an item type's \`fields\` too), with the slot prop's
6607
+ config keys (\`componentIds\`, \`multiple\`, \`minItems\`/\`maxItems\`, \`slotSettings\`). Its value
6608
+ sits at its key in the object or row, \`{ componentId, overrides? }\` or a list with
6609
+ \`config.multiple\`, judged like a slot prop's: the same allowlist, depth cap and cycle refusal.
6610
+ A list of several item shapes on a component is a \`table\` prop with
6611
+ \`config.mode: 'types'\` and \`config.itemTypes: [{ name, label?, fields, preview?, icon? }]\`, rows
6612
+ \`[{ _key?, _type: '<name>', ...values }]\`. An item type's \`preview\` names its OWN fields (dotted ok, \`<table>.caption\`).
6613
+
6614
+ **Block props by type** (a block's \`props\` in any \`blockJson\`): heading {text, level 1-6}; text/richtext {html} (NOT {text}), both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only); image {src (asset url), alt, caption?}; button {text, href, variant (hosted pages; else a Button component), size sm|md|lg, fullWidth, arrow, icon (a ui.icon name), newTab, rel nofollow|sponsored|ugc}; spacer {height px}; video {url, poster?}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns:[{heading?,links}], copyright?}; section {children: block[], background?, paddingY?, maxWidth?}; columns {columns: block[][], gap} (a column may hold ANY block); slider {slides:[{id,children:block[]}]}; tabs {tabs:[{id,label,children:block[]}]}; collection {cardComponentId?, source? {modelId}, limit?, filters?, sort?, pinnedIds?, excludeCurrent?}: a collection's published entries as cards. Containers nest blocks to any depth.
6588
6615
 
6589
6616
  A component inside a \`richtext\` value is \`{ _type: 'bcmsBlock', _key, schemaKey: 'component',
6590
6617
  schemaLabel?, fields: { componentId, overrides?, variant? } }\` and FOLLOWS the component: publishing
@@ -7038,8 +7065,10 @@ hints the dashboard renders \u2014 they are NOT enforced at write time, so an ov
7038
7065
  required prop still saves; \`get_site_composition\` is what reports those. Use \`select\` with
7039
7066
  \`config.options\` for layout / theme / alignment / spacing variants that change only styling,
7040
7067
  and a SEPARATE variant component when the markup differs. Repeatable list \u2192 \`table\`; fixed
7041
- cluster \u2192 \`group\`. Nesting is capped at TWO levels from the prop (a \`table\` row may hold a
7042
- \`table\`, and that inner row is scalars only). Flatten anything deeper.
7068
+ cluster \u2192 \`group\`. Nesting goes up to 10 levels counting the prop itself. A \`table\` row may hold a
7069
+ \`table\` (rows), a data table (\`config.mode: "data"\`) or a polymorphic list
7070
+ (\`config.mode: "types"\`), so a size guide's rows can each carry a chart and a multi-step
7071
+ form's steps can each carry their fields. Deeper is refused (\`field_depth_exceeded\`).
7043
7072
 
7044
7073
  **5. Design, responsiveness and accessibility.** \`componentize_sections\` and \`npx
7045
7074
  @bettercms-ai/convert --componentize\` move the section's markup VERBATIM \u2014 classes, responsive
@@ -7234,7 +7263,7 @@ conversion is yours to write.
7234
7263
  markup inside a \`text\` field.
7235
7264
  **Prose MIGRATED from another CMS needs one extra check.** Every block's \`_type\` must be one
7236
7265
  this platform stores \u2014 \`block\`, \`table\` (\`{_type:"table", rows:[{_key, cells:[{_key,
7237
- content:[...blocks], header?, group?}], group?}], caption?, notes?, rowGroups?, columnGroups?}\`,
7266
+ content:[...blocks], header?, group?, highlight?, sortable?}], group?, footer?, highlight?}], caption?, notes?, rowGroups?, columnGroups?, filterable?, jsonLd?: {type: "ItemList"|"Dataset"|"Offers", axis?, priceRow?}, design?: {responsive?: "stack"|"cards", sticky?}}\`,
7238
7267
  groups \`[{_key, label}]\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
7239
7268
  is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, \`pullQuote\`
7240
7269
  (\`{quote, name, role?, link?}\`), \`keyTakeaways\` (\`{title?, points:[...strings]}\`,
@@ -7406,7 +7435,7 @@ back each prop's stored \`ui\` or you delete it along with the prop. Props have
7406
7435
  prop joins a group with \`ui.group\`: the FIRST prop of a group defines \`{ id, label, kind?, icon?,
7407
7436
  collapsed? }\` (kind \`group\` or \`row\`; a tab is \`ui.tab\`, never a group kind), and every later prop
7408
7437
  sends \`{ id }\` only. A prop joins a TAB with \`ui.tab\` the same way: the FIRST top-level prop in a tab
7409
- defines \`{ id, label, icon?, order? }\`, later props send \`{ id }\`; at most 8 tabs, labels \u226420 characters
7438
+ defines \`{ id, label, icon?, order? }\`, later props send \`{ id }\`; at most 8 tabs, labels \u226440 characters
7410
7439
  and unique; top-level props only (never a sub-field); all members of one \`ui.group\` share one tab. A V2
7411
7440
  kit entry's schema \`groups\` and each field's \`group\` map 1:1 to \`ui.tab\`, so its \`design\` group is a
7412
7441
  tab named Design holding the theme, sticky and similar props. A FIELD never takes \`ui.group\` or
@@ -7484,7 +7513,7 @@ Importance orders a screen: required fields first, then array order.
7484
7513
  An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`openMode_leaf\`; a bad \`config.scale\` with \`scale_invalid\`; more than 10 nested field levels with \`field_depth_exceeded\`. update_field sets and clears (null) helpMore, advanced, recommendedMax and openMode on an existing field.
7485
7514
 
7486
7515
  **Tabs.** A \`kind: 'tab'\` fieldset is a tab:
7487
- - at most **3** tabs, names at most 20 characters and unique;
7516
+ - at most **3** tabs, names at most 40 characters and unique;
7488
7517
  - top-level fields only: tabs never nest (a component's props take tabs through \`ui.tab\`, its own rules);
7489
7518
  - untabbed fields stay PINNED above the strip;
7490
7519
  - a group or row fieldset joins a tab with \`tab: '<tab id>'\`; groups in a non-first tab default open;
@@ -7766,7 +7795,7 @@ Read \`bettercms://playbook/schema\` \xA712 first \u2014 this is the short form
7766
7795
  4. **Fields** = \`props\`: text, richtext, image, url, boolean, number, select, group, table, slot.
7767
7796
  Always a \`defaultValue\`; \`required\` / \`helpText\` / \`placeholder\` are editor hints, not write
7768
7797
  -time enforcement. \`select\` for styling-only variants, a separate component when the markup
7769
- differs. Nesting is capped at two levels \u2014 flatten anything deeper.
7798
+ differs. Nesting goes up to 10 levels; a \`table\` row may hold a data table or a polymorphic list.
7770
7799
  5. **Quality.** The componentize lane and \`npx @bettercms-ai/convert --componentize\` move markup
7771
7800
  VERBATIM \u2014 classes, responsive utilities, aria attributes, alt text all survive. Never
7772
7801
  restyle. Dry-run first.