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.swift — Generator 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.swift — remove .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.
transitionAnchorsAreIgnored — Transition(.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
Problem
.anchor(lane:offset:content:)is declared only onAssetClip(
Sources/FCPKitDSL/AssetClip+Modifiers.swift), so nothing can be anchored onto agenerator 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.swifthandlestitle, assetClip, generator, and video, and already throws
BuildError.invalidLanefor lane 0. Only the storage (
AssetClip.anchors) and the public modifier areAssetClip-specific. This is a lift, not a rewrite.DSLNodebecomes publicAn earlier draft of this issue routed around
DSLNodebeinginternalwith astructurally opaque public wrapper, because a public protocol requirement cannot
mention an internal type:
That constraint is lifted:
DSLNodebecomespublic(decision recorded2026-08-02 — pre-1.0, DSL API churn is acceptable). The wrapper is deleted from this
design.
public protocol StoryItemcan nameany DSLNodedirectly.Note
DSLNode.build's signature is expected to change again in Issue 3 (deferredtransform 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, notAnchorable..anchor(lane:offset:content:)hangs offStoryItemdirectly.This admits
Transition().anchor(lane: 1) { … }, which compiles and is a no-op:Anchor.swiftmaps only title/assetClip/generator/video into%anchor_item;, so a transition's anchors are silently not emitted. That is theaccepted 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 documentedbehavior rather than an accident a later change "fixes" into a throw.
Files
Sources/FCPKitDSL/StoryItem.swift— protocol + shared.anchormodifierSources/FCPKitDSL/AnchoredItemBuilder.swift— hoist the anchored-itemmapping currently private to
AssetClipSources/FCPKitDSL/Generator+Modifiers.swift—Generatorconformance(keeps
Generator.swift, already 144 lines, under the length warning)Sources/FCPKitDSL/DSLNode.swift— make the protocolpublicSources/FCPKitDSL/AssetClip.swift— conformSources/FCPKitDSL/AssetClip+Modifiers.swift— remove.anchor, keep.audioRoleSources/FCPKitDSL/Generator.swift— addanchorsstorage, thread throughreplacing, populateVideo.anchoredItemsinbuildSources/FCPKitDSL/Color+DSL.swift— conformance with promotionSources/FCPKitDSL/Layout+Packing.swift— add the missing.videocase toanchoredExtentAPI
The internal
Anchorstruct is unchanged.Conformances:
AnchoredAssetClipAssetClipGeneratorGeneratoranchorsstorageColorGeneratorTitleTitleGapGapTransitionTransitionWhy
Colorpromotes.ColorisFCPKit.Color, a model type in a differentmodule; it cannot gain stored properties. The
associatedtype Anchoredexistsprecisely to allow this. It is also semantically honest:
Color.buildalreadydesugars to
Generator(.custom).color(self), so.anchorjust performs thatdesugaring one step earlier. Chaining still works, since
Generatoris itselfa
StoryItem.Document this caveat:
.duration(_:)should precede.anchor(_:)..anchorcannot throw (it sits in builder position), so promotion uses
duration ?? .zero;a zero duration surfaces later as
BuildError.missingDurationatexport().Anchors into the model.
Generator.buildemitsFCPKit.Video(not<generator>, which is absent from the DTD's%anchor_item;list).FCPKit.Videoalready has a settable
anchoredItems, so:Packing needs no new wiring.
Layout+Packing.swift:64already passesanchoredExtent(video.anchoredItems, …)for the.videobranch, which is wheregenerators land. However
anchoredExtentitself (Layout+Packing.swift:162-180)handles only
.titleand.assetClip— add a.videocase so anchoredgenerators/colors contribute extent.
Tests
Extend
Tests/FCPKitDSLTests/GeneratorDSLTests.swiftand addTests/FCPKitDSLTests/StoryItemTests.swift:Generator.anchor(lane: 1) { Title }→ spine item.videowith one anchored.titleatlane == "1".Color.red.duration(...).anchor(lane: 1) { Title }→ same shape, and the colorparam survives (
param[0].value == "1 0 0 1").BuildError.invalidLaneon both new conformers.transitionAnchorsAreIgnored—Transition(.crossDissolve).anchor(lane: 1) { Title }builds successfully and emits a
<transition>with no anchored children.Pins the documented no-op.
(exercises the new
anchoredExtentcase).Spine { }insideGenerator.anchorstill passes through.exportsGeneratorWithAnchoredTitleand allAssetClip.anchorcall sites (TitlesCutDocument,FeaturePairDocuments) compileand pass untouched.
Acceptance criteria
AssetClip+Modifiers.swiftno longer declares.anchor; no call site changes.DSLNodeispublicwith doc comments on every requirement.swift-format lintandswiftlintclean;Generator.swiftstays under 225 lines.Conventions
Scripts/header.sh).import Testing,@Test) for new tests, per.claude/agent-notes.md. Include "Tests" in either the parent enum or the child struct, never both.file_length); preferType+Modifiers.swiftsplits.swift testandswift run fcpxml-diff schema-completeness Tests/FCPKitTests/TestDatabefore each PR. Prefer opening a PR over merging.Full spec with context and rationale: docs/planning/demo-presentation-video.md