Repository navigation
docs: add how-to for adding a new version of a slot - #344
Open
brian-smith-tcril wants to merge 2 commits into
Open
brian-smith-tcril wants to merge 2 commits into
brian-smith-tcril wants to merge 2 commits into
Conversation
brian-smith-tcril
added a commit
to brian-smith-tcril/bairf
that referenced
this pull request
Nov 4, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
Author
|
Answer to open question: #345 |
This was referenced Nov 6, 2026
Open
Walks through ADR 0009's new-version path on four invented examples: a prop rename, in full (v2 wrapping v1, deprecation notes, a README per version, the feat: commit, the later removal); a prop type change under the same step headings, showing only what differs; a prop that becomes a list, where the old prop has no direct source in the new one and the list is added to v1 as well; and a slot whose default widget takes widget options, where the item type gains a required field and the widget serves v1 and v2 operators' options at once until v1 is removed. Two further examples rename a slot. With its contract unchanged, the old id is kept as an alias of the one slot until it is removed. With its props changed, ADR 0009 calls for a new identifier rather than a version, so a new slot wraps the old one as a new version would, and the old README is named for the old slot (README.songList.v1.md), the rule that also names a previous version's README.v1.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixtures under runtime/slots/testMocks/ hold each example slot from docs/how_tos/add-a-slot-version.md in the three states the how-to shows: v1 only, v2 wrapping v1, and v2 after removal. For the rename example they also hold the two states the how-to says not to produce, and the operator's widget before and after its move to v2; for the list example, the state before the list is added to v1; for the widget-options example, the operator's OPTIONS operation and the page that renders the slot before and after the change. For the two slot-rename examples they hold the slot before, with the old id as an alias or the new slot wrapping the old one, and after the alias or the old slot is removed, plus, for the alias, the id renamed with no alias. howToExamples.test.tsx renders each state the ways an operator can use it: component widgets against either version, replacing the default content, replacing the layout and reading the props through useSlotContext(), and widgets appended to both versions at once. It checks that a v2 replacement of the default content takes v1 and its widgets with it and that a v2 layout keeps them, which the how-to's step 2 states, and it verifies the how-to's reasons for not renaming or bumping in place: a widget reading the old prop gets undefined, the typecheck rejects it, and a widget on the old slot id stops rendering. For the list example it checks what a v1 widget gets with two tags selected, before and after the list is added to v1, and with none. For the widget-options example it checks that a v1 option reaches the default widget on v1 only and wrapped, that a v2 option reaches it wrapped and on v2 only, that a v1 widget gets trees without the new field, that a tree a v1 option renamed keeps its foliage, and that a v2 option's function never receives a tree a v1 option added. For the aliased rename it checks that a widget under either id gets the same props and that a replacement under either id replaces the one default content, that a widget under the old id stops rendering without the alias and after its removal. The wrapped rename runs the same matrix as a new version, with the old and new slot in v1's and v2's place, and checks that an old-slot widget gets only the songs. howToExamples.drift.test.ts reads the how-to and asserts that every ts or tsx block under an example heading, and both sides of every diff block, appears in one of that example's fixtures, with each comment line checked verbatim, so the markdown and the tested code cannot drift apart silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
slot-versioning-how-to
branch
from
November 7, 2026 22:27
ff0801e to
079dc92
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A how-to for adding a new version of a slot,
docs/how_tos/add-a-slot-version.md, with tests that run its examples and a guard that fails when the markdown and the tested code drift apart. Docs and tests only; the runtime is unchanged.ADR 0009 sets the rules for a slot's contract and lifecycle but not the steps. The how-to walks the new-version path on four invented examples, from a prop rename in full to a slot whose default widget takes widget options, each ending with the old version's removal, and then two renames of a slot: one with its contract unchanged, where the old id is kept as an alias, and one where its props change, where ADR 0009 calls for a new identifier and a new slot wraps the old one. Every version of every example is a fixture the tests render, so the code in the how-to is code that runs. The examples follow the answer on #339 that an app exports the types its slot props are built from (comment).
Resolves #340.
What changed
docs/how_tos/add-a-slot-version.md: an overview and six examples. Renaming a prop, in full:v2wrappingv1,@deprecatednotes, a README per version, thefeat:commit, the laterfeat!:removal, and a closing section on moving an operator's widget tov2. Changing a prop's type, under the same step headings, showing only what differs. A prop that becomes a list, where the old prop has no direct source in the new one and the list is added tov1as well. A slot whose default widget takes options, where the item type gains a required field and the widget servesv1andv2operators' options at once untilv1is removed, with the whole slot file shown after the deprecation step and after the removal. Renaming a slot, where nothing in the contract changes: the old id is kept inidAliasesuntil a laterfeat!:removes it, and the slot keeps its one README. Renaming a slot when its props change: the new slot wraps the old one under the new-version steps, and the old README is renamedREADME.songList.v1.md, named for the old slot minus what its id shares with the new one, the rule that also namesREADME.v1.md. The how-to shows that rule by example only; where it gets written down is Decide where a "documenting a slot" doc lives #346.runtime/slots/testMocks/: twenty-six fixtures, each example slot in the three states the how-to shows (v1only,v2wrappingv1,v2after removal), plus the two states the rename says not to produce, the operator's widget before and after its move, the list example's state before the list is added tov1, and the options example's operator operation and the page that renders the slot before and after the change. The two rename examples hold the slot before, with the alias or the wrapping, and after the removal, plus the id renamed with no alias.runtime/slots/howToExamples.test.tsx: renders each state the ways an operator can use it (component widgets against either version, replacing the default content, replacing the layout and reading the props throughuseSlotContext(), widgets on both versions at once, av1operation on thev2-only state) and checks the reasons the how-to gives: a widget reading a renamed-in-place prop getsundefinedand the typecheck rejects it, a widget on a bumped-in-place id stops rendering, av1widget gets the first tag or an empty string, av1option reaches the default widget only whilev1exists, av2option's function never sees a tree av1option added, anduseWidgetOptions()inside the wrappedv1readsv1's options whileuseWidgetOptionsForIdon thev2id readsv2's.runtime/slots/howToExamples.drift.test.ts: reads the how-to and asserts that everytsortsxblock under an example heading, and both sides of everydiffblock, appears in one of that example's fixtures, with every comment line checked verbatim and every code fence tagged with one the guard reads.Open question
How do people find out a slot version is deprecated? A
feat!:subject or aBREAKING CHANGE:footer is easy to search for, but thefeat:commit that addsv2and deprecatesv1is not, and the deprecation sits there for at least a named release before the breaking removal. Should deprecations be tracked somewhere findable: a commit convention, a list in the repo, the DEPR ticket alone, or something else? The how-to's step 5 says only what the commit body should say.Testing
npm run typecheckandnpm run lintclean.npm run test -- runtime/slots: 9 suites, 287 tests pass, 228 of them in the two new files. No manual testing; docs and tests only.Decisions
The decision log kept while writing, from the first draft through two review rounds and the two rename examples added after openedx/frontend-app-catalog#174 asked for them.
Full decision log
Nothing from the FPF era is cited. The three v1/v2 pairs in the org (recorded in the plan, Prior art) shaped the examples only. Brian, 2026-11-02.
Examples are invented, ordered least to most complex: a prop rename, a prop type change, several changes at once. The rename is written; the other two are placeholders in the first draft. Brian, 2026-11-02.
One arrangement,
v2wrapsv1. The how-to prescribes it and does not describe siblings orv1-wraps-v2. Every existing pair in the org took it, and it is the one where every old operation keeps working and a new-version replace takes the old version with it.@deprecatednotes are split by where an editor shows them: the interface note names the new interface and the new slot id; each renamed member's note names its new name. Brian's reaction to the first wording, 2026-11-02.Props interfaces are versioned from the first bump (
GreetingSlotV1Props,GreetingSlotV2Props), with the unversioned name kept as a deprecated alias ofv1untilv1is removed, so a site config typed against it compiles through the coexistence window. Open question 2 of the plan, taken provisionally in the draft; not yet confirmed by Brian.The DEPR ticket is filed when the new version first ships from
stable, not when it lands onmain. Brian, 2026-11-03:mainpublishes alpha prereleases (.releaserc:main→ channelalpha,stable→latest), so there is no ticket to link at commit time. TheREADME.v1.mdwarning therefore says only "deprecated and will be removed in a future release" and gets the ticket link added later by step 6. Supersedes the earlier reading of ADR 0009 §3 as "at landing" (plan open question 1); the draft still cites ADR 0009 §3 for the process and does not cite ADR 0018 (docs: ADR 0018, what counts as a breaking change for an app #343). Step dropped from the draft on 2026-11-03: Brian wants the DEPR-ticket step written after the PR-body question about tracking deprecations (plan, "Open questions for the PR body") has been discussed. The removal step still says to wait for the deprecation process.Runtime warnings, pre-release apps, and a link from ADR 0009 are not mentioned (plan open questions 3, 4, 5), pending Brian.
File is
docs/how_tos/add-a-slot-version.md, Markdown, title "How to: Add a New Version of a Slot". The first draft wasslot-versioning.md, "How to: Change a Slot's Props"; Brian found both titles confusing (2026-11-02), and the readability pass renamed the file after the procedure the reader follows. The link from the migration how-to's "Refactor slots" section is not yet added.Each slot version gets its own README:
README.mdis the current version and links to the previous one,README.v1.mdis the old version with the deprecation note at its top. Brian recalled FPF slots not sharing one README across versions (2026-11-03); checked this session: all three surveyed v1/v2 pairs keep separate files (authoringCourseAuthoringUnitSidebarSlot/README.md+README.v1.mdin refactor: CourseAuthoringUnitSidebarSlot frontend-app-authoring#2000; headerv1/README.md+v2/README.mdsubfolders in feat: Add HeaderNotificationsSlot to enable notifications tray default ON frontend-component-header#663 and #682). The how-to uses the sibling-file layout and, per decision 1, does not cite any of them.The person inserting widgets is an "operator", ADR 0009's word, not "plugin author" (ADR 0011's). "Site config" is named only where the operator's diff is shown. Brian's objection to site configs framing the overview, 2026-11-03.
The overview and first example were rewritten with Brian sentence by sentence (2026-11-03). Settled framing: a change that would break the contract cannot land without bumping the version; a bare bump is a breaking change to the app; ideally both versions coexist with the old one deprecated and removed later; the guide provides examples of some ways to do that. "Breaking change" is said only of the app, never as a label for the category of slot change. The classification table is gone: which changes need a new version is ADR 0009's and docs: ADR 0018, what counts as a breaking change for an app #343's job. The rename example opens with two what-not-to-do diffs (rename in place; bump without keeping v1) before the steps.
The examples are tested from
runtime/slots, notdocs/(Brian, 2026-11-03). Jest's projects aretools,runtime,shell, andtsconfig.jsonincludes onlyruntime/**andshell/**, so nothing underdocs/runs or type-checks. Fixtures live inruntime/slots/testMocks/becausetsconfig.build.jsonexcludes**/testMocks/**from the published build (precedent:runtime/testMocks/,shell/testMocks/). One fixture per state the markdown shows (three per example).howToExamples.test.tsxruns the operator-usage matrix from the plan;howToExamples.drift.test.tsasserts everytsxblock under an## Example:heading is a verbatim substring of one of that example's fixtures, ignoring import lines (fixtures importSlotby relative path; no@openedx/frontend-baseself-mapping exists) and doc-comment lines (the how-to shows the interfaces with and without@deprecatednotes), with each@deprecatedline checked separately. Results at commit time:npm run test -- runtime/slots111 passed;npm run typecheckclean, including widgets typed({ userName }: GreetingSlotProps)with no cast, so the how-to's operator diff compiles under the repo's config;eslint .andlint:toolsclean; the test-site lint step needs a builtdist/(openedxCLI) and was not run.Review round, 2026-11-03. A fresh review of the branch, with no context from the writing. Taken: the step 2 bullet claiming a
v2layout "replacesv1and everything in it" contradicteduseWidgets()(a replacement layout still receivesdefaultContent, the innerv1slot) and the test "keeps v1 and its widgets inside a replaced v2 layout"; it is now its own bullet saying the layout receivesv1as itsdefaultContentwidget. "Computed once" dropped from the rename's step 2 (nothing is computed there). Step 3 heading is "Mark the old version as deprecated" in both examples (the "where an operator will see it" clause promised a place the body no longer named once the editor sentence was cut). The drift test now checks both sides of everydiffblock, not onlytsxfences: Brian's rule, "either we check the diffs or not", since even the what-not-to-do diffs produce valid slots. That needed fixtures for the two what-not-to-do states (GreetingSlotRenamedInPlace.tsx,GreetingSlotBumpedInPlace.tsx) and for the operator's widget before and after its move (greetingWidgets.tsx), each with a behaviour test that verifies the how-to's stated reason: the renamed-in-place slot gives a widget readinguserNameundefined and the typecheck rejects that widget (one test,@ts-expect-erroron the widget; Brian merged the earlier two tests because a jest test cannot assert a compile failure); the bumped-in-place slot no longer renders av1widget; the operator's widget replaces the default content on the states each side of the diff applies to. The fouruseSlotContext() as unknown as Xcasts became oneuseSlotProps<Props>()helper; Brian accepts it in a test only. Nothing in the runtime gives a layout a typed read of the slot's props (SlotContextis its fixed members plus an index signature; the dev showcase narrows withtypeof); a possible runtime issue, outside this how-to. Kept as written: "ideally" (Brian: coexistence may not always be possible, deprecating without a replacement would be the rare exception); the alias note "Renamed toGreetingSlotV1Props" (true, one hop more); no sentence on theBannerstand-in's own props changing (it exists only for the tests). Deferred: anOPTIONStest to the widget-options example, since neither example takes options. Not decided: "Moving a widget tov2" sitting after step 6, anidAliasessteer, the README andindex.tsconventions being stated without repo precedent.The third example is "a prop that becomes a list", not "several changes at once" (Brian, 2026-11-03). A taggable item slot's (
TaggableItemSlot, Brian's name over "filter")selectedTag: stringbecomesselectedTags: string[]. The nearestv1value,selectedTags[0], loses the rest of the list, sov1gainsselectedTagsas well (adding a member does not break the contract) andselectedTagis deprecated withinv1pointing atselectedTags;v2isv1without the old member. Brian's reasoning: av1widget only sees the inner slot's props, so adding the list tov1is the only way it can get the rest without moving slot id; the removal of the deprecated member is the break, so the removal is what gets the new version. Step 4 differs from the rename for the first time (README.v1.mddocuments the new prop). The banner could have done the same (v1gainingnotice) but itsmessageis exact, not degraded, so it is not retrofitted. A widened union of allowed values was considered for this example and set aside with the widget-options example. FixturesTaggableItemSlotV1.tsx,TaggableItemSlotV1V2.tsx,TaggableItemSlotV2.tsx; the usage matrix plus one test that av1widget gets the first tag under the old name and the whole list under the new one when two are selected. Results:npm run test -- runtime/slots158 passed, typecheck clean,eslint .andlint:toolsclean.Every example opens the same way: the slot, its code, the change in one sentence (Brian, 2026-11-03). The banner's and list example's prefaces ("This example follows the same steps as the rename. ...") jumped between ideas before the reader had seen the slot; "follows the same steps" is redundant with the repeated headings and the "Same as for the rename" steps. The idea each preface carried moved to the step that acts on it: the banner's is already step 2's sentence; the list's ("the old prop can hold only one of the tags") now opens step 1.
The list example's step 1 is split: the versioned interfaces first, then "
selectedTagscan also be added tov1as a non-breaking change" with a diff adding it (Brian, 2026-11-03). Three earlier wordings of a single step 1 paragraph failed because they asserted the benefit of adding the list ("instead of only the first tag") before step 2 establishes thatv1receivesselectedTags[0]; Brian spotted that nothing in step 1 says the first entry of the list isselectedTag. Each fact now sits at the step that shows it: the mapping and the benefit in step 2, the deprecation in step 3. Settled after three corrections from Brian the same hour as three blocks in order: the old interface under its versioned name with the alias; the new interface; then "selectedTagscan also be added tov1as a non-breaking change" with a diff adding it toTaggableItemSlotV1Props. The two rejected shapes were one block holding both interfaces (thev1part did not belong in a block meant to showv2) and a diff againstTaggableItemSlotV1Propswith no block declaring it (it assumedv1existed when it did not yet). The state after the first two blocks,v2wrappingv1with only the first tag passed down, is the fixtureTaggableItemSlotV1V2FirstTagOnly.tsx, with one behaviour test: av1widget sees only the first tag when two are selected. The claim test (av1widget gets the first tag under the old name and the whole list under the new one) runs onv2wrappingv1. Results:npm run test -- runtime/slots162 passed, typecheck clean,eslint .andlint:toolsclean.The list example's steps 1 and 2, settled line by line with Brian (2026-11-03). Register is imperative, as in the rename's "Give the two interfaces versioned names": prose that narrates what the block shows ("the old interface is renamed", "
v1gets the first item") was rejected each time as "saying x is done". Step 1's three lead-ins: "Version and alias the old interface." / "Declare the new interface with only the list." / "selectedTagscan also be added tov1so widgets in thev1slot can read the list. This is purely additive and non-breaking." (Brian's sentence, minus the "You can" he first wrote; the how-to has no "you"). Step 2 opens with the general point, since this is the first example where the old prop has no direct source in the new ones: "Without a 1:1 mapping from the new props to the old ones, there is a choice to make about what the old slot gets. Herev1expects a single tag andv2has a list." Rejected on the way: "the maintainer's call" and "judgement call" (a reader following the how-to is the maintainer and should not feel the choice is someone else's), "decided case by case", "whatever makes sense on a case-by-case basis", "is a decision" standing alone; "there is a choice to make" carries Brian's "a choice you'll need to make" without the pronoun. The wrapper is a function so the choice is a named line with a comment:const selectedTag = selectedTags[0] ?? '';under "// v1 takes a single tag. This example gives it the first of the list, or an empty string when the list is empty." Brian caught the empty-list hole (selectedTags[0]isundefinedagainst astringcontract; the repo does not enablenoUncheckedIndexedAccess, so the typecheck is silent); of falling back to'', not renderingv1when empty, or typing the list non-empty, he chose the fallback. The drift test now also requires every//line in a how-to block to appear verbatim in a fixture, as it did for@deprecatedlines, so the comment is guarded. One test added: with an empty list onv2wrappingv1, av1widget readingselectedTagrenders""(asserted throughJSON.stringify, which tells''fromundefined). Results:npm run test -- runtime/slots164 passed, typecheck clean,eslint .andlint:toolsclean.The list example's steps 3 and 4 (Brian, 2026-11-03). Step 3 prose is "Add the
@deprecatednotes as in the rename."; wordings that spelled out where the member's note points ("selectedTags, whichv1already has") were rejected as awkward and obvious, and "The interface's note points atv2" as not a how-to sentence. The member's note reads "This is the first ofselectedTags, or an empty string when there are none. ReadselectedTagsinstead.", matching the fallback. Step 4 is "Same as for the rename."; the README remark about the new prop was not about giving versions READMEs, so it moved to step 1 as "Also update the slot's README." after the diff that adds the list tov1. The redundant paragraph after step 2's code (what av1widget sees; that it can read the list) was cut: the comment and step 1's third line already say both. Both commits amended (hashes change with every amend, so none are recorded here): the docs body now names the three examples, the tests body names the list fixtures and cases and the comment check. The overview lost its "instead" the same day, amended into the docs commit. Rewording a non-tip commit here needsGIT_EDITORset on the command, because the session environment setsGIT_EDITOR=true, which outranks-c core.editor.idAliasesis not used for expand/contract, and the how-to does not offer it (Brian, from a discussion with Adolfo, relayed 2026-11-04). Example: a slot with optionalpropAisv1; add optionalpropB, bump the id tov2, keep thev1id as an alias. Both ids are now one slot with one props interface holdingpropAandpropB, so removingpropAbreaksv2as well asv1and needs av3. An alias never lets the contract contract. Wrapping gives each version its own interface, so removing the innerv1is the contraction andv2's contract never grows.idAliasesstays whatSlot's own comment says it is: renaming an id with the contract unchanged. The reviewer's "idAliasesis never mentioned" (decision 13) is answered by this; whether a sentence saying so goes in the how-to or only in the PR body is Brian's call.Fourth example, "a slot whose default widget takes options", written 2026-11-04 (Brian: "go ahead and write it up, it might be helpful to see the example instead of talking about it as a hypothetical"). Mechanism, read in
runtime/slots/widget/hooks.ts,WidgetProvider.tsxandutils.tsx: every widget renders inside aWidgetProvidercarrying its slot's id and its own id;useWidgetOptions()merges theOPTIONSoperations on that slot naming that widget; withv2wrappingv1the default widget isv1'sdefaultContent, andv2'sdefaultContentis thev1<Slot>element, so av2operator'sOPTIONSondefaultContentmatches an element that reads nothing.useWidgetOptionsForId(slotId, widgetId)is exported, so the default widget readsv2's options by id as well, merged withv2winning. Choices taken without Brian (offered, not yet confirmed): the trigger is a plain rename (linkstorelatedLinks) so steps 1, 3, 4 and 5 are "Same as for the rename" and only the options differ; the default widget takes the slot's value as a JSX prop from the wrapper rather than throughuseSlotContext()(the catalog'sDefaultSocialLinksWidgetreads context with the cast Brian dislikes); one scalar option,max, not the catalog's function-valued list. Brian's invariant from the alias discussion (decision 19) holds and is stated at step 6: av2operator'sOPTIONSondefaultContentreaches the widget by id before the removal and throughuseWidgetOptions()after, so av2site config does not notice the removal; the usage matrix'sv2 wrapping v1andv2 onlycolumns are the check. The how-to shows the operator'sOPTIONSoperation as atsblock, so the drift test now readstsfences as well astsxand that block has a fixture,relatedLinksOptions.ts. FixturesRelatedLinksSlotV1.tsx,RelatedLinksSlotV1V2.tsx,RelatedLinksSlotV2.tsx. Tests beyond the matrix: the operator'sv1max caps the list onv1 onlyand wrapped (theOPTIONStest deferred in decision 13); av2max caps it wrapped and onv2 only; av2max wins over av1max wrapped; thev1max does nothing onv2 only. Results:npm run test -- runtime/slots197 passed, typecheck clean,eslint .andlint:toolsclean. ADR 0009 does not mention widget options (grepped), so the example does not make the option the reason for the bump.The widget-options example is a tree list gaining a required
foliage, not related links with amax(Brian, 2026-11-04, after a long search). Brian rejected the related links version because the spread merge of two option objects felt specific; a survey of everyuseWidgetOptions/useLayoutOptionscall in the org (catalogCourseAboutSidebarSocialSlot; frontend-base shellCoursesLink,WidgetWithOptions,RevealLinks,LabeledLinkColumn,NavDropdownMenuSlot; docs.openedx.org porting how-to) showed one source per consumer and a??fallback, nobody merging two, because no slot with options has been versioned yet. The social links slot was the model: its option is a transformation of the prop (an array or a function of the current list), so what breaks av1operator's function is the item shape changing. Brian's technical shape: "we had this info in a free-text description field, now we're moving a piece of it to a structured field and have decided it is required going forward". Constraints that emerged from the rejections, in order: not learning-focused (uncanny valley), not subjective (roast, heat, size), not a messy enum (USB versions, wattage, PD's 100 W), not a field that would obviously have been structured from day one (book format, vehicle fuel), not a fact the app could default to (tabs'/${id}, bulbs' "not dimmable", "the provider v1 had"), not per-part of the item (venue access vs a balcony seat), not open-ended (credit roles), not theming (Paragon's variant work), not the real social slot with visible labels (too close, a design change Brian would argue against, and a derived string instead of an enum). The conflict named along the way: a UI-level list the app seeds is homogeneous on any field it did not need yet, so a new field always has a sensible default there; a required field needs items that genuinely differ on a fact the app cannot know, which is domain data. Brian accepted domain data; the "fake real thing" had to feel right. Trees won because Open edX releases are named after them. Fieldfoliage: 'evergreen' | 'deciduous'(Brian: "foliage", from the botanical definition of evergreen, not "leaves"); seeded Redwood, Sumac, Willow at first, then (Brian, same day: the names should follow the release order rather than jump) Redwood, Sumac, Teak, then reversed to newest first, Teak, Sumac, Redwood, with the operator'sv1option adding Quince, the release before Redwood (Brian, same day); Teak and Quince are deciduous, Redwood evergreen. Brian's rendering:v1lists names only;v2prefixes 🌳 or 🌲; for av1-added tree, which he notes nobody would really want for a slot but reads well in an example. Shape of step 2, carrying the invariant from decision 19:v2's option is applied to the plainv2list first, exactly as it will be after removal, thenv1's option on top tov1-shaped trees (foliage stripped going in, restored by id coming out,'unknown'for a tree it added), so av2operator's function never receives'unknown'and thev2contract is the same before and after removal;ShownTreewith'unknown'is the widget's own type and the removal diff deletes it.Treeis versioned (TreeV1,TreeV2, deprecated alias) under the exported-types rule, with the options interfaces local and versioned alongside. FixturesTreeListSlotV1.tsx,TreeListSlotV1V2.tsx,TreeListSlotV2.tsx,treeListOptions.ts(addOliveV1). Tests beyond the matrix: thev1option lists Olive onv1 only, marks it wrapped, is ignored onv2 only; av2option's Olive shows 🌲 wrapped and onv2 only; av1rename keeps the tree's foliage; av2function sees exactly['evergreen', 'deciduous', 'deciduous']with av1option adding Olive (the invariant); av2filter and av1add both apply.defaultContentTextbecamestring | RegExpbecausev1prints "Redwood" andv2"🌲 Redwood". Results:npm run test -- runtime/slots210 passed, typecheck clean,eslint .andlint:toolsclean. Supersedes the related links parts of decision 20. Same day,descriptiondropped fromTree(Brian): the free-text-to-structured story was the design conversation's, not the how-to's; the example's point is that the list now expects something new (🌲 vs 🌳), so a new required field is clear on its own. The change sentence is now "Each tree is to show its foliage, evergreen or deciduous, which is a new required field."No generic helpers in the how-to's code (Brian, 2026-11-04). The tree example had a
function resolve<T>(option, current)for the array-or-function option; the catalog'sDefaultSocialLinksWidgetresolves it inline with atypeof ... === 'function'ternary. Brian: generics make an example harder to scan, "template stuff can confuse people". The how-to and fixtures now inline the ternary at each of the three call sites, matching the catalog line for line. General rule for the examples: match the shape of real code in the org over a tidier abstraction.The tree example shows the app rendering the slot (Brian, 2026-11-04: "the slot is rendered in the application with a default list of trees"). The intro gains "The app renders the slot with its default list of trees:" and a
<TreeListSlot trees={[...]} />block with Teak, Sumac, Redwood; step 2 gains "The app's own list gains the field:" and a diff addingfoliageto each, which is where the required field reaches the app's own code. The default widget isDefaultTreeListWidget, the catalog'sDefaultSocialLinksWidgetpattern in full, becauseDefaultTreesread as "the default trees" when the widget renders the list after options (Brian). FixturesTreeListPageV1.tsxandTreeListPageV2.tsxhold the two renders; two tests render each page and assert the names, plain and with icons. Results:npm run test -- runtime/slots215 passed, typecheck clean,eslint .andlint:toolsclean.TreeV2does not extendTreeV1(Brian, 2026-11-04). The new version's types stand alone so that removing the old version's type at the end of the deprecation is a deletion, not a refactor ofv2.TreeV2declaresid,nameandfoliageitself; only the widget's ownShownTree, which the removal deletes anyway, is built fromTreeV1. General rule for the examples: nothing in av2declaration references av1one. The change sentence is now Brian's: "A new requiredfoliage(evergreenordeciduous) field is being added."The tree example's steps 1 and 2 are shown in pieces (Brian, 2026-11-04: step 1 "a lot going on in that one block", step 2 "dense and hard to parse"). Step 1: old interfaces versioned and aliased; new ones with the field; the option types (local, unexported, so no alias; Brian had missed the lack of export). Step 2, in order: the wrapper; the app's list gaining the field (diff); the widget's own
ShownTreeand icon map; the two option reads with the one comment that explains them;v2's option applied first;v1's applied tov1-shaped trees and the render. The long ternaries are now three lines each, as the catalog writes them, in the how-to and all three fixtures. The two code comments that restated the lead-ins were removed from the fixture too, so the how-to and the fixture carry the same comments. Each piece is a contiguous substring ofTreeListSlotV1V2.tsx, which was reordered (old declarations first) to keep that true. Results:npm run test -- runtime/slots217 passed, typecheck clean,eslint .andlint:toolsclean.Step 2 of the tree example starts from the
v2widget and adds thev1parts as diffs (Brian, 2026-11-04: "the first thing we should show is the widget switching to do the foliage stuff, then all the parts around it to make everything continue to work"). Order: the wrapper; the app's list diff; "Rebuild the default widget forv2, as it will be oncev1is gone" with thev2-only widget as atsxblock (a substring ofTreeListSlotV2.tsx); then three diffs whose before sides are that widget and after sides the wrapped one: the two option reads,ShownTreewith the icon, and the two applications. The step 6 diff is the exact reverse. No numbering inside the step; lead-ins carry the order. Fixtures unchanged; the drift guard checks each diff side against thev2-only and wrapped fixtures.Two adaptations, two places, and no sentinel (Brian, 2026-11-04). Brian found undoing the
'unknown'entry inFOLIAGE_ICONat removal awkward, and named the two moving pieces:v1options have to be adapted for the default widget, which wantsv2trees, andv2's list has to be adapted forv1replacement widgets and layouts, which do not expect foliage. The wrapper now strips foliage before thev1<Slot>(trees={trees.map(toTreeV1)}), the way the list example's wrapper computesselectedTag; the earlier version passedv2trees tov1consumers, a field they were never promised. In the widget, av1option is given the stripped list and each tree it returns gets its foliage looked up by id in thev2list; a tree it added has none.ShownTreeisTreeV1 & { foliage?: Foliage }, the icon map is thev2one untouched, and the render shows whenfoliageis missing. Names say what each list is:v2Trees,withoutFoliage,v1Trees,shown(the earlierafterV2/afterV1said when a value existed).toTreeV1is a one-line non-generic helper used by both the wrapper and the widget. Step 2 now ends with the "allow a tree without foliage" diffs after the application, and step 6's diff coverstoTreeV1,ShownTree, the widget and the wrapper. Lead-ins in Brian's register: "This can be worked around by readingv2's options by id" ("Read ... as well" read as a demand; "x does y" rejected). New test: av1widget onv2wrappingv1receives{"id":"teak","name":"Teak"}, no foliage. Results:npm run test -- runtime/slots224 passed, typecheck clean,eslint .andlint:toolsclean.All
v1handling in the tree widget lives in one function,applyV1Option;v2's resolution stays inline (Brian, 2026-11-04). Brian found the two-list-and-lookup body "a nightmare to read" and, more to the point, something he could not feel confident removing. He confirmed that av1option can legitimately return stripped objects, so the by-id restore cannot be dropped (the alternative of not stripping and trusting spreads was rejected on that ground). The shape:v2's option is resolved inline as the catalog does;applyV1Option(option, trees)strips tov1trees, applies the option, and restores foliage through a small named lookup,const foliageOf = (id: string) => trees.find(tree => tree.id === id)?.foliage;(aMapbuilt from entries was tried first and read oddly to Brian: twomaps and an invisible tuple in one line); the widget body isconst shown = applyV1Option(v1Options.trees, v2Trees);. A namedapplyOptionforv2was offered and rejected: inline makes the removal clearer.TreeListV1Optionis a named type so the function's signature needs no indexed type. The removal diff in step 6 deletestoTreeV1,ShownTree,applyV1Option, thev1read, theshownline, the render and the inner<Slot>, and nothing else. The drift guard caught a+-only diff whose before state existed in no fixture (context on both sides of the added line); its trailing context line was dropped. Results:npm run test -- runtime/slots227 passed, typecheck clean,eslint .andlint:toolsclean.The tree example's step 3 ends with the whole slot file in a
<details>block, and@deprecatedstays on exported types and props only (Brian, 2026-11-04: "keep deprecated meaning deprecated"). The tree example is the only one shown as diffs, so a reader needs one place to see the file whole; step 3 is the last step that touches it, and the file after step 3 is exactlyTreeListSlotV1V2.tsx, so the block is generated from the fixture (header comment dropped, the two relative imports replaced by one package import) and the drift guard checks it as an ordinarytsxblock. Internals (applyV1Option,toTreeV1,ShownTree) carry no@deprecated: the tag means callers should migrate, they have no callers, and the step 6 diff is already the inventory of what the removal deletes. Step 6 ends the same way with "The slot after step 6", generated fromTreeListSlotV2.tsx, placed before the invariant sentence (Brian, same day). Thetreesmember note now names the member: "treesinTreeListSlotV2PropsisTreeV2[]. Av1option cannot set a tree's foliage." Results: drift test 84 passed.The tree example's step 6 diff is the whole file, generated from the two fixtures (Brian, 2026-11-04, noticing the earlier diff left out the deprecated
v1types, the option types and the import). It is produced withdifflib.unified_diffoverTreeListSlotV1V2.tsxandTreeListSlotV2.tsxwith the relative imports replaced by package imports, hunk header dropped, the import's-/+pair kept adjacent, so both sides are exact substrings of the fixtures and the drift guard checks them. Same shape as the rename's step 6. Results: drift test 89 passed.The widget-options example is amended into the two commits (Brian, 2026-11-04). The how-to section into the docs commit, whose body now names four examples; the six tree fixtures, the test additions, the drift guard's
tsfence support and the removal of the related links files into the tests commit, whose body now describes the options cases. Branch is two commits on72d7053, tree clean, nothing pushed. Still open: the three review items left to Brian (placement of "Moving a widget tov2", anidAliasessentence, the README andindex.tsconventions without repo precedent), the DEPR-ticket step pending the PR-body question, the migration how-to link, and the widened-union example.The "Exported types" section is gone; what the examples needed is in them (Brian, 2026-11-04: it felt out of place). It was the one rule-stating section among examples, sat after the examples that linked to it, and half of it, renaming an exported type with its shape unchanged and keeping a deprecated alias "until the next breaking release", was the only case in the document with no slot version involved and on a different clock (the reviewer's point in decision 13). The export convention stays where it is first needed, the banner's step 1 ("exported from the app's
index.tsbeside the slot props, so that a widget can name it"); the shape-change rule is stated in the tree's step 1 ("since a change to the tree's shape is a change to every slot that passes it") instead of linked. The rename-with-alias rule went to the PR body as open question 2 (plan); dropped again, decision 36. Drift test unaffected: the section'stsblock was not under an example heading.Tests review round, 2026-11-04. A fresh review of the two test files, with no context from the writing. Taken, all nine of the defect group: every example's
v2 onlystate proves av1operation does nothing (av2-only fixture that still wrappedv1passed everything before); the how-to's runtime claim at the tree's step 2 is tested literally, a widget appended tov1with anOPTIONSaimed at it in both slots printsv1's fromuseWidgetOptions()andv2's fromuseWidgetOptionsForIdon thev2id, in the how-to test file (Brian: it verifies a "this is why" claim, as decision 13 set); the first-tag-only test prints the whole props object; the renamed-in-place test rendersString(userName)and expects "undefined" instead of a trailing-trimmed match that''also passed; the invariant test assignsseeninstead of pushing; thev2-only-ignores-v1-option test also asserts a seeded tree; the "v2 option then v1 option" test is dropped (Brian: the invariant test above it configures the samev1add and assertsv2saw exactly three foliages, which is already order-sensitive); drift labels carry the markdown line number and duplicate comments are checked once (16 of 31 comment tests were repeats from the<details>files and the step 6 diff); failures print the wanted text viatoContainEqual(expect.stringContaining(...)); every fence opener in an example section must bets,tsx,diff,markdownortext, since a block retaggedtypescriptor indented dropped out of the guard silently. That check found the two commit-message blocks in the rename example untagged; they are taggedtextrather than bare fences allowed, so an untagged code block still fails. Two lines of the how-to, amended into the docs commit; the rest into the tests commit. 233 tests. Deferred, Brian's call later:INSERT_BEFORE/INSERT_AFTER/REMOVEand layoutOPTIONSuntested against "everyv1widget, layout and option"; what av1option receives; av1option returningfoliagestill rendering ; a two-tag default content; a fixture file noEXAMPLESentry lists; an empty diff side matching vacuously. Noted, no action: the guard is one-directional by design; the how-to cannot elide with// ...and a fixture cannot reorder declarations or wrap a doc comment without breaking a block, none of which applies today.The first review round's three undecided items, and the plan's open questions, closed (Brian, 2026-11-04). "Moving a widget to
v2" stays after step 6: it is supplemental, the how-to is for people changing slots, not for site operators using them. No sentence onidAliases: the how-to covers the cases where an alias does not fit. TheREADME.v1.mdconvention and exporting a new type fromindex.tsstay as written: the README split was done in the FPF era and exporting types is something the apps are going to get better about. Plan open question 1 (DEPR timing at step 5) is not part of this how-to, removal is afeat!; a reviewer may raise it. Open question 2 (props interface naming) stays as the examples do it; a later iteration might alias the latest version to the unversioned name once the old one is gone, not in this PR. Open questions 3, 4, 5 (pre-release apps, a runtime warning, a link from ADR 0009) stay unmentioned: nothing to say about them fits this how-to. Open question 6 is decision 3.No link from
docs/how_tos/migrate-frontend-app.md(Brian, 2026-11-04). The plan had the new how-to linked from that file's "Refactor slots" section; that how-to is about migrating FPF slots and this one is not about migration, so it does not go there. Nothing links to the new how-to; it sits indocs/how_tos/beside the others.PR body open question 2 dropped (Brian, 2026-11-04: it felt odd). It stated that renaming an exported type keeps the old name as a
@deprecatedalias until the app's next breaking release, attributing that to the Decide whether apps export the types their slot props are built from #339 answer. The Decide whether apps export the types their slot props are built from #339 answer is only "Yes. They should export."; the alias rule is Decide whether apps export the types their slot props are built from #339's own open question 2 (shape-only or name-too contract), still undecided there, and where it lives is Decide whether apps export the types their slot props are built from #339's open question 3. The PR body now has one open question, how deprecations are found. The how-to says nothing about renaming an exported type.Two rename examples are added after the tree list, alias case first (Brian, 2026-11-07). Trigger: refactor: rename course catalog data to catalog data [FC-0137] frontend-app-catalog#174's author noted the how-to has no case for deprecating one slot in favour of another (refactor: rename course catalog data to catalog data [FC-0137] frontend-app-catalog#174 (comment)), and the review there had sent four pure renames down the wrapping path when slot
idAliases(feat: support slot ID aliases #178) is the lighter mechanism for a rename with the contract unchanged. One domain for both so the contrast is the point: a music app's player page with asongListslot (songs: Song[]) and asongControlsslot (onPlay,onPause); podcast episodes are being added beside the songs. "Renaming a slot":songControlsbecomestrackControls, props, place and content unchanged, so ADR 0009 section 2's contract is untouched; the component, folder and props interface are renamed with the old interface name kept as a deprecated type alias; the one<Slot>gainsidAliases={['...songControls.v1']}; one README, retitled, with a note that the old id is a deprecated alias, and the slots index listing both ids;feat:naming the deprecated id; the alias removed later asfeat!:. Fixtures: before, aliased, alias dropped; tests: an old-id widget renders in the aliased slot and reads the same props, and does not render once the alias is gone. "Renaming a slot when its props change":songListbecomestrackListwithtracks: Track[], a track being a song or an episode; the content type widened, so it is a contract change, and the identifier would mislead assongList.v2, so it is a new identifier, not a version (ADR 0009 section 2, "if it still make sense to use the same Identifier"). Opens with one what-not-to-do diff, the alias, which hands an old-id widgetsongsasundefined, then the prop-rename example's six steps with the differences named: the new interface is unversioned and the old one deprecated whole;trackList.v1wrapssongList.v1and passes ittracks.filter(isSong), which meets the old contract exactly; the notes name the new id; the commits name slots rather than versions. Fixtures: the three states plus the alias what-not-to-do; tests: the old-id widget gets only the songs, the new-id widget every track, the alias state fails the old widget, the removal state ignores old-id operations. The title stays; the overview gains one sentence saying the last two examples are renames, with and without a contract change. Decision 34's "no sentence onidAliases" is superseded: the alias example is that sentence.The old README is named for the old slot, minus the parts it shares with the current one (Brian, 2026-11-07). The greeting case shares the identifier, so
README.v1.mdas before; the track list shares nothing, soREADME.songList.v1.md. Brian's check: a slot onv3renamed while itsREADME.v2.mdis still in the folder keeps both files distinct. Rejected:README.deprecated.md(the catalog author's choice in refactor: rename course catalog data to catalog data [FC-0137] frontend-app-catalog#174 at 1e1c048994) names a state, not a slot, and two deprecated READMEs in one folder would be named one by state and one by version;README.songList.mddrops the version, so it is not the old id either, and its rule ("whatever differs") has to be worked out. Also weighed and set aside: the old folder keeping itsREADME.mdbeside the new one, one folder per slot, honest about "two slots" but an empty folder for the whole deprecation window.Decisions 37 and 38 written, 2026-11-07. Fixtures
SongControlsSlot.tsx,TrackControlsSlotNoAlias.tsx,TrackControlsSlotAliased.tsx,TrackControlsSlot.tsx;SongListSlot.tsx,TrackListSlotAliased.tsx,TrackListSlotWrappingSongList.tsx,TrackListSlot.tsx. The alias example's steps are its own five (rename the component and props interface; keep the old id as an alias; update the README;feat:; remove the alias), since nothing wraps; the wrapped rename reuses the new-version headings with "slot" for "version".Trackis one interface withkind: 'song' | 'episode'rather than a union, so the old slot'ssongs: Song[]istracks.filter(track => track.kind === 'song')with no type guard (Trackis structurally aSong);Songgoes at removal and thefeat!:names it. The test matrix (describeExample) gained an optionalnamespair so its test names readsongList/trackListfor the wrapped rename instead ofv1/v2; the alias example has its own cases (a widget under either id gets the same props, checked by clicking it and asserting the app'sonPlayran once; a replacement under either id replaces the one default content; the old id does nothing without the alias and after its removal). The alias what-not-to-do for the wrapped rename is tested like the greeting's rename-in-place:@ts-expect-erroron a widget readingsongsfrom the aliased slot's props, andString(songs)renders "undefined". Results:npm run test -- runtime/slots288 passed,npm run typecheckclean,npm run lintclean through all three stages once the test-site was set up the wayci.ymldoes it (npm run pack, thennpm i ../pack/openedx-frontend-base.tgzandnpm ciintest-site); the test-site stage runs this repo's ownopenedx lint, built intodist/tools/cli, and had never been installed in this checkout, so earlier runs stopped there. Thenpm irewritestest-site/package-lock.jsonwith unrelated bumps and is restored afterwards;pack/andtest-site/node_modulesare ignored. Both commits rebuilt on72d7053with the bodies naming six examples; nothing pushed, PR body not yet updated.Step 2 of the alias example is the heading and the code, no prose after (Brian, 2026-11-07). The sentence explaining what an alias does ("An operation whose
slotIdis an alias applies to the slot as if it named the slot's id ...") was cut: the heading says "Keep the old id as an alias", the code showsidAliaseswith the old id, and the tests carry the consequences. Commits rebuilt again on72d7053; the two how-to suites pass (229).The alias example's README step is one sentence and the note; no slots-index sentence in either rename example (Brian, 2026-11-07). Step 3 now reads "Change the slot id in the README's title and examples, and add a note at the top:" with the note in the greeting note's shape (no "until then it keeps working as an alias" clause). The "If the app lists its slots in one place ..." sentence was cut from both rename examples: the four version examples never mention an index, and an app that has one updates it without being told. The step 4 commit body lost the same clause ("; until then the old id keeps working as an alias") and now ends "instead." like the greeting's. Supersedes that part of decision 37. Commits rebuilt on
72d7053; the two how-to suites pass (229).The wrapped rename's opener states the change and the alias failure, nothing on versions (Brian, 2026-11-07, after two drafts that argued identifier-over-version with ADR 0009 cited mid-example). What the rejected drafts shared: they classified the change, which decision 11 moved out of this how-to, and passed a verdict on the alias in prose where the other openers let the wrong move's diff and its concrete failure carry it. Now: "Podcast episodes are being added to the list, so
songsis becomingtracks, a list of songs and episodes, and the slot is becomingtrackList. Keeping the old id as an alias, as in the previous example, would give a widget that readssongsundefined." then the alias diff, then "The steps below add the new slot while leaving the old one in place. They are the steps that add a new version, with the new slot inv2's place and the old slot inv1's." Brian: no sentence on not incrementing the version, since the example is titled "renaming". Commits rebuilt on72d7053; the two how-to suites pass (229).The wrapped rename opens with the in-place diff, and the alias what-not-to-do is gone (Brian, 2026-11-07). Opener: "Podcast episodes are being added to the list, so both the slot id and its props are changing:" then the diff from
SongListSlotto the finalTrackListSlot(both sides fixtures already), then "Making the change in place removessongList.v1from the app, which is a breaking change to the app.", the greeting's bump-in-place sentence. Brian's reason for dropping the alias diff: the greeting's two "don't" diffs are the intended change done naively, where the alias diff showed adding something nobody intended. Nothing in the example now mentions aliases; the contrast with the previous example is carried by the two headings.TrackListSlotAliased.tsx, its drift entry and the "aliased instead" test are removed; the tests commit body no longer names them. Supersedes those parts of decisions 37, 39 and 42. Results:npm run test -- runtime/slots287 passed,npm run typecheckclean,npm run lintclean through the test-site stage. Commits rebuilt on72d7053; tree clean.The wrapped rename's opener is the change sentence and the diff, then step 1 (Brian, 2026-11-07). Brian's sentence: "Podcast episodes are being added to the list. The slot's id and props are changing:". The two paragraphs after the diff (the greeting's "removes ... from the app, which is a breaking change" line, and "The steps below add the new slot ... with the new slot in
v2's place and the old slot inv1's") are dropped: the diff is the change itself, not a wrong move, so there is nothing to warn about, and the parallel step headings say the mapping; the banner and list examples go from their change sentence straight to step 1 the same way. Commits rebuilt on72d7053; the two how-to suites pass.The wrapped rename's step 1 prose is the
Trackexport sentence only (Brian, 2026-11-07). "The old interface keeps its name and every member it had. The two slots have different identifiers, so their props interfaces already have different names, and nothing needs a versioned name." said what the heading and code show; the surviving sentence states the one fact the code cannot, as the banner's step 1 does forNotice, and namesTrackKindbesideTrack(Brian: it is new too). Commits rebuilt on72d7053.The wrapped rename's step 2 is one imperative sentence, nothing after the code (Brian, 2026-11-07: the prose was "x does y" narration, the fault decision 17 names). Now: "Rename the component, its file and its folder with the slot, and wrap the old
<Slot>in the new one asv2wrapsv1in the rename." The paragraph after the code that restated the first example's three "what keeps both working" bullets is cut. Commits rebuilt on72d7053.Step 2's trailing clause is cut (Brian, 2026-11-07: "as
v2wrapsv1in the rename" made no sense). It was meant to point at the version examples, wherev2wrapsv1, and the tail was wrong. The sentence now ends at "wrap the old<Slot>in the new one."; the step headings already parallel the version examples, so no pointer replaces it. Commits rebuilt on72d7053(docs4b1b272, testsaa531a4); the two how-to suites pass (228).Every step 3 is "Mark the old props as deprecated" (Brian, 2026-11-07). The four version examples said "Mark the old version as deprecated" and the wrapped rename "Mark the old slot as deprecated", but the step adds
@deprecatedto the props interface, its members and (in the tree example) the tree type; nothing marks a slot or a version, and frontend-base has no way to tag a slot id as deprecated. The heading now names what the step does. Considered and set aside: a step that marks the slot itself, which would mean a JSX comment and nested steps, a bigger rework than the gap is worth, and the README note already says the old slot is deprecated. Extends decision 29 (@deprecatedon exported types and props only). Commits rebuilt on72d7053(docsc85b679, testsc784e75); the two how-to suites pass (228).The wrapped rename's README step is "Same as for the rename" plus the old file's name (Brian, 2026-11-07: the paragraph was explaining the README naming rule inline). Now: "Same as for the rename, with the old slot's
README.mdrenamed toREADME.songList.v1.md. The note at its top:" then the note. The rule itself stays in decision 38; the three version examples already say "Same as for the rename." and this one differs only in the old file's name. Commits rebuilt on72d7053(docscd3d6e9, tests754e437); the two how-to suites pass (228)."The rename" is "the prop rename" throughout (Brian, 2026-11-07: with two slot-rename examples in the file, "Same as for the rename" reads as the slot rename, and the wrapped rename renames both). All twelve cross-references to the first example (nine "Same as for the rename" steps, two "
@deprecatednotes as in the rename", the tree removal's "Same as for the rename, and everythingv1needed goes:") now say "the prop rename". Commits rebuilt on72d7053(docsa74bb05, testsc3bcb5d); the two how-to suites pass (228).The
feat!:lead-in mirrors thefeat:one (Brian, 2026-11-07: "This is the breaking change to the app, and the commit says so:" read badly). The three removal steps (prop rename, alias, wrapped rename) now say "Removing the old version / the alias / the old slot is a breaking change to the app, so this commit isfeat!:.", the shape of each step 5's "...is not a breaking change to the app, so this isfeat:and notfeat!:." The subject in each is the removal heading's object. Commits rebuilt on72d7053(docs314529c, tests079dc92); the two how-to suites pass (228).🤖 Generated with Claude Code