Repository navigation
feat(lift): one opt-in lift-from-trigger presentation #8485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
vjeux
wants to merge
46
commits into
meta:main
Choose a base branch
from
vjeux:uikit/c20-lift
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+5,825
−127
Draft
Changes from all commits
Commits
Show all changes
46 commits
Select commit
Hold shift + click to select a range
3dd581e
feat(core): one progress module for the motion foundation
vjeux 3cfb3b1
refactor(animatedLayout): take the settle spring's progress from prog…
cixzhang 273da1e
feat(core): the rect morph's paths, the rect a grow-out presentation …
vjeux f1149cd
Merge remote-tracking branch 'origin/main' into motion/morph-path
cixzhang 131ca14
feat(core): one rect morph for touch presentations that grow out of a…
vjeux f19df27
fix(rectMorph): a handed-in animator stays on the element through a c…
cixzhang e3a43d0
fix(motion): one timed curve keeps a retargeted velocity, for the pag…
cixzhang c99210a
fix(rectMorph): a timed motion from rest plays its easing as given
cixzhang 3272828
docs(rectMorph): the timing follows the component's motion record
cixzhang df642e0
Merge main into motion/morph-path
vjeux 8c8f726
Merge motion/morph-path into motion/rect-morph
vjeux 3fdca8f
fix(rectMorph): AST-094 links the morph; the proof imports a safe test
vjeux d4903e2
test(SnapPaging): the pager's bundle lists the progress module
vjeux 1b794af
test(motion): the popover's proofs read Popover's live geometry
vjeux 5dd6a6a
Merge motion/morph-path into motion/rect-morph
vjeux 3bfa524
refactor(motion): one eased progress, with a retargeted velocity
vjeux ad7f8fb
test(motion): the popover's turn-around reads Popover's live curves
vjeux 99d352d
Merge motion/morph-path into motion/rect-morph
vjeux 33a3ee8
docs(changeset): the rect morph's note names the eased-progress move
vjeux 876eee1
feat(lift): one opt-in lift-from-trigger presentation
vjeux 2b23b70
docs(lift): the lift's springs are the caller's, not motion roles
vjeux a1c82b7
refactor(lift): the lift holds input through the layer runtime's inpu…
vjeux bc2d667
refactor(liftFromTrigger): the lift-from-trigger module takes its rec…
vjeux 365fca0
test(liftFromTrigger): a lifted list item leaves its list valid and t…
vjeux cc48c18
fix(liftFromTrigger): an li holds a list item's spot, and takes no nu…
vjeux a3d820d
test(liftFromTrigger): the zoom's copy of a source, through the lift …
vjeux 40ee8c4
feat(liftFromTrigger): liftCopy, the zoom's copy of a source through …
vjeux f7df475
test(liftFromTrigger): browser proofs of the zoom's copy
vjeux ceb3da9
docs(layer-runtime): the lift-from-trigger module exists
vjeux e9a26c8
fix(liftFromTrigger): each spring reaches the rect morph as a motion
vjeux 5bc1866
refactor(liftFromTrigger): the caller names the lift's marks
vjeux 991b361
docs(liftFromTrigger): comments say why, not which record
vjeux 073520e
test(liftFromTrigger): a finger taps the growing lift, from the harness
vjeux 1e1e3b5
docs(liftFromTrigger): comment sentences keep to 25 words
vjeux 33630ee
test(liftFromTrigger): a tall source fits between the gutters
vjeux 5747593
fix(liftFromTrigger): the lift fits between the gutters on both axes
vjeux 7b7918c
test(liftFromTrigger): a pinch on the lifted backdrop zooms the page
vjeux b5ac7b9
fix(liftFromTrigger): the lift layer keeps the page's pinch-zoom
vjeux fdf5f74
test(liftFromTrigger): a drop mid-rise turns the tint with its velocity
vjeux a966b6c
fix(liftFromTrigger): the tint fades on the animator's backdrop channel
vjeux 34c1812
refactor(liftFromTrigger): the shadow rides a rect morph of its own
vjeux 1300256
test(liftFromTrigger): a dropped row's margin returns with no transition
vjeux 106ec92
fix(liftFromTrigger): the restore flushes style with a non-geometry read
vjeux 9e24365
fix(liftFromTrigger): the lifted trigger casts no shadow
vjeux eb7e753
test(liftFromTrigger): the growth sample reads its animations at 30 ms
vjeux 4aa36aa
fix(liftFromTrigger): a source lifts at its layout width
vjeux File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: one opt-in lift-from-trigger presentation for touch surfaces. The trigger itself lifts into the top layer above a tinted backdrop; a surface grows out of the press beside it on the shared rect morph and shrinks back into it on the layer runtime's exit copy; the trigger returns to its place. Translate, scale and opacity only; nothing mounted at rest, three elements while lifted, and no shadow. A zoom from a thumbnail lifts a copy of the thumbnail's picture into the viewer instead, while the thumbnail hides in place. Not exported from the package entry point; no component uses it yet. | ||
|
|
||
| @vjeux |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: the rect morph's paths, the geometry touch presentations share when they grow out of a source rect and shrink back into it. `lerpPath` (a straight line between two rects) and `edgeGrowPath` (UIKit's popover, growing out of a point on its edge) name the rect an element shows at each progress, and say how an interrupted motion continues in their own law; `morphCurves` turns a path into compositor curves (`translate`, `scale` and a companion `opacity`). Not exported from the package entry point; no component uses them yet. | ||
|
|
||
| @vjeux |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen. Each play takes the timing the component's motion record names (AST-094 FR45): a duration and easing, or a spring, starting from the velocity it is given. Reduced Motion is left to the animator. `easedProgress` moves to the progress helpers and keeps a retargeted velocity; `timedCurve` joins them. Not exported from the package entry point; no component uses it yet. | ||
|
|
||
| @vjeux |
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,12 +27,8 @@ import { | |
| type MotionHandle, | ||
| } from '../motion/animator'; | ||
| import {holdInput} from '../Layer/inputHold'; | ||
| import {mapProgress, mapRange} from '../motion/progress'; | ||
| import { | ||
| easedProgress, | ||
| readThemeTiming, | ||
| type ThemeTimingTokens, | ||
| } from '../motion/themeTiming'; | ||
| import {easedProgress, mapProgress, mapRange} from '../motion/progress'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| import {readThemeTiming, type ThemeTimingTokens} from '../motion/themeTiming'; | ||
| import {placePopoverExitCopy} from '../privateAttributes/popoverExit.stylex'; | ||
| import { | ||
| frameOf, | ||
|
|
@@ -56,8 +52,11 @@ export type { | |
| /** | ||
| * The curves from `from` to `to` as `progress` moves from 0 to 1. The scale | ||
| * and the length move together, so the scale along the axis is their product. | ||
| * | ||
| * @internal Read by the rect morph's popover proofs; not exported from the | ||
| * package. | ||
| */ | ||
| function surfaceCurves( | ||
| export function surfaceCurves( | ||
| from: SurfaceState, | ||
| to: SurfaceState, | ||
| axis: 'x' | 'y', | ||
|
|
@@ -82,8 +81,16 @@ function timingOn(element: Element, tokens: ThemeTimingTokens): MotionCurve { | |
| return readThemeTiming(element, tokens) ?? easedProgress(0, x => x); | ||
| } | ||
|
|
||
| /** What `animator` shows now, split back into scale and length. */ | ||
| function readState(animator: Animator, axis: 'x' | 'y'): SurfaceState { | ||
| /** | ||
| * What `animator` shows now, split back into scale and length. | ||
| * | ||
| * @internal Read by the rect morph's popover proofs; not exported from the | ||
| * package. | ||
| */ | ||
| export function readState( | ||
| animator: Pick<Animator, 'get'>, | ||
| axis: 'x' | 'y', | ||
| ): SurfaceState { | ||
| const across = animator.get(axis === 'x' ? 'scaleY' : 'scaleX'); | ||
| const along = animator.get(axis === 'x' ? 'scaleX' : 'scaleY'); | ||
| return { | ||
|
|
||
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # LiftFromTrigger | ||
|
|
||
| The one lift-from-trigger presentation, internal to core and opt-in. A | ||
| surface that chooses it (ContextMenu's touch invocation) lifts its trigger | ||
| out of the page above a tinted backdrop, grows a surface (a menu) out of the | ||
| press beside it, shrinks that surface back into the press when it closes, and | ||
| returns the trigger to its place. A viewer's zoom from a thumbnail lifts a | ||
| copy of the thumbnail's picture instead (below). Nothing else gets it by | ||
| default, and any surface that lifts uses this module, never a copy of it. | ||
| Nothing here is exported from the package entry point; components import it | ||
| by path. | ||
|
|
||
| Layer stays generic. The top layer, dismissal, the input hold | ||
| (`Layer/inputHold.ts`) and the shared exit copy | ||
| (`Layer/layerExitCopy.ts`) belong to Layer; the lift only uses them. Its | ||
| motion is the rect morph's (`motion/rectMorph.ts`), and its springs, scale | ||
| rule and gap are the caller's own. | ||
|
|
||
| ## What a lift adds | ||
|
|
||
| The trigger itself lifts, not a copy: it becomes a manual popover in the top | ||
| layer, laid out at its own size where it stood, so no clipping, transformed | ||
| or stacking ancestor cuts or covers it, and an empty placeholder after it | ||
| holds its spot, so the page does not move. In a list the placeholder is an | ||
| `li` that is not a list item, so the list stays valid and keeps its numbers. | ||
| The trigger takes no hits while lifted: a tap on it is a tap on the backdrop. | ||
| While lifted the page has three | ||
| elements more, whatever the trigger holds: the lift layer (its `::backdrop` | ||
| is the tint); an empty box at the lifted rect for the surface to be placed | ||
| against (anchor positioning follows transforms, so a box inside the trigger | ||
| would move with it); and the placeholder. The lifted trigger casts no | ||
| shadow. The input hold's shield joins while the lift grows and while it | ||
| closes: never more than four (`spec:AST-017` FR60, DEC-29). The | ||
| exit copy the surface shrinks on is the layer runtime's transient copy. At | ||
| rest there is nothing. | ||
|
|
||
| ## The zoom's copy | ||
|
|
||
| A zoom from a thumbnail into a viewer (UIKit's `.zoom`) needs the | ||
| thumbnail's picture between the viewer's backdrop and its photo, which a | ||
| trigger in the top layer cannot be: it would cover the whole viewer, or sit | ||
| under its backdrop. So `liftCopy(source, {host, src})` lifts a copy, the one | ||
| transient clone `architecture:layer-runtime` INV15 allows: one image of the | ||
| source's picture (aspect fill, its corners) in the viewer's host at the | ||
| source's box, inert and hidden from assistive technology, while the source | ||
| hides where it stands and keeps its layout (no placeholder). The viewer moves | ||
| the copy on the rect morph with its own paths and springs; a close during the | ||
| opening turns the same copy around from the rect on screen, with its | ||
| velocity. `drop()` at the landing, or on unmount, takes the copy away and | ||
| shows the source as it was, once the last copy of it drops. While a copy is | ||
| up the page has one element more. | ||
|
|
||
| ## Motion | ||
|
|
||
| Every motion is `translate`, `scale` or `opacity` on the compositor | ||
| (`spec:AST-094` FR5): the trigger and the surface on the rect morph, | ||
| which continues an interrupted motion with the velocity on screen (FR6); the | ||
| tint on the animator, as the opacity of the layer's `::backdrop`, so a drop | ||
| while it rises turns it with its velocity too. A | ||
| tap or a drag that lands while the lift and the surface grow reaches nothing | ||
| (FR35). The close holds the layer until the trigger is back, the surface's | ||
| exit is done and every press on the layer has lifted. Its timers and | ||
| document listeners end with it, and an unmount ends everything at once | ||
| (FR24). Reduce Motion is the animator's (`design:reduced-motion`). | ||
|
|
||
| ## Files | ||
|
|
||
| | File | What it is | | ||
| | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `liftFromTrigger.ts` | `createLiftFromTrigger`: lift a trigger, grow and close a surface beside it, drop, end. `liftCopy`: the zoom's copy. | | ||
| | `useLiftFromTrigger.ts` | A component's one lift, ended when it unmounts. | | ||
| | `liftFromTriggerGeometry.ts` | How far the trigger grows, where it lands inside the layer runtime's gutter, which side the surface takes. | | ||
| | `__tests__/liftFromTrigger.interaction.browser.spec.ts` | Browser proofs (Chromium and WebKit): the top layer, the placeholder, the properties that move, counts, leaks, the zoom's copy. | |
148 changes: 148 additions & 0 deletions
148
packages/core/src/liftFromTrigger/__tests__/liftFromTrigger.fixture.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| // Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|
|
||
| /** | ||
| * @file liftFromTrigger.fixture.ts | ||
| * @input Nothing: bundled by esbuild and injected into a blank page | ||
| * @output A page with a list of rows in a clipping, transformed panel, and a | ||
| * menu popover beside it. The first row is rounded by `:first-child`, and | ||
| * the second holds twelve elements. `window.__lift` lifts a row, grows and | ||
| * closes the menu, drops and ends, and logs taps and clicks. A thumbnail | ||
| * and a hidden viewer serve the zoom's copy through `window.__zoom` | ||
| * @position Browser fixture of liftFromTrigger.interaction.browser.spec.ts; test-only | ||
| */ | ||
|
|
||
| import {createRectMorph, lerpPath} from '../../motion/rectMorph'; | ||
| import { | ||
| createLiftFromTrigger, | ||
| liftCopy, | ||
| type LiftFromTriggerOptions, | ||
| type LiftFromTriggerResult, | ||
| } from '../liftFromTrigger'; | ||
|
|
||
| interface LiftLog { | ||
| backdropTaps: number; | ||
| clicks: string[]; | ||
| } | ||
|
|
||
| /** The page's handle on its lift. */ | ||
| export interface LiftFixture { | ||
| log: LiftLog; | ||
| lift: (id: string, springResponse?: number) => LiftFromTriggerResult | null; | ||
| presentMenu: (press: {x: number; y: number} | null) => void; | ||
| dismissMenu: () => void; | ||
| closeMenu: () => void; | ||
| showMenu: () => void; | ||
| drop: () => void; | ||
| dispose: () => void; | ||
| isLifted: () => boolean; | ||
| } | ||
|
|
||
| /** What a zoom proof drives: the copy, and the rect morph that moves it. */ | ||
| export interface ZoomFixture { | ||
| liftCopy: typeof liftCopy; | ||
| createRectMorph: typeof createRectMorph; | ||
| lerpPath: typeof lerpPath; | ||
| } | ||
|
|
||
| declare global { | ||
| interface Window { | ||
| __lift: LiftFixture; | ||
| __zoom: ZoomFixture; | ||
| } | ||
| } | ||
|
|
||
| document.documentElement.style.cssText = | ||
| '--color-overlay: rgba(1, 18, 40, 0.4); --spacing-4: 16px;'; | ||
| document.body.style.margin = '0'; | ||
| document.body.style.font = '16px sans-serif'; | ||
| const deep = Array.from( | ||
| {length: 3}, | ||
| (_, i) => | ||
| `<div><span>${i * 3 + 1}</span><span>${i * 3 + 2}</span><span>${i * 3 + 3}</span></div>`, | ||
| ).join(''); | ||
| document.body.innerHTML = ` | ||
| <style> | ||
| .panel { position: absolute; left: 20px; top: 120px; width: 335px; height: 250px; overflow: hidden; transform: translateX(0.5px); border-radius: 12px; } | ||
| ul { list-style: none; margin: 0; padding: 0; } | ||
| li { box-sizing: border-box; height: 60px; padding: 0 16px; display: flex; align-items: center; gap: 8px; background: rgb(255, 255, 255); border-bottom: 1px solid rgb(221, 221, 221); color: rgb(17, 17, 17); } | ||
| li:first-child { border-radius: 12px 12px 0 0; } | ||
| #menu { position: fixed; inset: auto; margin: 0; padding: 0; border: 0; left: 40px; top: 420px; width: 250px; height: 180px; background: rgb(238, 238, 238); border-radius: 16px; } | ||
| </style> | ||
| <div class="panel"> | ||
| <ul id="list"> | ||
| <li id="row1">Row 1</li> | ||
| <li id="row2" style="color: rgb(0, 0, 200)">${deep}</li> | ||
| <li id="row3">Row 3</li> | ||
| <li id="row4">Row 4</li> | ||
| </ul> | ||
| </div> | ||
| <button id="page" style="position: absolute; left: 20px; top: 640px; width: 335px; height: 120px">page</button> | ||
| <div id="chip" style="position: absolute; left: 20px; top: 40px; width: 100.4px; line-height: 20px; background: rgb(255, 255, 255)"><span id="chip-a" style="display: inline-block; width: 50.2px; height: 20px"></span><span id="chip-b" style="display: inline-block; width: 50.2px; height: 20px"></span></div> | ||
| <div id="menu" popover="manual"><div role="menu" aria-label="Actions">Menu</div></div> | ||
| <button id="thumb" type="button" style="position: absolute; left: 280px; top: 20px; width: 80px; height: 80px; padding: 0; border: 0; border-radius: 10px; overflow: hidden"><img alt="A photo" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120'%3E%3Crect width='160' height='120' fill='rgb(0,140,0)'/%3E%3C/svg%3E" style="display: block; width: 100%; height: 100%; object-fit: cover"></button> | ||
| <div id="viewer" style="display: none; position: fixed; inset: 0; isolation: isolate; background: rgba(0, 0, 0, 0.8)"> | ||
| <div id="photo" style="position: absolute; left: 20px; top: 260px; width: 335px; height: 251px; background: rgb(200, 0, 0)"></div> | ||
| </div>`; | ||
|
|
||
| function element(id: string): HTMLElement { | ||
| const found = document.getElementById(id); | ||
| if (found == null) { | ||
| throw new Error(`missing #${id}`); | ||
| } | ||
| return found; | ||
| } | ||
|
|
||
| const log: LiftLog = {backdropTaps: 0, clicks: []}; | ||
| document.addEventListener( | ||
| 'click', | ||
| event => { | ||
| const target = event.target; | ||
| if (target instanceof Element) { | ||
| log.clicks.push(target.id || target.tagName.toLowerCase()); | ||
| } | ||
| }, | ||
| true, | ||
| ); | ||
|
|
||
| const lift = createLiftFromTrigger(); | ||
|
|
||
| function options(response: number): LiftFromTriggerOptions { | ||
| const spring = {response, dampingRatio: 0.9}; | ||
| return { | ||
| motion: { | ||
| lift: spring, | ||
| drop: spring, | ||
| clear: spring, | ||
| surfaceIn: spring, | ||
| surfaceOut: spring, | ||
| }, | ||
| scale: {maxScale: 1.15, scaleUpPoints: 26}, | ||
| surfaceStartWidth: 44, | ||
| attribute: 'data-lift', | ||
| onBackdropTap: () => { | ||
| log.backdropTaps += 1; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| window.__zoom = {liftCopy, createRectMorph, lerpPath}; | ||
|
|
||
| window.__lift = { | ||
| log, | ||
| lift: (id, springResponse = 0.3) => | ||
| lift.lift(element(id), options(springResponse)), | ||
| showMenu: () => element('menu').showPopover(), | ||
| presentMenu: press => { | ||
| element('menu').showPopover(); | ||
| lift.presentSurface( | ||
| element('menu').querySelector<HTMLElement>('[role="menu"]') ?? | ||
| element('menu'), | ||
| press, | ||
| ); | ||
| }, | ||
| dismissMenu: () => lift.dismissSurface(element('menu')), | ||
| closeMenu: () => element('menu').hidePopover(), | ||
| drop: () => lift.drop(), | ||
| dispose: () => lift.dispose(), | ||
| isLifted: () => lift.isLifted(), | ||
| }; |
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional: "the one lift-from-trigger module exists" is build status inside a gaps paragraph. Stating INV15's gap as its observable mismatch keeps the record free of temporary status, for example "INV15 gap: ContextMenu and Lightbox do not use the lift-from-trigger module yet".