app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 117488141a55125669391a555b93eebc647b01fd
parent aceccd90a58a1ee5381eeac1e9ed4cc6415ffb68
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:26:25 +0000

design: port HarvestCircle interaction primitives

- own semantic content surface text and icon primitives
- preserve independent hover activation and focus interaction state
- provide explicit background focus-dismiss behavior
- admit only the canonical text primitive through shell policy

Diffstat:
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_add.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_check.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_down.xml | 9+++++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_left.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_right.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_close.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_delete.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_info.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_search.xml | 6++++++
Aapp/design_system/src/commonMain/composeResources/drawable/harvestcircle_warning.xml | 6++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/HarvestCircleComponentTypes.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/focus/HarvestCircleFocusDismissBehavior.kt | 13+++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/icon/HarvestCircleIcons.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/focus/HarvestCircleFocusDismissal.kt | 36++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/interaction/HarvestCircleInteraction.kt | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleContent.kt | 30++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleFocusDismissRegion.kt | 37+++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleIcon.kt | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleSurface.kt | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleText.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/lib.rs | 8++++++--
21 files changed, 603 insertions(+), 2 deletions(-)

diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_add.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_add.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M11,5h2v6h6v2h-6v6h-2v-6H5v-2h6z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_check.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_check.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M9.2,16.6L4.8,12.2l1.4,-1.4l3,3l8.6,-8.6l1.4,1.4z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_down.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_down.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M6.7,9.2L12,14.5l5.3,-5.3l1.4,1.4L12,17.3l-6.7,-6.7z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_left.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_left.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M15.4,5.4L8.8,12l6.6,6.6l-1.4,1.4L6,12l8,-8z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_right.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_chevron_right.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M8.6,18.6l6.6,-6.6l-6.6,-6.6L10,4l8,8l-8,8z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_close.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_close.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M6.4,5L12,10.6L17.6,5L19,6.4L13.4,12L19,17.6L17.6,19L12,13.4L6.4,19L5,17.6L10.6,12L5,6.4z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_delete.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_delete.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M8,4h8l1,2h3v2H4V6h3zM6,9h12l-1,11H7zM9,11v7h2v-7zM13,11v7h2v-7z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_info.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_info.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M12,2a10,10 0,1 0,0 20a10,10 0,0 0,0 -20zM11,7h2v2h-2zM11,11h2v6h-2z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_search.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_search.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M10.5,4a6.5,6.5 0,1 0,3.96 11.65L19.8,21l1.2,-1.2l-5.35,-5.34A6.5,6.5 0,0 0,10.5 4zM10.5,6a4.5,4.5 0,1 1,0 9a4.5,4.5 0,0 1,0 -9z"/> +</vector> diff --git a/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_warning.xml b/app/design_system/src/commonMain/composeResources/drawable/harvestcircle_warning.xml @@ -0,0 +1,6 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" android:height="24dp" + android:viewportWidth="24" android:viewportHeight="24"> + <path android:fillColor="#FF000000" + android:pathData="M12,3L1.8,21h20.4zM11,9h2v6h-2zM11,17h2v2h-2z"/> +</vector> diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/HarvestCircleComponentTypes.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/HarvestCircleComponentTypes.kt @@ -0,0 +1,59 @@ +package org.harvestcircle.designsystem.component + +/** Visual emphasis for an action. */ +public enum class HarvestCircleButtonVariant { + Primary, + Secondary, + Ghost, + Destructive, +} + +/** Canonical macOS control size. */ +public enum class HarvestCircleControlSize { + Small, + Medium, + Large, +} + +/** + * Controls whether a component renders the optional macOS-style keyboard focus ring. + * + * HarvestCircle deliberately defaults to [None]. Components remain focusable and keyboard-operable when + * the ring is disabled; this enum controls only the visible ring. Use [WhenFocused] where a product + * surface requires an explicit focus indicator. + */ +public enum class HarvestCircleFocusRing { + None, + WhenFocused, +} + +/** Content emphasis shared by text and icons. */ +public enum class HarvestCircleContentTone { + Primary, + Secondary, + Muted, + Disabled, + Inverse, + Inherit, +} + +/** Semantic text role. */ +public enum class HarvestCircleTextRole { + Display, + PageTitle, + SectionTitle, + SubsectionTitle, + Body, + BodyStrong, + BodySmall, + Label, + LabelSmall, + Code, +} + +/** Canonical icon size. */ +public enum class HarvestCircleIconSize { + Small, + Medium, + Large, +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/focus/HarvestCircleFocusDismissBehavior.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/focus/HarvestCircleFocusDismissBehavior.kt @@ -0,0 +1,13 @@ +package org.harvestcircle.designsystem.focus + +/** + * Controls whether a containing HarvestCircle surface clears Compose focus after a pointer press on + * otherwise-unhandled background space. + */ +public enum class HarvestCircleFocusDismissBehavior { + /** Clear the currently focused component when a blank surface area is pressed. */ + ClearOnBackgroundPress, + + /** Preserve focus until another component explicitly requests or clears it. */ + KeepFocused, +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/icon/HarvestCircleIcons.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/icon/HarvestCircleIcons.kt @@ -0,0 +1,47 @@ +package org.harvestcircle.designsystem.icon + +import org.harvestcircle.designsystem.generated.resources.Res +import org.harvestcircle.designsystem.generated.resources.harvestcircle_add +import org.harvestcircle.designsystem.generated.resources.harvestcircle_check +import org.harvestcircle.designsystem.generated.resources.harvestcircle_chevron_down +import org.harvestcircle.designsystem.generated.resources.harvestcircle_chevron_left +import org.harvestcircle.designsystem.generated.resources.harvestcircle_chevron_right +import org.harvestcircle.designsystem.generated.resources.harvestcircle_close +import org.harvestcircle.designsystem.generated.resources.harvestcircle_delete +import org.harvestcircle.designsystem.generated.resources.harvestcircle_info +import org.harvestcircle.designsystem.generated.resources.harvestcircle_search +import org.harvestcircle.designsystem.generated.resources.harvestcircle_warning +import org.jetbrains.compose.resources.DrawableResource + +/** Stable names for icons owned by the HarvestCircle design system. */ +public object HarvestCircleIcons { + public val Add: DrawableResource + get() = Res.drawable.harvestcircle_add + + public val Check: DrawableResource + get() = Res.drawable.harvestcircle_check + + public val ChevronDown: DrawableResource + get() = Res.drawable.harvestcircle_chevron_down + + public val ChevronLeft: DrawableResource + get() = Res.drawable.harvestcircle_chevron_left + + public val ChevronRight: DrawableResource + get() = Res.drawable.harvestcircle_chevron_right + + public val Close: DrawableResource + get() = Res.drawable.harvestcircle_close + + public val Delete: DrawableResource + get() = Res.drawable.harvestcircle_delete + + public val Info: DrawableResource + get() = Res.drawable.harvestcircle_info + + public val Search: DrawableResource + get() = Res.drawable.harvestcircle_search + + public val Warning: DrawableResource + get() = Res.drawable.harvestcircle_warning +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/focus/HarvestCircleFocusDismissal.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/focus/HarvestCircleFocusDismissal.kt @@ -0,0 +1,36 @@ +package org.harvestcircle.designsystem.internal.focus + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput + +/** + * Clears focus only when a pointer-down reaches this region without being consumed by a child. + * + * The final pointer-event pass runs after descendant buttons, text fields, selection controls, + * menus, and other interactive elements have had the opportunity to consume the down event. An + * unconsumed down therefore represents background space rather than another control. + */ +internal fun Modifier.harvestCircleClearFocusOnBackgroundPress( + focusManager: FocusManager, + enabled: Boolean, + force: Boolean, +): Modifier = + if (!enabled) { + this + } else { + pointerInput(focusManager, force) { + awaitEachGesture { + awaitFirstDown( + requireUnconsumed = true, + pass = PointerEventPass.Final, + ) + focusManager.clearFocus(force = force) + waitForUpOrCancellation(pass = PointerEventPass.Final) + } + } + } diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/interaction/HarvestCircleInteraction.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/interaction/HarvestCircleInteraction.kt @@ -0,0 +1,67 @@ +package org.harvestcircle.designsystem.internal.interaction + +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +/** + * Keeps activation/focus interactions separate from pointer hover interactions. + * + * `clickable`, `selectable`, and `toggleable` own press and focus semantics. A dedicated + * [hoverSource] is attached with [Modifier.hoverable] so pointer enter remains active for the full + * time the pointer is inside the component. This prevents transient enter/exit flashes caused by + * relying on activation modifiers as the visual hover source on desktop and web targets. + */ +@Stable +internal class HarvestCircleInteractionSources internal constructor( + internal val activationSource: MutableInteractionSource, + internal val hoverSource: MutableInteractionSource, +) + +@Composable +internal fun rememberHarvestCircleInteractionSources(vararg keys: Any?): HarvestCircleInteractionSources = + remember(*keys) { + HarvestCircleInteractionSources( + activationSource = MutableInteractionSource(), + hoverSource = MutableInteractionSource(), + ) + } + +@Immutable +internal data class HarvestCircleInteractionSnapshot( + val pressed: Boolean, + val hovered: Boolean, + val focused: Boolean, +) + +@Composable +internal fun HarvestCircleInteractionSources.harvestCircleInteractions(enabled: Boolean): HarvestCircleInteractionSnapshot = + if (!enabled) { + HarvestCircleInteractionSnapshot( + pressed = false, + hovered = false, + focused = false, + ) + } else { + HarvestCircleInteractionSnapshot( + pressed = activationSource.collectIsPressedAsState().value, + hovered = hoverSource.collectIsHoveredAsState().value, + focused = activationSource.collectIsFocusedAsState().value, + ) + } + +internal fun Modifier.harvestCircleHoverable( + sources: HarvestCircleInteractionSources, + enabled: Boolean, +): Modifier = + hoverable( + interactionSource = sources.hoverSource, + enabled = enabled, + ) diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleContent.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleContent.kt @@ -0,0 +1,30 @@ +package org.harvestcircle.designsystem.primitive + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color + +/** Internal content-color propagation used by HarvestCircle primitives and components. */ +internal val LocalHarvestCircleContentColor = compositionLocalOf { Color.Unspecified } + +@Composable +internal fun ProvideHarvestCircleContentColor( + color: Color, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalHarvestCircleContentColor provides color, + content = content, + ) +} + +@Composable +internal fun currentHarvestCircleContentColor(): Color { + val inherited = LocalHarvestCircleContentColor.current + return if (inherited == Color.Unspecified) { + org.harvestcircle.designsystem.theme.HarvestCircleTheme.foundation.colors.content.primary + } else { + inherited + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleFocusDismissRegion.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleFocusDismissRegion.kt @@ -0,0 +1,37 @@ +package org.harvestcircle.designsystem.primitive + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import org.harvestcircle.designsystem.focus.HarvestCircleFocusDismissBehavior +import org.harvestcircle.designsystem.internal.focus.harvestCircleClearFocusOnBackgroundPress + +/** + * A non-visual region that optionally clears focus when its unhandled background is pressed. + * + * Descendant controls retain normal focus transfer because pointer-down events consumed by a child + * are ignored. Use [HarvestCircleFocusDismissBehavior.KeepFocused] for editor canvases and other intentional + * focus-retaining surfaces. Set [force] only when this region must override a descendant that + * deliberately captured focus. + */ +@Composable +public fun HarvestCircleFocusDismissRegion( + modifier: Modifier = Modifier, + behavior: HarvestCircleFocusDismissBehavior = HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress, + force: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + val focusManager = LocalFocusManager.current + + Box( + modifier = + modifier.harvestCircleClearFocusOnBackgroundPress( + focusManager = focusManager, + enabled = behavior == HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress, + force = force, + ), + content = content, + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleIcon.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleIcon.kt @@ -0,0 +1,50 @@ +package org.harvestcircle.designsystem.primitive + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.Dp +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleIconSize +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource + +@Composable +private fun iconColor(tone: HarvestCircleContentTone): Color = + when (tone) { + HarvestCircleContentTone.Primary -> HarvestCircleTheme.foundation.colors.content.primary + HarvestCircleContentTone.Secondary -> HarvestCircleTheme.foundation.colors.content.secondary + HarvestCircleContentTone.Muted -> HarvestCircleTheme.foundation.colors.content.muted + HarvestCircleContentTone.Disabled -> HarvestCircleTheme.foundation.colors.content.disabled + HarvestCircleContentTone.Inverse -> HarvestCircleTheme.foundation.colors.content.inverse + HarvestCircleContentTone.Inherit -> currentHarvestCircleContentColor() + } + +@Composable +private fun iconSize(size: HarvestCircleIconSize): Dp = + when (size) { + HarvestCircleIconSize.Small -> HarvestCircleTheme.shell.dimensions.iconSmall + HarvestCircleIconSize.Medium -> HarvestCircleTheme.shell.dimensions.iconMedium + HarvestCircleIconSize.Large -> HarvestCircleTheme.shell.dimensions.iconLarge + } + +/** Canonical resource-backed icon rendered with Foundation rather than Material Icon. */ +@Composable +public fun HarvestCircleIcon( + resource: DrawableResource, + contentDescription: String?, + modifier: Modifier = Modifier, + size: HarvestCircleIconSize = HarvestCircleIconSize.Medium, + tone: HarvestCircleContentTone = HarvestCircleContentTone.Inherit, +) { + Image( + painter = painterResource(resource), + contentDescription = contentDescription, + modifier = modifier.size(iconSize(size)), + colorFilter = ColorFilter.tint(iconColor(tone)), + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleSurface.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleSurface.kt @@ -0,0 +1,122 @@ +package org.harvestcircle.designsystem.primitive + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.focus.HarvestCircleFocusDismissBehavior +import org.harvestcircle.designsystem.internal.focus.harvestCircleClearFocusOnBackgroundPress +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** Semantic surface roles. */ +public enum class HarvestCircleSurfaceRole { + Canvas, + Base, + Raised, + Sunken, + Selected, + Overlay, +} + +@Composable +internal fun harvestCircleSurfaceColor(role: HarvestCircleSurfaceRole): Color = + when (role) { + HarvestCircleSurfaceRole.Canvas -> HarvestCircleTheme.foundation.colors.surface.canvas + HarvestCircleSurfaceRole.Base -> HarvestCircleTheme.foundation.colors.surface.base + HarvestCircleSurfaceRole.Raised -> HarvestCircleTheme.foundation.colors.surface.raised + HarvestCircleSurfaceRole.Sunken -> HarvestCircleTheme.foundation.colors.surface.sunken + HarvestCircleSurfaceRole.Selected -> HarvestCircleTheme.foundation.colors.surface.selected + HarvestCircleSurfaceRole.Overlay -> HarvestCircleTheme.foundation.colors.surface.overlay + } + +/** Flat semantic surface with optional AppKit-style border and restrained shadow. */ +@Composable +public fun HarvestCircleSurface( + modifier: Modifier = Modifier, + role: HarvestCircleSurfaceRole = HarvestCircleSurfaceRole.Base, + shape: Shape = RectangleShape, + border: BorderStroke? = null, + shadowElevation: Dp = 0.dp, + color: Color? = null, + contentColor: Color? = null, + content: @Composable BoxScope.() -> Unit, +) { + val surface = color ?: harvestCircleSurfaceColor(role) + val resolvedContentColor = contentColor ?: HarvestCircleTheme.foundation.colors.content.primary + val decoratedModifier = + modifier + .then( + if (shadowElevation > 0.dp) { + Modifier.shadow( + elevation = shadowElevation, + shape = shape, + clip = false, + ) + } else { + Modifier + }, + ).clip(shape) + .background(color = surface, shape = shape) + .then( + if (border != null) { + Modifier.border(border = border, shape = shape) + } else { + Modifier + }, + ) + + ProvideHarvestCircleContentColor(resolvedContentColor) { + Box( + modifier = decoratedModifier, + content = content, + ) + } +} + +/** + * Root application surface. + * + * By default, pressing non-interactive canvas space clears the current Compose focus. This matches + * conventional desktop and browser behavior where a focus ring disappears after the user clicks + * away from a control. Set [focusDismissBehavior] to [HarvestCircleFocusDismissBehavior.KeepFocused] for an + * editor canvas or another surface that intentionally retains focus. Set [forceFocusDismissal] only + * when the product must override a component that deliberately captured focus. + */ +@Composable +public fun HarvestCircleAppSurface( + modifier: Modifier = Modifier, + focusDismissBehavior: HarvestCircleFocusDismissBehavior = + HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress, + forceFocusDismissal: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + val focusManager = LocalFocusManager.current + + HarvestCircleSurface( + modifier = + modifier + .fillMaxSize() + .harvestCircleClearFocusOnBackgroundPress( + focusManager = focusManager, + enabled = + focusDismissBehavior == + HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress, + force = forceFocusDismissal, + ), + role = HarvestCircleSurfaceRole.Canvas, + shape = RectangleShape, + content = content, + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleText.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleText.kt @@ -0,0 +1,73 @@ +package org.harvestcircle.designsystem.primitive + +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +@Composable +private fun textColor(tone: HarvestCircleContentTone): Color = + when (tone) { + HarvestCircleContentTone.Primary -> HarvestCircleTheme.foundation.colors.content.primary + HarvestCircleContentTone.Secondary -> HarvestCircleTheme.foundation.colors.content.secondary + HarvestCircleContentTone.Muted -> HarvestCircleTheme.foundation.colors.content.muted + HarvestCircleContentTone.Disabled -> HarvestCircleTheme.foundation.colors.content.disabled + HarvestCircleContentTone.Inverse -> HarvestCircleTheme.foundation.colors.content.inverse + HarvestCircleContentTone.Inherit -> currentHarvestCircleContentColor() + } + +@Composable +private fun textStyle(role: HarvestCircleTextRole): TextStyle = + when (role) { + HarvestCircleTextRole.Display -> HarvestCircleTheme.foundation.typography.display + HarvestCircleTextRole.PageTitle -> HarvestCircleTheme.foundation.typography.pageTitle + HarvestCircleTextRole.SectionTitle -> HarvestCircleTheme.foundation.typography.sectionTitle + HarvestCircleTextRole.SubsectionTitle -> HarvestCircleTheme.foundation.typography.subsectionTitle + HarvestCircleTextRole.Body -> HarvestCircleTheme.foundation.typography.body + HarvestCircleTextRole.BodyStrong -> HarvestCircleTheme.foundation.typography.bodyStrong + HarvestCircleTextRole.BodySmall -> HarvestCircleTheme.foundation.typography.bodySmall + HarvestCircleTextRole.Label -> HarvestCircleTheme.foundation.typography.label + HarvestCircleTextRole.LabelSmall -> HarvestCircleTheme.foundation.typography.labelSmall + HarvestCircleTextRole.Code -> HarvestCircleTheme.foundation.typography.code + } + +/** Canonical HarvestCircle text primitive rendered without Material component anatomy. */ +@Composable +public fun HarvestCircleText( + text: String, + modifier: Modifier = Modifier, + role: HarvestCircleTextRole = HarvestCircleTextRole.Body, + tone: HarvestCircleContentTone = HarvestCircleContentTone.Inherit, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, +) { + val baseStyle = textStyle(role) + val style = + if (textAlign == null) { + baseStyle.copy(color = textColor(tone)) + } else { + baseStyle.copy( + color = textColor(tone), + textAlign = textAlign, + ) + } + + BasicText( + text = text, + modifier = modifier, + style = style, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + ) +} diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -717,16 +717,20 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St "{path}: hard-coded Compose color outside the theme adapter" )); } + let approved_text_primitive = path + == "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleText.kt"; + let approved_input_primitive = path + == "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/input/HarvestCircleTextField.kt"; if is_production_compose(path, &source) && path != "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt" { - if contains_direct_call(&compact, "BasicText(") { + if !approved_text_primitive && contains_direct_call(&compact, "BasicText(") { findings.push(format!( "{path}: BasicText bypasses the shell primitive adapter" )); } - if contains_direct_call(&compact, "BasicTextField(") { + if !approved_input_primitive && contains_direct_call(&compact, "BasicTextField(") { findings.push(format!( "{path}: BasicTextField bypasses the shell primitive adapter" ));