Skip to content

feat: composable/lambda extension points for tooltip, connector, and cutout rendering - #53

Merged
aldefy merged 2 commits into
mainfrom
feature/extensibility-slots
Aug 20, 2026
Merged

aldefy merged 2 commits into
mainfrom
feature/extensibility-slots

Conversation

@aldefy

@aldefy aldefy commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Summary

Adds three general extension points so app- and brand-specific rendering doesn't need a new enum case or config field added to :lumen every time:

  • CoachmarkConfig.progressIndicator — optional @Composable slot replacing the built-in progress dots entirely. Receives (currentStep, totalSteps). null (default) keeps existing behavior.
  • CoachmarkConfig.customConnector — optional DrawScope lambda that takes over the whole connector (line + endpoint), given the cutout-edge anchor, tooltip-edge anchor, and reveal progress (0f..1f). ConnectorStyle/ConnectorEndStyle are ignored while set.
  • CutoutShape.Custom — a pathBuilder case for cutout outlines that aren't Circle/RoundedRect/Rect/Squircle/Star. Draws through the same BlendMode.Clear offscreen-layer path as every built-in shape, including the PULSE/GLOW/BOUNCE stroke-scale animation.

Also keeps ProgressIndicatorStyle.PILL (from #52) as a built-in convenience layered on top of the new slot — it's a common enough idiom (iOS-style page indicator) to ship directly, while the slot covers everything else.

Why

Reviewing #51 (TEARDROP connector) and #52 (PILL indicator) against the actual design screens driving them showed a pattern: each new visual requirement was landing as a new sealed enum case + several CoachmarkConfig fields in the library, for something entirely expressible in app code. CoachmarkConfig was already at 40 fields before those two PRs. CutoutShape is sealed with no escape hatch. CoachmarkTooltip has 34 params, all primitives, zero @Composable slots.

This PR adds the missing general escape hatches instead. Verified all three on-device (Pixel 9 Pro Fold) via a new "Custom Rendering" sample rebuilding a pill indicator, a teardrop speech-bubble connector, and a diamond cutout — none of it touching :lumen's enum surface.

Compatibility

All three new fields default to null/absent. Every existing caller is unaffected — confirmed by the full test suite (230 Android/Robolectric + 201 JVM, all passing, including every existing Roborazzi golden byte-for-byte unchanged).

Test plan

  • ./gradlew :lumen:apiCheck — passes, dumps regenerated
  • ./gradlew :lumen:testDebugUnitTest :lumen:jvmTest — 230 + 201 tests, 0 failures
  • ./gradlew :lumen:compileKotlinMetadata :lumen:compileDebugKotlinAndroid :lumen:compileKotlinJvm :sample:compileDebugKotlinAndroid :sample:compileKotlinWasmJs — all green
  • On-device verification (Pixel 9 Pro Fold, Android 17) via the new Custom Rendering sample: pill progress indicator, teardrop connector, and diamond cutout all render correctly, cutout remains a true transparent hole

aldefy and others added 2 commits August 20, 2026 09:35
…r rendering

Adds three ways for app code to customize rendering without a new enum
case or config field in :lumen:

- CoachmarkConfig.progressIndicator: optional @composable slot replacing
  the built-in progress dots.
- CoachmarkConfig.customConnector: optional DrawScope lambda taking over
  the entire connector (line + endpoint) given the two anchors and reveal
  progress. ConnectorStyle/ConnectorEndStyle are ignored while set.
- CutoutShape.Custom: pathBuilder case for cutout outlines that aren't one
  of the built-ins. Draws through the same BlendMode.Clear offscreen-layer
  path as every built-in shape (including the PULSE/GLOW/BOUNCE stroke
  scale animation) so a custom shape isn't a second-class citizen.

All three default to null/absent, so every existing caller is unaffected.

Sample: new "Custom Rendering" example rebuilding a pill progress
indicator, a teardrop speech-bubble connector, and a diamond cutout
entirely in app code, to demonstrate the extension points replace
per-feature library additions.

Bump to 1.0.0-beta16.
Adds ProgressIndicatorStyle (DOTS default / PILL) so the current step in
a multi-step tooltip's progress indicator can render as an elongated pill
instead of a same-size dot. Kept as a built-in convenience layered on top
of the new CoachmarkConfig.progressIndicator slot: PILL covers the common
case out of the box, the slot covers everything else.

New CoachmarkConfig.progressIndicatorStyle and
CoachmarkConfig.progressActivePillWidth (default 20.dp) control it. No new
color config needed - reuses existing CoachmarkColors.progressActiveColor
/ progressInactiveColor. DOTS behavior is unchanged.

Added a "Start Tour (Pill)" button to the Multi-Step Sequence sample.
@aldefy
aldefy merged commit acd60da into main Aug 20, 2026
6 checks passed
aldefy added a commit that referenced this pull request Aug 20, 2026
…theme contrast fix (#54)

* feat: per-target ConnectorStyle.CUSTOM, sample teardrop tail demo

Dry-run for re-scoping #51 (ConnectorStyle.TEARDROP) onto #53's
customConnector extension point.

Finding: config.customConnector from #53 is sequence-wide only, with no
per-target override. Paul's TEARDROP could mix a teardrop connector with
an elbow connector in the same sequence (per-target CoachmarkTarget field);
customConnector alone could not. This fixes that gap:

- ConnectorStyle.CUSTOM: new enum case, the generic gate for full-control
  connector rendering (mirrors the existing ConnectorEndStyle.CUSTOM
  pattern). resolveConnectorStyle() resolves it through the same heuristic
  as AUTO for geometry purposes, so the anchors/fallback path stay correct.
- CoachmarkTarget.customConnector: per-target override, takes priority
  over CoachmarkConfig.customConnector when both are set.
- CUSTOM with no lambda supplied anywhere falls back to drawing a normal
  connector line rather than nothing, verified via a new screenshot test.

Sample: added a "Teardrop Tail" step to ConnectorsExample using
ConnectorStyle.CUSTOM, and a drawTeardropTail() helper — Paul's original
"hill" silhouette (compared against a classic pointed teardrop and a true
geometric pear/raindrop shape; the hill was the right call for a tail meant
to read as continuous with a rounded card, not a separate sharp shape).
Verified on-device end to end.

Two new CoachmarkScrimScreenshotTest cases cover priority (target lambda
wins over config default) and fallback (CUSTOM with nothing supplied still
draws a line) behavior.

* feat: speech-bubble tooltip shape with tail carved into the card outline

Adds CoachmarkConfig.tooltipShape + tooltipTailInset: the tail is one
continuous outline with the card (via a Shape), not a separately-drawn
nub next to it — matches a real speech-bubble reference design where the
tail reads as part of the card, no seam.

- shapes/SpeechBubblePath.kt: SpeechBubbleShape + speechBubblePath(),
  following the existing StarPath/SquirclePath convention. The card body
  is sized to (measured size - tailInset) so the tail draws inside the
  Shape's own bounds rather than bumping past them, since clip() always
  bounds to the actual layout size.
- CoachmarkTooltip gains tailAnchorX/tooltipShape/tooltipTailInset params.
  A Spacer inside the Column reserves tooltipTailInset on the tail's side
  so the Column's measured size includes room for the shape's tail.
- Scrim computes tailAnchorX the same way the existing inline-dot-X code
  does: target center X relative to the tooltip's left edge.

Sample: CustomRenderingExample's step 2 now uses tooltipShape instead of
customConnector for the speech-bubble tail (customConnector kept only as
a no-op to suppress the built-in line, since ConnectorStyle has no NONE
case today - a real gap worth a follow-up).

Two bugs found and fixed against on-device verification, not just visual
inspection:

1. Tail direction was inverted (isTooltipBelow=true was mapped to
   tailOnBottom=true; it should be the opposite - tooltip below the
   target means the tail points UP, on the top edge, toward it).

2. tooltipCardColor in DarkCoachmarkColors and the default CoachmarkColors
   failed WCAG contrast against scrimColor: DarkCoachmarkColors measured
   1.19:1, default measured 1.38:1 (need >=3:1 for a UI boundary), because
   both defaulted to a near-black card over a near-black scrim. The
   default's titleColor = Color.Black on a dark card additionally failed
   text contrast outright (1.38:1, needs >=4.5:1) - that one predates this
   change and was previously undetected because the sample never exercised
   showTooltipCard=true with the plain default colors.

   Fixed by moving both dark-mode-adjacent card colors to #666666 and
   fixing the default's title/description colors to white/near-white to
   match its (now-legible) dark card. Verified: card-vs-scrim >=3.1:1,
   title-vs-card >=6.2:1, description-vs-card >=3.86:1 in both palettes.
   LightCoachmarkColors was already fine (17.26:1) and is untouched.

Re-recorded the 12 tooltip_* goldens affected by the card color change.
Full suite green: 232 Android + 201 JVM tests, apiCheck passes.

* feat(sample): speech-bubble tail on the LazyColumn whole-row target

Adds CoachmarkConfig.tooltipShape to the LazyColumn example's existing
"Item Cards" step (list_card), which already targets a whole row via
CutoutShape.RoundedRect. This is the closest existing target to the
reference speech-bubble tooltip design - a whole list row, tooltip above
it, tail carved into the card's own bottom edge pointing down at the row,
no connector line. Verified on-device against a real scrollable list.

Also confirmed and documented a real gap while building this: tooltipShape
only supports a top or bottom tail (SpeechBubbleShape has no left/right
case), and CoachmarkScrim always computes tailAnchorX from
target.bounds.center.x regardless of connector direction. Verified live
with a temporary HORIZONTAL-connector target: the line correctly points
sideways at the target while the tail sits meaninglessly on the bottom
edge, disconnected from what the line points at. Left as a known
limitation for a future PR rather than silently working around it -
tooltipShape should not be combined with HORIZONTAL-style targets today.

* chore: bump to 1.0.0-beta17

CHANGELOG entry for the tooltipShape / per-target CUSTOM connector /
contrast-fix work in this branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants