Pull-to-refresh
interaction specs/interaction/pull-to-refresh.kmd
Material 3 Expressive pull-to-refresh pattern: drag from top ≥ threshold triggers refresh; spring-snap release; loading indicator morphs during drag and persists until completion. Mandatory binding to `loading-indicator.kmd` for visual consistency.
When this spec applies
Primary triggers
- Add pull-to-refresh gesture to a scroll surface
All triggers
- User pulls down from top of list/feed to refresh
- Implement feed surface in any Koder product
- Replace existing pull-to-refresh implementation with Koder-canonical
Specification body
Spec — Pull-to-refresh
Mandatory binding:
loading-indicator.kmdR4. Driver:motion.kmdR9.1 spring physics.
Princípios
- Threshold-gated trigger — drag distance ≥ threshold (40dp default).
- Loading indicator hosted — uses
loading-indicator.kmd(NÃO custom spinner). - Spring-snap release — release < threshold → snap-back; release ≥ → refresh triggered.
- Keyboard alternative —
Cmd/Ctrl+R(desktop) refresh trigger. - Multi-tenant safe — refresh scope respeita workspace context.
R1 — Trigger gesture
Pre-conditions:
- Scrollable container.
- Scroll position at top (offset = 0).
Drag down:
- Distance < threshold (40dp): static loading indicator at smaller scale 0.6, partial opacity.
- Distance ≥ threshold: indicator full size + shape morph begins (per
loading-indicator.kmdR2). - Distance > 2× threshold: indicator stays at threshold position (rubber-band resistance).
R2 — Binding com Loading Indicator
Per loading-indicator.kmd R4:
| Drag stage | Indicator state |
|---|---|
| 0 → 40dp | Static Cookie-4 shape, scale 0.6 → 1.0, opacity 0.3 → 1.0 |
| 40dp+ | Morph cycle begins (Cookie-4 → Burst → Flower) |
| Release ≥ 40dp | Indicator stays full-size, cycle continues until refresh completes |
| Refresh done | Cycle completes current frame, indicator fades out |
R3 — Release behavior
| User action | Behavior |
|---|---|
| Release at < threshold | Snap back to 0 via spring motion-spatial-default; indicator fades out |
| Release at ≥ threshold | Snap back to indicator-position (40dp from top); refresh callback invoked; indicator persists |
| Refresh callback completes | Snap back to 0 via spring; indicator fades out |
| Refresh callback errors | Indicator turns red briefly; toast/snackbar with retry per errors/user-facing-messages.kmd |
R4 — Keyboard alternative
Desktop:
Cmd+R(macOS) /Ctrl+R(Win/Linux) triggers refresh equivalent.- Indicator appears briefly at top (300ms minimum visible).
CLI/TUI:
rkey ORrefreshcommand.
R5 — Multi-tenant + scope
Refresh action scope:
- Conversation history: refresh current workspace + user scope only.
- Memory drawer: idem.
- Feed surfaces: workspace-scoped per
multi-tenant-by-default.kmd.
Cross-tenant refresh NOT supported (would violate isolation).
R6 — Surface bindings
| Surface | API |
|---|---|
| Flutter | KoderRefreshIndicator wrapper around RefreshIndicator (Flutter stock); skin custom |
| Web | Custom gesture handler via pointerdown/move/up events; CSS overscroll-behavior: contain |
| Compose Android | PullToRefreshContainer (compose.material3) with Koder skin |
| SwiftUI iOS | .refreshable modifier (native); custom loading indicator overlay |
| CLI / TUI | n/a (r key trigger inline) |
R7 — Acessibilidade
- Container:
role="region" aria-label="Pull to refresh, refreshing...". - Indicator: aria-live="polite" announces "Refreshing" → "Done".
- Keyboard: Cmd+R OR explicit refresh button.
- Reduced-motion: drag-distance-driven morph disabled; threshold cross → instant indicator + spring-snap replaced by direct transition.
R8 — i18n
| Key | en-US | pt-BR |
|---|---|---|
refresh.pulling | "Pull to refresh" | "Puxe para atualizar" |
refresh.release | "Release to refresh" | "Solte para atualizar" |
refresh.refreshing | "Refreshing..." | "Atualizando..." |
refresh.done | "Done" | "Concluído" |
refresh.error | "Refresh failed" | "Falha ao atualizar" |
(Inline hints abaixo do indicator são opcionais; geralmente o indicator visual basta.)
R9 — Reduced-motion
- Drag-distance-driven morph: disabled.
- Spring snap-back: replaced by direct opacity fade (200ms).
- Threshold-cross: instant.
R10 — Per-preset variation
| Preset | Pull-to-refresh style |
|---|---|
material3 / material_expressive | Default (loading-indicator morph + spring) |
material2 | Circular spinner (legacy); no shape morph |
terminal_classic | n/a (terminal não tem pull gesture) |
brutalist | Square indicator + flat snap (no spring) |
cyberpunk_neon | Default + glow during refresh |
minimalist_mono | Thin line indicator at top; no curves |
T-suite
- T1 Drag down at top → indicator appears; scale 0.6→1.0 over 40dp drag.
- T2 Drag ≥ threshold → indicator full size + morph begins.
- T3 Release < threshold → snap-back; indicator fades; NO refresh callback.
- T4 Release ≥ threshold → spring-snap to threshold position; refresh callback invoked.
- T5 Refresh completes → indicator fades; spring to 0.
- T6 Refresh errors → indicator red briefly; toast.
- T7 Keyboard alternative: Cmd+R triggers indicator + refresh.
- T8 Reduced-motion: drag-driven morph disabled; threshold-cross instant.
- T9 A11y: aria-live announces transitions.
- T10 Multi-tenant: refresh workspace A → workspace B context unaffected.
- N1 Container NOT scrollable: gesture inactive (no spurious refresh).
Cross-link
- Indicator:
components/loading-indicator.kmdR4 - Motion:
motion.kmdR9.1 - Errors:
errors/user-facing-messages.kmd - Policies:
multi-tenant-by-default.kmd - Refs: M3 Pull-to-refresh pattern (companion to Loading indicator)
References
specs/components/loading-indicator.kmdspecs/themes/motion.kmdspecs/interaction/states.kmd