Skip to content

StoryItem: lift .anchor off AssetClip and make DSLNode public #36

Description

@leogdion

Problem

.anchor(lane:offset:content:) is declared only on AssetClip
(Sources/FCPKitDSL/AssetClip+Modifiers.swift), so nothing can be anchored onto a
generator or a color. A media-free slide deck needs exactly that: text over a color
background. Anchoring belongs on any story item that can accept it.

The internal machinery is already general. Sources/FCPKitDSL/Anchor.swift handles
title, assetClip, generator, and video, and already throws BuildError.invalidLane
for lane 0. Only the storage (AssetClip.anchors) and the public modifier are
AssetClip-specific. This is a lift, not a rewrite.

DSLNode becomes public

An earlier draft of this issue routed around DSLNode being internal with a
structurally opaque public wrapper, because a public protocol requirement cannot
mention an internal type:

error: method cannot be declared public because its parameter uses an internal type

That constraint is lifted: DSLNode becomes public (decision recorded
2026-08-02 — pre-1.0, DSL API churn is acceptable). The wrapper is deleted from this
design. public protocol StoryItem can name any DSLNode directly.

Note DSLNode.build's signature is expected to change again in Issue 3 (deferred
transform resolution). Making it public does not freeze it; it is documented as
evolving until 1.0.

One protocol or two

StoryItem — "I can sit in a spine" — is the protocol, not Anchorable.
.anchor(lane:offset:content:) hangs off StoryItem directly.

This admits Transition().anchor(lane: 1) { … }, which compiles and is a no-op:
Anchor.swift maps only title/assetClip/generator/video into
%anchor_item;, so a transition's anchors are silently not emitted. That is the
accepted trade (decision 2026-08-02) — one protocol matching the DTD's %clip_item;
entity beats two protocols splitting hairs over which story items host lanes.

Pin it with a test (transitionAnchorsAreIgnored) so the no-op is documented
behavior rather than an accident a later change "fixes" into a throw.

Files

  • Create Sources/FCPKitDSL/StoryItem.swift — protocol + shared .anchor modifier
  • Create Sources/FCPKitDSL/AnchoredItemBuilder.swift — hoist the anchored-item
    mapping currently private to AssetClip
  • Create Sources/FCPKitDSL/Generator+Modifiers.swiftGenerator conformance
    (keeps Generator.swift, already 144 lines, under the length warning)
  • Modify Sources/FCPKitDSL/DSLNode.swift — make the protocol public
  • Modify Sources/FCPKitDSL/AssetClip.swift — conform
  • Modify Sources/FCPKitDSL/AssetClip+Modifiers.swiftremove .anchor, keep
    .audioRole
  • Modify Sources/FCPKitDSL/Generator.swift — add anchors storage, thread through
    replacing, populate Video.anchoredItems in build
  • Modify Sources/FCPKitDSL/Color+DSL.swift — conformance with promotion
  • Modify Sources/FCPKitDSL/Layout+Packing.swift — add the missing .video case to
    anchoredExtent

API

/// A story item: content that can sit in a spine, per the DTD's `%clip_item;` entity.
public protocol StoryItem: DocumentContent {
  /// The type produced by anchoring; usually `Self`.
  associatedtype Anchored: DocumentContent
  /// The anchors already attached to this item.
  var anchors: [any DSLNode] { get }
  /// Returns a copy carrying the given anchors.
  func replacingAnchors(_ anchors: [any DSLNode]) -> Anchored
}

extension StoryItem {
  /// Anchors content on a connected lane. `lane` must be nonzero.
  ///
  /// Anchoring onto a ``Transition`` has no effect: the FCPXML DTD does not admit
  /// anchored items on transitions, so they are not emitted.
  public func anchor(
    lane: Int,
    offset: FCPTime = .zero,
    @DocumentBuilder content: () -> DocumentGroup
  ) -> Anchored
}

The internal Anchor struct is unchanged.

Conformances:

Type Anchored Notes
AssetClip AssetClip Behavior identical to today
Generator Generator New anchors storage
Color Generator Promotes — see below
Title Title Anchors emitted; also a spine item in its own right
Gap Gap Anchors emitted
Transition Transition Anchors accepted, not emitted (see above)

Why Color promotes. Color is FCPKit.Color, a model type in a different
module; it cannot gain stored properties. The associatedtype Anchored exists
precisely to allow this. It is also semantically honest: Color.build already
desugars to Generator(.custom).color(self), so .anchor just performs that
desugaring one step earlier. Chaining still works, since Generator is itself
a StoryItem.

extension Color: StoryItem {
  public var anchors: [any DSLNode] { [] }
  public func replacingAnchors(_ anchors: [any DSLNode]) -> Generator {
    Generator(.custom, duration: duration ?? .zero)
      .color(self)
      .replacingAnchors(anchors)
  }
}

Document this caveat: .duration(_:) should precede .anchor(_:). .anchor
cannot throw (it sits in builder position), so promotion uses duration ?? .zero;
a zero duration surfaces later as BuildError.missingDuration at export().

Anchors into the model. Generator.build emits FCPKit.Video (not
<generator>, which is absent from the DTD's %anchor_item; list). FCPKit.Video
already has a settable anchoredItems, so:

let items = try anchors.map { try anchoredItem($0, resources: &resources) }
videoElement.anchoredItems = items.isEmpty ? nil : items

Packing needs no new wiring. Layout+Packing.swift:64 already passes
anchoredExtent(video.anchoredItems, …) for the .video branch, which is where
generators land. However anchoredExtent itself (Layout+Packing.swift:162-180)
handles only .title and .assetClip — add a .video case so anchored
generators/colors contribute extent.

Tests

Extend Tests/FCPKitDSLTests/GeneratorDSLTests.swift and add
Tests/FCPKitDSLTests/StoryItemTests.swift:

  • Generator.anchor(lane: 1) { Title } → spine item .video with one anchored
    .title at lane == "1".
  • Color.red.duration(...).anchor(lane: 1) { Title } → same shape, and the color
    param survives (param[0].value == "1 0 0 1").
  • Lane 0 throws BuildError.invalidLane on both new conformers.
  • transitionAnchorsAreIgnoredTransition(.crossDissolve).anchor(lane: 1) { Title }
    builds successfully and emits a <transition> with no anchored children.
    Pins the documented no-op.
  • Sequence duration accounts for an anchored title longer than its background
    (exercises the new anchoredExtent case).
  • A nested Spine { } inside Generator.anchor still passes through.
  • Regression: the existing exportsGeneratorWithAnchoredTitle and all
    AssetClip.anchor call sites (TitlesCutDocument, FeaturePairDocuments) compile
    and pass untouched.

Acceptance criteria

  • AssetClip+Modifiers.swift no longer declares .anchor; no call site changes.
  • DSLNode is public with doc comments on every requirement.
  • Full existing suite green with zero fixture changes.
  • swift-format lint and swiftlint clean; Generator.swift stays under 225 lines.

Conventions

  • MIT header block on every new file (see Scripts/header.sh).
  • Swift Testing (import Testing, @Test) for new tests, per .claude/agent-notes.md. Include "Tests" in either the parent enum or the child struct, never both.
  • Doc comments on every public declaration.
  • Keep files under 225 lines (SwiftLint file_length); prefer Type+Modifiers.swift splits.
  • Run swift test and swift run fcpxml-diff schema-completeness Tests/FCPKitTests/TestData before each PR. Prefer opening a PR over merging.

Full spec with context and rationale: docs/planning/demo-presentation-video.md

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agentv0.1.0FCPKit v0.1.0 milestone work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions