Skip to content

feat(document-api): create named paragraph and character styles - #3976

Open
Nathaniel-260 wants to merge 1 commit into
superdoc:mainfrom
Nathaniel-260:feat/styles-define
Open

feat(document-api): create named paragraph and character styles#3976
Nathaniel-260 wants to merge 1 commit into
superdoc:mainfrom
Nathaniel-260:feat/styles-define

Conversation

@Nathaniel-260

@Nathaniel-260 Nathaniel-260 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Relates to #3975

What changed

styles.create — define or redefine a named paragraph or character style. It is
the missing half of styles.getCatalog: what the catalogue can describe, this
can author. #3975 has the full argument for why the gap exists and why
templates.apply is the wrong tool for it; this PR is the contract.

Additive, 19 files. New operation in
packages/document-api/src/styles/create.ts, registered the way every other
operation is: operation-definitions.ts (via mutationOperation()),
operation-registry.ts, hand-written schemas in contract/schemas.ts,
invoke/invoke.ts, and the four sites in src/index.ts. Nothing generated is
committed.

api.styles.create({
  id: 'Quote',
  name: 'Quote',
  type: 'paragraph',
  basedOn: 'Normal',
  next: 'Normal',
  qFormat: true,
  priority: 29,
  paragraph: { indent: { left: 720 }, spacing: { before: 240 } },
  run: { italic: true },
});

The decisions worth arguing with are in the commit message; the two that touch
existing code are below.

The exclusion list is now a property of the destination

EXCLUDED_KEYS was the docDefaults exclusion list and the only list, so
every caller inherited a restriction only one of them is subject to. Four of its
run entries — w:cs, w:highlight, w:oMath, w:rtl — are disallowed in
w:docDefaults and legal on a named w:style; the header comment in
registry.ts already says so ("intentionally disallowed in Word
docDefaults"), and all four are read back off a w:style by
StyleDefinition.runProperties in @superdoc/style-engine.

w:rtl is the property that makes a run right-to-left. Without the split, no
right-to-left style is expressible through this API — the operation would ship
unable to author a style for Hebrew, Arabic, Persian or Urdu.

So EXCLUDED_KEYS_BY_SCOPE keys the list by 'docDefaults' | 'style', the
docDefaults list is byte-for-byte what it was, and the four new registry
entries are reachable under the style scope alone.

styles.apply is unchanged, and two things had to be fixed to keep it that way

Both were caught by tests written for this PR, and both fail without the fix:

  1. buildStateSchema is scoped as well as buildPatchSchema. Otherwise the
    four keys land in the published before / after maps of a docDefaults
    receipt — the exact keys that scope rejects, in objects carrying
    additionalProperties: false, so receipt validation would have loosened too.
  2. classifyPatchKey asks whether a key is reachable on the other channel
    in this scope.
    Putting the four in ALLOWED_KEYS_BY_CHANNEL.run meant a
    run property sent to the paragraph channel started answering cross_channel
    instead of excluded, dropping the excluded_docdefaults_key reason a
    caller branches on.

Verified rather than asserted. Generating document-api-contract.json on this
branch and on the merge-base and deep-diffing per operation:

base head
operations 427 428
added styles.create
removed none
changed capabilities.get only, and only because it enumerates operation ids
styles.* inherited from main, changed 0 of 5

styles.apply is byte-identical. Same accepted keys, same rejection messages,
same detail codes, same published input and output schemas.

Two more gates run against the generated contract:

  • All four styles.create schemas compile in a 2020-12 validator, with no
    dangling $ref. This is why the failure schema types code as a string
    rather than an enum derived from possibleFailureCodes: that list is empty
    while the operation ships without an adapter, and enum: [] does not compile
    — it takes the whole output oneOf with it, so a consumer could not validate
    even a success receipt. styles.apply publishes the same open shape.
  • 37 inputs run through the hand-written validator and the published input
    schema side by side: 0 disagreements. That gate is why one rule is not
    enforced — an alias equal to the style's own name. It is redundant rather than
    corrupting, and JSON Schema cannot compare sibling fields, so the rule would
    live only in the validator and a caller pre-validating against the published
    contract would get a green light and then a throw. The comma rule, which does
    corrupt w:aliases, is enforced on both sides and expressed as a pattern.

What is not in this PR

The adapter. The mutation lives in @superdoc/docx-engine, which is not in
this repository, so executeStylesCreate throws CAPABILITY_UNAVAILABLE until
the engine side lands — the same way styles.getCatalog fails closed when its
optional hook is absent.

Two consequences are deliberate, and I would rather state them than have them
discovered after merge:

  • possibleFailureCodes is NONE_FAILURES, because with no adapter there is no
    code a host can currently produce and the contract must not over-declare.
  • A merged contract is advertised by capabilities.get() before it can
    succeed
    , and a feat: release publishes it into the JSON schemas, the agent
    artifacts and the reference site. OperationDefinitionEntry has no
    status / planned field, so the contract cannot currently say "defined, not
    yet implemented".

That second point is a decision for you, not a detail — it is the question at
the bottom of #3975. Please do not merge this until it is answered. If you
would rather implement contract and adapter together and drop this, say so and
I will close it; the shape is yours to use.

Checks

pnpm run <script> does not run on Windows in this repo (the root prepare
hook is POSIX, and scripts/test.mjs calls spawnSync('pnpm.cmd') without
shell: true), so each stage was invoked directly. CI runs the real scripts.

  • tsc -b packages/document-api — clean
  • bun test packages/document-api/src — 1800 pass, 1801 run. The one failure,
    executeParagraphsSetTabStop > rejects clear as a setTabStop alignment, fails
    identically on the merge-base and is untouched by this branch
  • check-contract-parity — 428 operations, 428 API members
  • check-contract-outputs — 5 generated files
  • check-agent-artifacts — 3 files
  • check-stable-schemas — 2 files
  • check-documented-operations — 428 operations, 468 pages
  • vp fmt --check on all 19 files — clean
  • vp lint on all 19 files — 0 errors; the 24 warnings are pre-existing in
    index.ts and schemas.ts and name no symbol this PR adds
  • tests/consumer-typecheck/src/document-api-styles-create.ts — new fixture,
    asserting both the parameter and the return shapes; compiles clean against the
    built .d.ts under the fixture's own strict settings

Tests: validation and routing in styles/create.test.ts, scope filtering of
both schema builders in styles/schema.test.ts, direct coverage of the public
classifyPatchKey export, an invoke parity case, and the SD-2018 registry
gate pinned per scope. Every branch was checked by mutation — each test was
confirmed to fail against the corresponding broken code, not merely to pass
against the working code.

Review in cubic

Nothing in the Document API brings a named style into existence. styles.apply
writes w:docDefaults and is validated as such (target.scope must be
"docDefaults"); styles.paragraph.setStyle and setStyleRef apply a style that is
already in the document, by styleId or by one of four semantic roles;
styles.getCatalog reads. So a caller who wants a "Question" or a "Quote" style
has one route left: synthesize a whole .docx and hand it to templates.apply,
whose input is { source, bodyPolicy } with no scope selector — which parts of
the document it also adopts is then decided by the shape of the synthesized
package rather than by the caller's request.

styles.create is the missing half of styles.getCatalog: what the catalogue can
describe, this can author.

Contract only. The adapter that writes word/styles.xml is not in this
repository, so the operation reports CAPABILITY_UNAVAILABLE until the engine
side lands — the same way styles.getCatalog fails closed when its optional
catalogue hook is absent. Two consequences are deliberate and worth stating
rather than discovering: possibleFailureCodes is NONE_FAILURES, because with no
adapter there is no code a host can currently produce and the contract must not
over-declare; and a merged contract is advertised by capabilities.get() before
it can succeed, which is a decision for the maintainers, not a side effect to
be discovered after merge.

Design notes, each of which had a plausible alternative:

- Named .create, not .define. The create.* namespace is body content, but the
  .create leaf is already how this codebase names a durable object that lives
  in an auxiliary part and is referenced from the body — lists.create writes a
  numbering definition into word/numbering.xml, which is structurally the same
  operation on a different part.

- The input is a discriminated union rather than one flat interface with
  cross-field checks, matching StylesApplyInput and ListsCreateInput. A
  character style cannot carry next or paragraph properties, and that is now a
  compile error rather than a runtime one.

- Fields are named id and priority, not styleId and uiPriority, so that what
  StyleCatalogItem reads back is what this writes. priority stays number | null
  and is not capped at Word's 0..99 UI band, because the catalogue can return
  values outside it and a cap here would break the round trip.

- The exclusion list is now a property of the destination. EXCLUDED_KEYS was
  the docDefaults list and the only list, so every caller inherited a
  restriction only one of them was subject to. Its four run entries that Word
  forbids in docDefaults and allows on a named style — w:cs, w:highlight,
  w:oMath, w:rtl — join the registry and are reachable under the new style
  scope alone. w:rtl is the property that makes a run right-to-left, so
  without this split no right-to-left style could be authored through this
  API at all. Each of the four is already read back off a w:style by
  StyleDefinition.runProperties in the style engine.

  styles.apply is unchanged in every observable way, and two of those ways took
  a fix to keep. buildStateSchema is scoped as well as buildPatchSchema, or the
  four keys would have landed in the published before/after maps of a
  docDefaults receipt — the exact keys that scope rejects, in objects carrying
  additionalProperties: false, so receipt validation would have loosened too.
  And classifyPatchKey now asks whether a key is reachable on the other channel
  *in this scope*: putting the four in ALLOWED_KEYS_BY_CHANNEL.run meant a run
  property sent to the paragraph channel started answering cross_channel
  instead of excluded, dropping the excluded_docdefaults_key reason a caller
  branches on. Both are covered by tests that fail without the fix.

  What remains: same accepted keys, same rejection messages, same detail codes,
  same published input and output schemas.

- before/after are per channel, in the published schema as well as in the
  types. styles.apply can use a flat map because resolution.channel says which
  channel it describes; one w:style carries both at once, and snapToGrid,
  shading and borders exist on both — borders with genuinely different shapes
  (w:bdr, one border, against w:pBdr, six edges). buildStateSchema therefore
  takes an optional channel: without it the receipt schema would have folded
  the two back together and accepted rtl under `after.paragraph`, which is the
  exact confusion the split exists to prevent.

- The one alias rule that is not enforced: an alias equal to the style's own
  name. It is redundant rather than corrupting, and JSON Schema cannot compare
  sibling fields — so the rule would live only in the validator, and a caller
  pre-validating against the published contract would get a green light and
  then a throw. The comma rule, which does corrupt, is enforced in both and
  expressed as a pattern.

- No new failure codes. DUPLICATE_ID, PRECONDITION_FAILED, LOCK_VIOLATION and
  STYLE_CONFLICT already cover every case; STYLE_CONFLICT in particular was
  unclaimed and is exactly the name collision this operation has to report.
  The published failure schema types `code` as a string rather than an enum
  derived from possibleFailureCodes: that list is empty while the operation
  ships without an adapter, and an empty enum does not compile in a
  2020-12 validator — it would have taken the whole output oneOf with it, so a
  consumer could not have validated even a success receipt. styles.apply
  publishes the same open shape.

- idempotency is conditional, not idempotent. Under the default
  conflictPolicy 'fail' a second identical call fails; only 'replace' makes it
  repeatable. The value is published to the reference page and the agent
  artifacts, so declaring it idempotent would invite an orchestrator to replay
  the call after a transport timeout and take a hard conflict — or, under
  replace, silently clobber a style someone edited in between.

- highlight is the closed ST_HighlightColor enumeration, not a free string.
  A free string writes an invalid w:highlight into styles.xml, which Word
  reports as a damaged document rather than as a rejected call. The token list
  moves to inline-semantics/token-sets.ts beside the other ST_* sets, and
  SDHighlightColor is now derived from it so the two cannot drift.

- An empty run: {} or paragraph: {} is rejected, matching styles.apply and the
  published minProperties: 1. Omitting the channel is how you say "no
  properties"; an empty object asked the adapter to write an empty w:rPr and
  disagreed with the contract a caller may pre-validate against.

- conflictPolicy is decided on both id and name. Word keys its Styles gallery
  on w:name: two styles with distinct ids and one name are two identically
  labelled entries, and a name that collides with a latent style is resolved
  by Word against w:latentStyles, which can inherit w:semiHidden and leave a
  successful call with an invisible style.

Out of scope for this first version, and deliberately: merge semantics, since
the patch types cannot express removal and the registry attaches a per-property
merge strategy on a second axis; linked pairs, since each half names the other
and one call cannot satisfy the first; table and numbering styles, which have
no patch surface here; and w:default, which is a singleton per type.

Tests: validation and routing in styles/create.test.ts, scope filtering of both
schema builders in styles/schema.test.ts, direct coverage of the public
classifyPatchKey export, an invoke parity case, and a consumer-typecheck
fixture asserting both the parameter and the return shape. The SD-2018 registry
gate pins each scope separately. Every branch was checked by mutation: each
test was confirmed to fail against the corresponding broken code, not merely to
pass against the working code.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/document-api/src/inline-semantics/token-sets.ts">

<violation number="1" location="packages/document-api/src/inline-semantics/token-sets.ts:70">
P3: The new ST_HighlightColor block was inserted between the ST_ThemeColor section header comment and its doc comment/array, so the `// ST_ThemeColor (underline rich attrs: w:themeColor)` header now sits directly above the highlight section and is separated from the values it describes. Move the highlight block after ST_THEME_COLOR_VALUE_SET (or before the ST_ThemeColor header) so each section header is adjacent to its own content.</violation>
</file>

<file name="packages/document-api/src/index.ts">

<violation number="1" location="packages/document-api/src/index.ts:2113">
P2: When the host omits `adapters.styles.create`, `capabilities.get()` can still report `styles.create` as available, but `api.styles.create()` throws `CAPABILITY_UNAVAILABLE`. Gate this operation in the capability snapshot, or derive its availability from the hook, so callers do not select an advertised unavailable operation.</violation>
</file>

<file name="packages/document-api/src/contract/operation-definitions.ts">

<violation number="1" location="packages/document-api/src/contract/operation-definitions.ts:1290">
P2: `styles.create` returns a `StylesCreateReceipt`, but its command metadata omits `returnsReceipt: true`; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.</violation>
</file>

<file name="packages/document-api/src/contract/schemas.ts">

<violation number="1" location="packages/document-api/src/contract/schemas.ts:5169">
P3: The published schema hardcodes `xmlPath: { const: 'w:styles/w:style' }`, duplicating the `STYLE_XML_PATH` constant exported from create.ts and pinned as the `StylesCreateResolution.xmlPath` type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports `STYLE_XML_PATH`), reference the constant here instead so the schema and the resolution type cannot diverge.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

write: WriteAdapter;
selectionMutation: SelectionMutationAdapter;
styles: StylesAdapter;
styles: StylesAdapter & Partial<StylesCreateAdapter>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the host omits adapters.styles.create, capabilities.get() can still report styles.create as available, but api.styles.create() throws CAPABILITY_UNAVAILABLE. Gate this operation in the capability snapshot, or derive its availability from the hook, so callers do not select an advertised unavailable operation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/index.ts, line 2113:

<comment>When the host omits `adapters.styles.create`, `capabilities.get()` can still report `styles.create` as available, but `api.styles.create()` throws `CAPABILITY_UNAVAILABLE`. Gate this operation in the capability snapshot, or derive its availability from the hook, so callers do not select an advertised unavailable operation.</comment>

<file context>
@@ -2078,7 +2110,7 @@ export interface DocumentApiAdapters {
   write: WriteAdapter;
   selectionMutation: SelectionMutationAdapter;
-  styles: StylesAdapter;
+  styles: StylesAdapter & Partial<StylesCreateAdapter>;
   templates: TemplatesAdapter;
   trackChanges: TrackChangesAdapter;
</file context>

throws: ['INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'],
// Writes word/styles.xml outside the document history, exactly as
// styles.apply does.
historyUnsafe: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: styles.create returns a StylesCreateReceipt, but its command metadata omits returnsReceipt: true; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/contract/operation-definitions.ts, line 1290:

<comment>`styles.create` returns a `StylesCreateReceipt`, but its command metadata omits `returnsReceipt: true`; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.</comment>

<file context>
@@ -1264,6 +1264,34 @@ export const OPERATION_DEFINITIONS = {
+      throws: ['INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'],
+      // Writes word/styles.xml outside the document history, exactly as
+      // styles.apply does.
+      historyUnsafe: true,
+    }),
+    referenceDocPath: 'styles/create.mdx',
</file context>
Suggested change
historyUnsafe: true,
historyUnsafe: true,
returnsReceipt: true,

/**
* Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97).
*/
// ---------------------------------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new ST_HighlightColor block was inserted between the ST_ThemeColor section header comment and its doc comment/array, so the // ST_ThemeColor (underline rich attrs: w:themeColor) header now sits directly above the highlight section and is separated from the values it describes. Move the highlight block after ST_THEME_COLOR_VALUE_SET (or before the ST_ThemeColor header) so each section header is adjacent to its own content.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/inline-semantics/token-sets.ts, line 70:

<comment>The new ST_HighlightColor block was inserted between the ST_ThemeColor section header comment and its doc comment/array, so the `// ST_ThemeColor (underline rich attrs: w:themeColor)` header now sits directly above the highlight section and is separated from the values it describes. Move the highlight block after ST_THEME_COLOR_VALUE_SET (or before the ST_ThemeColor header) so each section header is adjacent to its own content.</comment>

<file context>
@@ -67,6 +67,37 @@ export const ST_UNDERLINE_VALUE_SET: ReadonlySet<string> = new Set(ST_UNDERLINE_
 /**
  * Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97).
  */
+// ---------------------------------------------------------------------------
+// ST_HighlightColor (w:highlight)
+// ---------------------------------------------------------------------------
</file context>

id: { type: 'string', minLength: 1 },
type: { enum: ['paragraph', 'character'] },
xmlPart: { type: 'string' },
xmlPath: { const: 'w:styles/w:style' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The published schema hardcodes xmlPath: { const: 'w:styles/w:style' }, duplicating the STYLE_XML_PATH constant exported from create.ts and pinned as the StylesCreateResolution.xmlPath type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports STYLE_XML_PATH), reference the constant here instead so the schema and the resolution type cannot diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/document-api/src/contract/schemas.ts, line 5169:

<comment>The published schema hardcodes `xmlPath: { const: 'w:styles/w:style' }`, duplicating the `STYLE_XML_PATH` constant exported from create.ts and pinned as the `StylesCreateResolution.xmlPath` type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports `STYLE_XML_PATH`), reference the constant here instead so the schema and the resolution type cannot diverge.</comment>

<file context>
@@ -5122,6 +5122,101 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
+        id: { type: 'string', minLength: 1 },
+        type: { enum: ['paragraph', 'character'] },
+        xmlPart: { type: 'string' },
+        xmlPath: { const: 'w:styles/w:style' },
+      },
+      ['scope', 'id', 'type', 'xmlPart', 'xmlPath'],
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant