Skip to content

styles.create: no Document API operation defines a named style #3975

Description

@Nathaniel-260

What problem does this solve?

There is no way to bring a named style into a document through the Document API.

Walking the styles surface in packages/document-api:

  • styles.apply writes w:docDefaults and only that. It is not just typed that way — it is enforced at runtime: styles/validation.ts throws INVALID_TARGET with target.scope must be "docDefaults" for anything else.
  • styles.paragraph.setStyle / setStyleRef apply a style that is already in the document, by styleId, or one of four semantic roles (defaultParagraph, heading, title, subtitle).
  • styles.getCatalog reads. It models every field a style definition has — id, name, aliases, type, custom, basedOn, next, link, priority, qFormat, hidden, semiHidden, unhideWhenUsed, locked — and nothing can author them.

So a caller who wants a "Question" style, or a house "Quote" style, has exactly one route: synthesize a whole .docx whose styles.xml contains the style, and hand it to templates.apply.

That route works, and it is the wrong tool. TemplatesApplyInput is { source, bodyPolicy } — there is no scope selector, and the operation's own description says scopes "are auto-detected from source package evidence". So which parts of the open document also get adopted is decided by the shape of the synthesized package rather than by what the caller asked for. TemplateScope already enumerates all eight scopes; it appears only in the outgoing report, never in the input. The caller's only lever is to carefully leave parts out of a package they had to build in the first place.

Concretely, to add one paragraph style without disturbing the open document, a caller must know to omit w:sectPr from the template's document.xml (or sectionDefaults is adopted) and to omit w:docDefaults from its styles.xml (or the source singleton replaces the target's, as documented in templates/apply.ts). Both are documented and both are reported in the receipt — the problem is not that they are surprising, it is that "add a style" should not require reasoning about them at all.

Proposed solution

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.

I have a branch with the contract, validation, schemas, registration, unit tests and a consumer-typecheck fixture; all three docapi:check stages and the package suite pass. It is open as a PR linked to this issue so the shape is reviewable in full — but the part I cannot write is the part that matters, and the question at the bottom of this issue should be answered before that PR merges.

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 },
});

Shape notes, each with the alternative I rejected and why:

  • styles.create, not styles.define. create.* is body content, but the .create leaf is already how this codebase names a durable, id-bearing 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.
  • Discriminated union input, matching StylesApplyInput and ListsCreateInput: a character style cannot carry next or paragraph properties, and next?: never makes that a compile error rather than a runtime one.
  • id and priority, not styleId and uiPriority, so getCatalog round-trips into create. priority stays number | null and is not capped at Word's 0..99 UI band, because the catalogue can return values outside it.
  • conflictPolicy: 'fail' | 'replace', 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 colliding with a latent style is resolved against w:latentStyles and can inherit w:semiHidden — a successful call leaving an invisible style. No 'merge': the patch types cannot express removal, and the registry attaches a per-property merge strategy on a second axis, so a merge has two answers for borders and none for "remove this".
  • before / after per channel. styles.apply can use a flat StylesStateMap because resolution.channel disambiguates it. One w:style carries both channels at once, and snapToGrid, shading and borders exist on both — borders with genuinely different shapes (w:bdr, one border, vs w:pBdr, six edges).
  • No new failure codes. DUPLICATE_ID, PRECONDITION_FAILED, LOCK_VIOLATION and STYLE_CONFLICT cover every case; STYLE_CONFLICT is currently unclaimed and is exactly the name collision above.
  • Out of scope for a first version: linked pairs (each half names the other, so one call cannot satisfy the first), table and numbering styles (no patch surface exists), and w:default (a singleton per type, a different operation).

One prerequisite that is not cosmetic

EXCLUDED_KEYS is the docDefaults exclusion list and the only list, so every caller inherits 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 perfectly legal on a named w:style; the header comment in registry.ts says as much ("intentionally disallowed in Word docDefaults"). All four are already 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 splitting the exclusion list by destination, 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.

My branch makes the exclusion list a property of the destination (docDefaults | style), leaves the docDefaults list untouched, and filters buildPatchSchema by scope so the published styles.apply schema does not change. styles.apply keeps the same accepted keys, the same rejection message and the same excluded_docdefaults_key detail code.

The question I actually need answered first

Who owns the engine adapter, and when?

The mutation lives in @superdoc/docx-engine, which is not in this repository, so I can contribute the contract and not the implementation. That leaves a real problem I would rather raise than paper over: apps/docs/.../receipts-and-errors.mdx defines CAPABILITY_UNAVAILABLE as "the current runtime cannot perform the request", remediation "change the mode, operation, or runtime". For an operation with no implementation anywhere there is no runtime to change to, and capabilities.get() would advertise the operation before it can ever succeed. A feat: release would also publish it into the JSON schemas, the agent artifacts and the reference site.

I looked for precedent and did not find one. Nine of the ten ids in V1_RUNTIME_UNAVAILABLE_OPERATION_IDS map one-to-one onto optional adapter methods, and the note beside them says what "optional" means here: "Available on v2-backed sessions only; v1-backed sessions currently return CAPABILITY_UNAVAILABLE" — two runtimes that both exist. The remaining optional methods are either legacy shims or, like styles.getCatalog, optional and implemented. OperationDefinitionEntry has no status / experimental / planned field, so the contract has no way to say "defined, not yet implemented".

So, concretely, pick whichever you prefer and I will follow it:

  1. You take the whole thing. Close this and implement contract + adapter together; the shape above is yours to use or discard.
  2. Contract and engine land together, with someone assigned to the adapter — I open the PR and it waits for that.
  3. Contract first, if you want a marker for it, and you tell me how it should be flagged so it is not advertised as available.

I am happy with any of the three. What I would like to avoid is opening a PR that sits because the question was never asked.

Alternatives considered

  • A scopes selector on templates.apply. Smaller, and it would make the existing route safe rather than merely possible — but "synthesize a DOCX to add one style" stays the wrong shape for the job, and templates.apply reads a package from disk or base64, which is a lot of machinery for a style definition.
  • Widening styles.apply to scope: 'style'. Rejected: its input is a { target, patch } pair with no room for name, basedOn, next, qFormat or the rest of the w:style attributes, and StylesApplyReceipt's flat state map cannot describe two channels at once.
  • Doing nothing and documenting the templates.apply recipe. The gap is a missing capability, not a missing doc: getCatalog describes fifteen properties of a style and nothing can write one.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: queuedEngineering work is queued; no delivery date is committed.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions