diff --git a/documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx b/documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx
new file mode 100644
index 0000000..409bbe0
--- /dev/null
+++ b/documentation/content/docs/api/jindong-compose/composable-dsl/clip.mdx
@@ -0,0 +1,105 @@
+---
+title: Clip
+description: DSL function to place a prebuilt pattern on the timeline
+---
+
+# Clip
+
+
+**Module**: `jindong-compose` | **Package**: `io.github.compose.jindong.dsl`
+
+
+Places a prebuilt `HapticPattern` on the timeline at the current position.
+
+## Signature
+
+```kotlin
+@Composable
+fun JindongScope.Clip(pattern: HapticPattern)
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `pattern` | `HapticPattern` | The prebuilt pattern to place on the timeline |
+
+## Description
+
+`Clip` drops a self-contained pattern next to inline nodes. It is the bridge between a `HapticPattern` value, typically an algebra result built with `buildHapticPattern`, and the composable DSL. The clip's events are offset by the nodes that precede it, so it schedules relative to its position like any other node.
+
+```kotlin
+val heartbeat = buildHapticPattern {
+ haptic(60.ms)
+ delay(80.ms)
+ haptic(40.ms)
+}
+
+Jindong(trigger) {
+ Clip(heartbeat)
+ Delay(200.ms)
+ Haptic(60.ms, HapticIntensity.MEDIUM)
+}
+```
+
+## Freezing and re-firing
+
+A clip captures its `pattern` at compile time. Because `Jindong` compiles content in a single pass with no recomposition (see [The Reactive Contract](/docs/guide/reactive-contract)), swapping the pattern in state alone does not update the clip. Thread the pattern through the trigger keys to make it live:
+
+```kotlin
+Jindong(pattern) { Clip(pattern) }
+```
+
+`HapticPattern` is a data class, so the key comparison is structural: an equal pattern does not re-fire, a different one does.
+
+
+Do not add a `Jindong(pattern, vararg keys)` overload to skip the `Clip` call. `Jindong(p) { ... }` resolves to the existing `vararg keys` overload with `p` as a key, which compiles but plays the lambda instead of `p`. The `Jindong(pattern) { Clip(pattern) }` idiom is the supported way to embed a value.
+
+
+## Usage
+
+### Composing with the pattern algebra
+
+Clips pair well with the pattern transforms, which return new `HapticPattern` values:
+
+```kotlin
+val base = buildHapticPattern {
+ haptic(50.ms)
+ delay(50.ms)
+ haptic(50.ms)
+}
+
+Jindong(speed) {
+ Clip(base.timeStretch(1f / speed))
+}
+```
+
+### Reusing a pattern across screens
+
+```kotlin
+private val confirm = buildHapticPattern {
+ haptic(50.ms)
+ delay(50.ms)
+ haptic(100.ms)
+}
+
+@Composable
+fun SaveButton(saved: Boolean) {
+ Jindong(saved) {
+ if (saved) Clip(confirm)
+ }
+}
+```
+
+## Notes
+
+- The pattern is captured when the node is inserted, not on every frame
+- Preceding `Delay` and `Haptic` nodes shift the clip's start time
+- `HapticPattern` equality is structural, which drives key comparison
+- An empty pattern (`HapticPattern.Empty`) places nothing
+
+## See Also
+
+- [The Reactive Contract](/docs/guide/reactive-contract)
+- [Sequence](/docs/api/jindong-compose/composable-dsl/sequence)
+- [Core API](/docs/api/jindong-core/core-api)
diff --git a/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json b/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json
index fe344e2..b77284c 100644
--- a/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json
+++ b/documentation/content/docs/api/jindong-compose/composable-dsl/meta.json
@@ -5,6 +5,7 @@
"delay",
"sequence",
"repeat",
- "repeat-with-index"
+ "repeat-with-index",
+ "clip"
]
}
diff --git a/documentation/content/docs/api/jindong-compose/jindong.mdx b/documentation/content/docs/api/jindong-compose/jindong.mdx
index ddfbaa3..f1d13e7 100644
--- a/documentation/content/docs/api/jindong-compose/jindong.mdx
+++ b/documentation/content/docs/api/jindong-compose/jindong.mdx
@@ -76,6 +76,22 @@ This is useful for:
The content block is compiled into a `HapticPattern` containing scheduled events. The pattern is then executed by the `HapticExecutor` provided via `LocalHapticExecutor`.
+### The Reactive Contract
+
+`Jindong` reads values and fires playback under three rules:
+
+1. Values read inside `content` are frozen at compile time. The block is compiled once per key change and does not recompose, so a captured state value is read once.
+2. Keys are the playback trigger. Put a value in `keys` when its change should play; keep pattern-shaping values inside the block as parameters.
+3. Cancel-and-restart is best effort. A key change cancels the in-flight playback first, but the platform stop calls report no completion, so brief physical overlap is possible.
+
+To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it inside the block. For a prebuilt pattern, thread it through the keys and place it with `Clip`:
+
+```kotlin
+Jindong(pattern) { Clip(pattern) }
+```
+
+See [The Reactive Contract](/docs/guide/reactive-contract) for the full explanation.
+
## Usage
### Basic Usage
@@ -169,6 +185,7 @@ Within the `JindongScope`, you can use:
| [`Sequence`](/docs/api/jindong-compose/composable-dsl/sequence) | Group events sequentially |
| [`Repeat`](/docs/api/jindong-compose/composable-dsl/repeat) | Repeat content N times |
| [`RepeatWithIndex`](/docs/api/jindong-compose/composable-dsl/repeat-with-index) | Repeat with index access |
+| [`Clip`](/docs/api/jindong-compose/composable-dsl/clip) | Place a prebuilt pattern on the timeline |
## Notes
@@ -179,6 +196,7 @@ Within the `JindongScope`, you can use:
## See Also
+- [The Reactive Contract](/docs/guide/reactive-contract)
- [JindongProvider](/docs/api/jindong-compose/jindong-provider)
- [JindongScope](/docs/api/jindong-compose/jindong-scope)
- [Quick Start](/docs/guide/quick-start)
diff --git a/documentation/content/docs/api/meta.json b/documentation/content/docs/api/meta.json
index 406e9eb..4e3cd2b 100644
--- a/documentation/content/docs/api/meta.json
+++ b/documentation/content/docs/api/meta.json
@@ -14,6 +14,7 @@
"jindong-compose/composable-dsl/delay",
"jindong-compose/composable-dsl/sequence",
"jindong-compose/composable-dsl/repeat",
- "jindong-compose/composable-dsl/repeat-with-index"
+ "jindong-compose/composable-dsl/repeat-with-index",
+ "jindong-compose/composable-dsl/clip"
]
}
diff --git a/documentation/content/docs/guide/meta.json b/documentation/content/docs/guide/meta.json
index 7c4e5f9..0ec889e 100644
--- a/documentation/content/docs/guide/meta.json
+++ b/documentation/content/docs/guide/meta.json
@@ -3,6 +3,7 @@
"thinking-declarative",
"why-jindong",
"getting-started",
- "quick-start"
+ "quick-start",
+ "reactive-contract"
]
}
diff --git a/documentation/content/docs/guide/quick-start.mdx b/documentation/content/docs/guide/quick-start.mdx
index 06a6885..bef0426 100644
--- a/documentation/content/docs/guide/quick-start.mdx
+++ b/documentation/content/docs/guide/quick-start.mdx
@@ -270,7 +270,8 @@ Jindong(level) {
```
If a captured value is not in the keys, the pattern keeps the value from the last
-key change instead of updating.
+key change instead of updating. [The Reactive Contract](/docs/guide/reactive-contract)
+covers when values are read and when playback fires.
## Next Steps
diff --git a/documentation/content/docs/guide/reactive-contract.mdx b/documentation/content/docs/guide/reactive-contract.mdx
new file mode 100644
index 0000000..f8ec866
--- /dev/null
+++ b/documentation/content/docs/guide/reactive-contract.mdx
@@ -0,0 +1,83 @@
+---
+title: The Reactive Contract
+description: How Jindong decides when to play and which values it reads
+---
+
+import { Callout } from 'fumadocs-ui/components/callout';
+
+# The Reactive Contract
+
+`Jindong` behaves like `LaunchedEffect`: it plays the pattern in its content block, and a change to any key restarts it. Three rules describe exactly which values it reads and when it fires. Everything about reactive `Jindong` usage follows from them.
+
+## Rule 1: values in content are frozen at compile time
+
+When a key changes, `Jindong` compiles the content block into a pattern once and plays it. The compilation runs in a composition that produces the pattern in a single pass and is then torn down, so there is no recomposition. A state value the block reads is read once, at that compile, and does not update on its own afterward.
+
+```kotlin
+var level by remember { mutableStateOf(1) }
+
+// `level` is read once, when the current key last changed.
+Jindong(trigger) {
+ RepeatWithIndex(level) { index ->
+ Haptic(50.ms)
+ }
+}
+```
+
+If `level` changes but `trigger` does not, the played pattern keeps the `level` from the last time `trigger` changed.
+
+## Rule 2: keys are the playback trigger
+
+A key change is what fires playback. Put a value in the keys exactly when its change should play. Values that only shape the pattern belong inside the block as parameters.
+
+The distinction matters most with continuous input. A slider value passed as a key fires a vibration on every drag step:
+
+```kotlin
+// Fires on every value change while dragging.
+Jindong(sliderValue) {
+ Haptic((sliderValue * 100).toInt().ms)
+}
+```
+
+The same value as a parameter shapes the next playback without firing on each step:
+
+```kotlin
+// Reads sliderValue when `commit` changes; does not fire mid-drag.
+Jindong(commit) {
+ Haptic((sliderValue * 100).toInt().ms)
+}
+```
+
+To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it inside the block:
+
+```kotlin
+Jindong(level) {
+ RepeatWithIndex(level) { index ->
+ Haptic(50.ms, HapticIntensity.Custom(1f - index * 0.1f))
+ }
+}
+```
+
+
+Prebuilt patterns follow the same rule. `Clip` captures its pattern at compile time, so thread the pattern through the keys to re-fire on change: `Jindong(pattern) { Clip(pattern) }`. See [Clip](/docs/api/jindong-compose/composable-dsl/clip).
+
+
+## Rule 3: cancel-and-restart is best effort
+
+A key change cancels the in-flight playback before starting the new one. The manager serializes this with a state lock, so a fast sequence of key changes cancels in order and the last one wins.
+
+The cancellation itself is best effort. Neither `Vibrator.cancel()` on Android nor `CHHapticPatternPlayer.stop` on iOS reports when the hardware has actually stopped, so a few milliseconds of physical overlap between the old and new playback are possible. Patterns that change keys rapidly should not assume a clean cut between them.
+
+## Choosing keys
+
+| Value | Where it goes | Effect |
+|-------|---------------|--------|
+| Should fire playback when it changes | Key | Each change plays |
+| Shapes the pattern, read at the next fire | Parameter in the block | Frozen until a key changes |
+| Should both shape and fire | Key and read in the block | Each change plays with the new value |
+
+## See also
+
+- [Jindong API](/docs/api/jindong-compose/jindong)
+- [Clip](/docs/api/jindong-compose/composable-dsl/clip)
+- [Quick Start](/docs/guide/quick-start)
diff --git a/documentation/content/docs/guide/thinking-declarative.mdx b/documentation/content/docs/guide/thinking-declarative.mdx
index 1fdff71..8374dc8 100644
--- a/documentation/content/docs/guide/thinking-declarative.mdx
+++ b/documentation/content/docs/guide/thinking-declarative.mdx
@@ -89,4 +89,6 @@ Declarative programming means:
2. **Delegate control** to the framework
3. **Trust the abstraction** to handle details
-This mental model—focusing on "what" rather than "how"—is the foundation of effective Jindong usage.
+This mental model, focusing on "what" rather than "how", is the foundation of effective Jindong usage.
+
+The one place the framework's control shows through is timing: `Jindong` reads its content and fires playback under a small set of rules. [The Reactive Contract](/docs/guide/reactive-contract) spells them out.
diff --git a/documentation/content/docs/meta.json b/documentation/content/docs/meta.json
index fce9c36..e974ee8 100644
--- a/documentation/content/docs/meta.json
+++ b/documentation/content/docs/meta.json
@@ -16,6 +16,7 @@
"api/jindong-compose/composable-dsl/sequence",
"api/jindong-compose/composable-dsl/repeat",
"api/jindong-compose/composable-dsl/repeat-with-index",
+ "api/jindong-compose/composable-dsl/clip",
"---Contributing---",
"...contributing"
]
diff --git a/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt b/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt
index 2158e6f..782bdea 100644
--- a/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt
+++ b/jindong-compose/src/commonMain/kotlin/io/github/compose/jindong/Jindong.kt
@@ -29,12 +29,24 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
- * Composable that triggers haptic pattern execution when keys change.
+ * Composable that compiles the haptic pattern in [content] and plays it when [keys] change.
*
- * This works similarly to [LaunchedEffect] - when any of the keys change,
- * the haptic pattern defined in [content] is compiled and executed.
+ * Like [LaunchedEffect], a change to any key restarts the effect: the pattern is recompiled and
+ * played. The reactive behavior follows three rules.
+ *
+ * 1. Values read inside [content] are frozen at compile time. The pattern is compiled once per key
+ * change through a single-shot composition that never recomposes (see [compilePattern]), so a
+ * state value the block captures is read once and does not update on its own.
+ * 2. Keys are the playback trigger. Put a value in [keys] exactly when its change should fire
+ * playback. A slider value passed as a key fires on every drag step; state that only shapes the
+ * pattern belongs inside [content] as a parameter.
+ * 3. Cancel-and-restart is best effort. A key change cancels the in-flight playback before starting
+ * the new one, ordered by the manager's state lock. The platform stop calls do not report
+ * completion, so a few milliseconds of physical overlap are possible.
+ *
+ * To make a value both shape the pattern and re-fire when it changes, pass it as a key and read it
+ * inside the block:
*
- * Example:
* ```
* var count by remember { mutableStateOf(0) }
*
@@ -48,6 +60,13 @@ import kotlinx.coroutines.launch
* }
* ```
*
+ * To embed a prebuilt pattern and re-fire when it changes, thread it through the keys and place it
+ * with [io.github.compose.jindong.dsl.Clip]:
+ *
+ * ```
+ * Jindong(pattern) { Clip(pattern) }
+ * ```
+ *
* @param keys Keys that trigger re-execution when changed (like [LaunchedEffect])
* @param content DSL block defining the haptic pattern
*/
diff --git a/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/JindongContractTest.kt b/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/JindongContractTest.kt
new file mode 100644
index 0000000..20bc622
--- /dev/null
+++ b/jindong-compose/src/commonTest/kotlin/io/github/compose/jindong/JindongContractTest.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2026 compose-jindong
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:OptIn(ExperimentalTestApi::class)
+
+package io.github.compose.jindong
+
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.runComposeUiTest
+import io.github.compose.jindong.core.ms
+import io.github.compose.jindong.dsl.Haptic
+import io.github.compose.jindong.executor.LocalHapticExecutor
+import io.github.compose.jindong.executor.RecordingHapticExecutor
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+
+/**
+ * Guards the reactive contract at the [Jindong] level: keys are the only playback trigger, and a
+ * value read inside `content` but left out of the keys is frozen at the last key change.
+ *
+ * The Clip-level counterpart lives in [ClipTest]; this covers a parameter read directly by an
+ * inline node.
+ */
+class JindongContractTest :
+ FunSpec({
+ test("a parameter read inside content but absent from keys does not re-fire") {
+ runComposeUiTest {
+ val recorder = RecordingHapticExecutor()
+ val triggerKey = mutableStateOf(0)
+ val durationMs = mutableStateOf(50)
+
+ setContent {
+ val key by triggerKey
+ val duration by durationMs
+ CompositionLocalProvider(LocalHapticExecutor provides recorder) {
+ // duration shapes the pattern but is not a key, so changing it must not fire.
+ Jindong(key) {
+ Haptic(duration.ms)
+ }
+ }
+ }
+
+ waitForIdle()
+ recorder.executedPatterns.size shouldBe 1
+ recorder.executedPatterns.last().events.single().durationMs shouldBe 50
+
+ durationMs.value = 200
+ waitForIdle()
+
+ // Contract: only a key change triggers playback. Mutating a parameter leaves the executor
+ // untouched, so no new pattern is recorded.
+ recorder.executedPatterns.size shouldBe 1
+ recorder.executedPatterns.last().events.single().durationMs shouldBe 50
+ }
+ }
+
+ test("a key change re-fires and recompiles content with the current parameter") {
+ runComposeUiTest {
+ val recorder = RecordingHapticExecutor()
+ val triggerKey = mutableStateOf(0)
+ val durationMs = mutableStateOf(50)
+
+ setContent {
+ val key by triggerKey
+ val duration by durationMs
+ CompositionLocalProvider(LocalHapticExecutor provides recorder) {
+ Jindong(key) {
+ Haptic(duration.ms)
+ }
+ }
+ }
+
+ waitForIdle()
+ recorder.executedPatterns.size shouldBe 1
+ recorder.executedPatterns.last().events.single().durationMs shouldBe 50
+
+ // Update the parameter first: it stays frozen until a key change picks it up.
+ durationMs.value = 200
+ triggerKey.value = 1
+ waitForIdle()
+
+ // The key changed, so playback fires again and the recompiled pattern reflects the
+ // parameter value read at that point.
+ recorder.executedPatterns.size shouldBe 2
+ recorder.executedPatterns.last().events.single().durationMs shouldBe 200
+ }
+ }
+ })