app

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

commit c74db430f82ee4f85dafd5a19a6839aa907b52d8
parent 117488141a55125669391a555b93eebc647b01fd
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:29:40 +0000

design: port accessible HarvestCircle controls

- own buttons icon actions text fields and native progress chrome
- centralize component-specific submit colors in theme ownership
- default keyboard focus rings to visible for every public control
- enforce the visible-focus default through repository policy

Diffstat:
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/HarvestCircleComponentTypes.kt | 7+++----
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleIconButton.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/input/HarvestCircleTextField.kt | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/chrome/HarvestCircleMacChrome.kt | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/progress/HarvestCircleMacProgress.kt | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/HarvestCircleColors.kt | 10++++++++++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/internal/chrome/HarvestCircleMacChromeTest.kt | 38++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/lib.rs | 7+++++++
9 files changed, 1055 insertions(+), 4 deletions(-)

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 @@ -16,11 +16,10 @@ public enum class HarvestCircleControlSize { } /** - * Controls whether a component renders the optional macOS-style keyboard focus ring. + * Controls whether a component renders the 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. + * HarvestCircle controls default to [WhenFocused] so keyboard focus is always visible. [None] is + * reserved for a parent control that renders an equivalent focus indicator around the same target. */ public enum class HarvestCircleFocusRing { None, diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt @@ -0,0 +1,262 @@ +package org.harvestcircle.designsystem.component.action + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant +import org.harvestcircle.designsystem.component.HarvestCircleControlSize +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.generated.resources.Res +import org.harvestcircle.designsystem.generated.resources.harvestcircle_loading +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacBezel +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame +import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush +import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacFilledBorder +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.internal.progress.HarvestCircleMacSpinner +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.harvestcircle.designsystem.theme.color.HarvestCircleActionStateColors +import org.harvestcircle.designsystem.theme.color.HarvestCircleMacPrimarySubmitColors +import org.jetbrains.compose.resources.stringResource + +/** + * Pixel-sampled colors from the supplied canonical macOS default-action reference. + * + * These are component-level implementation tokens, not general product colors. The surrounding + * blue contour is the native active-state halo shown in the reference; it appears only while the + * button is pressed and is intentionally distinct from the optional keyboard [HarvestCircleFocusRing]. + */ +private object HarvestCircleMacPrimarySubmitMetrics { + val RingWidth = 3.dp +} + +private data class HarvestCircleButtonMetrics( + val visualHeight: Dp, + val minimumVisualWidth: Dp, + val horizontalPadding: Dp, + val indicatorSize: Dp, +) + +@Composable +private fun buttonMetrics(size: HarvestCircleControlSize): HarvestCircleButtonMetrics = + when (size) { + HarvestCircleControlSize.Small -> + HarvestCircleButtonMetrics( + visualHeight = HarvestCircleTheme.shell.dimensions.controlSmall, + minimumVisualWidth = 48.dp, + horizontalPadding = HarvestCircleTheme.foundation.spacing.md, + indicatorSize = HarvestCircleTheme.shell.dimensions.iconSmall, + ) + + HarvestCircleControlSize.Medium -> + HarvestCircleButtonMetrics( + visualHeight = HarvestCircleTheme.shell.dimensions.controlMedium, + minimumVisualWidth = 68.dp, + horizontalPadding = HarvestCircleTheme.foundation.spacing.lg, + indicatorSize = HarvestCircleTheme.shell.dimensions.iconMedium, + ) + + HarvestCircleControlSize.Large -> + HarvestCircleButtonMetrics( + visualHeight = HarvestCircleTheme.shell.dimensions.controlLarge, + minimumVisualWidth = 86.dp, + horizontalPadding = HarvestCircleTheme.foundation.spacing.xl, + indicatorSize = HarvestCircleTheme.shell.dimensions.iconMedium, + ) + } + +@Composable +private fun variantTokens(variant: HarvestCircleButtonVariant): HarvestCircleActionStateColors = + when (variant) { + HarvestCircleButtonVariant.Primary -> HarvestCircleTheme.foundation.colors.action.primary + HarvestCircleButtonVariant.Secondary -> HarvestCircleTheme.foundation.colors.action.secondary + HarvestCircleButtonVariant.Ghost -> HarvestCircleTheme.foundation.colors.action.ghost + HarvestCircleButtonVariant.Destructive -> HarvestCircleTheme.foundation.colors.action.destructive + } + +/** + * Canonical HarvestCircle push button using pre-Liquid-Glass AppKit geometry and bezel treatment. + * + * Primary actions reproduce the supplied macOS default-submit control: a flat system-blue interior, + * a one-pixel bright inner edge, and a three-dp muted-blue halo while pressed. The active halo is + * interaction state, not keyboard focus state. [focusRing] remains independent and defaults to a + * visible indicator when keyboard focus enters the control. Its transparent resting footprint + * prevents layout movement on press. + * + * There is no Material ripple, tonal elevation, pill geometry, or Material button anatomy. Hover is + * driven by a dedicated persistent hover source and changes immediately, avoiding transient edge + * flashes. + */ +@Composable +public fun HarvestCircleButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + variant: HarvestCircleButtonVariant = HarvestCircleButtonVariant.Primary, + size: HarvestCircleControlSize = HarvestCircleControlSize.Medium, + enabled: Boolean = true, + loading: Boolean = false, + leadingIcon: (@Composable () -> Unit)? = null, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, + content: @Composable RowScope.() -> Unit, +) { + val effectiveEnabled = enabled && !loading + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(effectiveEnabled) + val tokens = variantTokens(variant) + val metrics = buttonMetrics(size) + val loadingDescription = stringResource(Res.string.harvestcircle_loading) + val nativePrimary = variant == HarvestCircleButtonVariant.Primary && effectiveEnabled + val activeRingColor = + if (nativePrimary && interactions.pressed) { + HarvestCircleMacPrimarySubmitColors.ActiveRing + } else { + Color.Transparent + } + + val container = + when { + nativePrimary && interactions.pressed -> HarvestCircleMacPrimarySubmitColors.Pressed + nativePrimary && interactions.hovered -> HarvestCircleMacPrimarySubmitColors.Hover + nativePrimary -> HarvestCircleMacPrimarySubmitColors.Rest + !effectiveEnabled -> tokens.disabled + interactions.pressed -> tokens.pressed + interactions.hovered -> tokens.hover + else -> tokens.rest + } + val contentColor = + when { + nativePrimary -> HarvestCircleMacPrimarySubmitColors.Content + effectiveEnabled -> tokens.content + else -> tokens.disabledContent + } + val isFilled = variant == HarvestCircleButtonVariant.Primary || variant == HarvestCircleButtonVariant.Destructive + val borderColor = + when { + nativePrimary -> HarvestCircleMacPrimarySubmitColors.InnerEdge + !effectiveEnabled -> HarvestCircleTheme.foundation.colors.border.subtle + variant == HarvestCircleButtonVariant.Ghost && !interactions.hovered && !interactions.pressed -> + Color.Transparent + isFilled -> harvestCircleMacFilledBorder(container, HarvestCircleTheme.foundation.colors.isDark) + else -> tokens.border + } + val shadow = + if ( + effectiveEnabled && + !interactions.pressed && + variant != HarvestCircleButtonVariant.Ghost && + !nativePrimary + ) { + HarvestCircleTheme.component.elevations.raised + } else { + 0.dp + } + val controlBrush: Brush = + if (nativePrimary) { + SolidColor(container) + } else { + harvestCircleMacControlBrush(container, emphasized = isFilled) + } + + Box( + modifier = + modifier + .defaultMinSize(minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive) + .harvestCircleHoverable(sources = sources, enabled = effectiveEnabled) + .clickable( + interactionSource = sources.activationSource, + indication = null, + enabled = effectiveEnabled, + role = Role.Button, + onClick = onClick, + ).semantics { + if (loading) stateDescription = loadingDescription + }, + contentAlignment = Alignment.Center, + ) { + HarvestCircleMacFocusFrame( + focused = interactions.focused, + focusRing = focusRing, + shape = HarvestCircleTheme.foundation.shapes.control, + ringShape = HarvestCircleTheme.foundation.shapes.controlFocusRing, + ) { + val bezel: @Composable () -> Unit = { + HarvestCircleMacBezel( + modifier = + Modifier.defaultMinSize( + minWidth = metrics.minimumVisualWidth, + minHeight = metrics.visualHeight, + ), + brush = controlBrush, + border = BorderStroke(1.dp, borderColor), + shape = HarvestCircleTheme.foundation.shapes.control, + shadowElevation = shadow, + ) { + ProvideHarvestCircleContentColor(contentColor) { + Row( + modifier = Modifier.padding(horizontal = metrics.horizontalPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + when { + loading -> { + HarvestCircleMacSpinner( + modifier = Modifier.size(metrics.indicatorSize), + size = metrics.indicatorSize, + color = contentColor, + ) + Spacer(Modifier.width(HarvestCircleTheme.foundation.spacing.sm)) + } + + leadingIcon != null -> { + leadingIcon() + Spacer(Modifier.width(HarvestCircleTheme.foundation.spacing.sm)) + } + } + + content() + } + } + } + } + + if (nativePrimary) { + Box( + modifier = + Modifier + .background( + color = activeRingColor, + shape = HarvestCircleTheme.foundation.shapes.controlFocusRing, + ).padding(HarvestCircleMacPrimarySubmitMetrics.RingWidth), + contentAlignment = Alignment.Center, + ) { + bezel() + } + } else { + bezel() + } + } + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleIconButton.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleIconButton.kt @@ -0,0 +1,115 @@ +package org.harvestcircle.designsystem.component.action + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleControlSize +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleIconSize +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacBezel +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame +import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.jetbrains.compose.resources.DrawableResource + +@Composable +private fun iconButtonVisualSize(size: HarvestCircleControlSize): Dp = + when (size) { + HarvestCircleControlSize.Small -> HarvestCircleTheme.shell.dimensions.controlSmall + HarvestCircleControlSize.Medium -> HarvestCircleTheme.shell.dimensions.controlMedium + HarvestCircleControlSize.Large -> HarvestCircleTheme.shell.dimensions.controlLarge + } + +/** Borderless AppKit toolbar action with stable hover, press, focus, and required accessible label. */ +@Composable +public fun HarvestCircleIconButton( + onClick: () -> Unit, + icon: DrawableResource, + label: String, + modifier: Modifier = Modifier, + size: HarvestCircleControlSize = HarvestCircleControlSize.Medium, + enabled: Boolean = true, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val ghost = HarvestCircleTheme.foundation.colors.action.ghost + val visualSize = iconButtonVisualSize(size) + val container = + when { + !enabled -> Color.Transparent + interactions.pressed -> ghost.pressed + interactions.hovered -> ghost.hover + else -> Color.Transparent + } + val contentColor = if (enabled) ghost.content else ghost.disabledContent + val border = + if (interactions.hovered || interactions.pressed) { + HarvestCircleTheme.foundation.colors.border.subtle + } else { + Color.Transparent + } + + Box( + modifier = + modifier + .defaultMinSize( + minWidth = HarvestCircleTheme.shell.dimensions.minimumInteractive, + minHeight = HarvestCircleTheme.shell.dimensions.minimumInteractive, + ).semantics { contentDescription = label } + .harvestCircleHoverable(sources = sources, enabled = enabled) + .clickable( + interactionSource = sources.activationSource, + indication = null, + enabled = enabled, + role = Role.Button, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + HarvestCircleMacFocusFrame( + focused = interactions.focused, + focusRing = focusRing, + shape = HarvestCircleTheme.foundation.shapes.control, + ringShape = HarvestCircleTheme.foundation.shapes.controlFocusRing, + ) { + HarvestCircleMacBezel( + modifier = Modifier.size(visualSize), + brush = harvestCircleMacControlBrush(container), + border = BorderStroke(1.dp, border), + shape = HarvestCircleTheme.foundation.shapes.control, + ) { + ProvideHarvestCircleContentColor(contentColor) { + HarvestCircleIcon( + resource = icon, + contentDescription = null, + size = + when (size) { + HarvestCircleControlSize.Small -> HarvestCircleIconSize.Small + HarvestCircleControlSize.Medium -> HarvestCircleIconSize.Medium + HarvestCircleControlSize.Large -> HarvestCircleIconSize.Large + }, + tone = if (enabled) HarvestCircleContentTone.Inherit else HarvestCircleContentTone.Disabled, + ) + } + } + } + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/input/HarvestCircleTextField.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/input/HarvestCircleTextField.kt @@ -0,0 +1,342 @@ +package org.harvestcircle.designsystem.component.input + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleControlSize +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleIconSize +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleIconButton +import org.harvestcircle.designsystem.icon.HarvestCircleIcons +import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacFocusFrame +import org.harvestcircle.designsystem.internal.chrome.harvestCircleMacControlBrush +import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverable +import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions +import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleInputMode +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.jetbrains.compose.resources.DrawableResource + +private val LocalHarvestCircleTextFieldShape = compositionLocalOf<Shape?> { null } +private val LocalHarvestCircleTextFieldFocusRingShape = compositionLocalOf<Shape?> { null } + +/** + * Canonical AppKit-style text field. + * + * Labels sit above the bezel and placeholders remain inside it. Text editing, IME composition, + * selection, cursor, keyboard actions, and read-only behavior come from Foundation's + * [BasicTextField] rather than Material text-field anatomy. The optional visible focus ring is + * disabled by default. + */ +@Composable +public fun HarvestCircleTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + placeholder: String? = null, + supportingText: String? = null, + errorMessage: String? = null, + leadingIcon: DrawableResource? = null, + trailingIcon: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + accessibilityLabel: String? = null, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + require(minLines >= 1) { "minLines must be at least 1" } + require(maxLines >= minLines) { "maxLines must be greater than or equal to minLines" } + + val sources = rememberHarvestCircleInteractionSources() + val interactions = sources.harvestCircleInteractions(enabled) + val colors = HarvestCircleTheme.foundation.colors + val fieldShape = LocalHarvestCircleTextFieldShape.current ?: HarvestCircleTheme.foundation.shapes.control + val fieldFocusRingShape = + LocalHarvestCircleTextFieldFocusRingShape.current ?: HarvestCircleTheme.foundation.shapes.controlFocusRing + val resolvedAccessibilityLabel = accessibilityLabel ?: label ?: placeholder + val isError = errorMessage != null + val visualHeight = + if (singleLine) { + HarvestCircleTheme.shell.dimensions.controlMedium + } else { + HarvestCircleTheme.shell.dimensions.controlLarge + } + val background = + when { + !enabled -> colors.surface.sunken + readOnly -> colors.surface.base + else -> colors.surface.raised + } + val borderColor = + when { + isError -> colors.feedback.error.strong + interactions.hovered && enabled -> colors.border.strong + !enabled -> colors.border.subtle + else -> colors.border.default + } + val textColor = if (enabled) colors.content.primary else colors.content.disabled + val supportingColor = + when { + isError -> colors.feedback.error.strong + enabled -> colors.content.muted + else -> colors.content.disabled + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.Start, + ) { + if (label != null) { + ProvideHarvestCircleContentColor( + when { + isError -> colors.feedback.error.strong + enabled -> colors.content.secondary + else -> colors.content.disabled + }, + ) { + HarvestCircleText( + text = label, + modifier = Modifier.padding(bottom = HarvestCircleTheme.foundation.spacing.xs), + role = HarvestCircleTextRole.LabelSmall, + tone = HarvestCircleContentTone.Inherit, + ) + } + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = HarvestCircleTheme.shell.dimensions.minimumInteractive) + .harvestCircleHoverable(sources = sources, enabled = enabled) + .semantics { + if (resolvedAccessibilityLabel != null) { + contentDescription = resolvedAccessibilityLabel + } + if (errorMessage != null) { + stateDescription = errorMessage + } + }, + enabled = enabled, + readOnly = readOnly, + textStyle = + HarvestCircleTheme.foundation.typography.body + .merge(TextStyle(color = textColor)), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + interactionSource = sources.activationSource, + cursorBrush = SolidColor(if (isError) colors.feedback.error.strong else colors.focus.ring), + decorationBox = { innerTextField -> + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = HarvestCircleTheme.shell.dimensions.minimumInteractive), + contentAlignment = Alignment.Center, + ) { + HarvestCircleMacFocusFrame( + focused = interactions.focused && enabled, + focusRing = focusRing, + modifier = Modifier.fillMaxWidth(), + shape = fieldShape, + ringShape = fieldFocusRingShape, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = visualHeight) + .clip(fieldShape) + .background( + brush = harvestCircleMacControlBrush(background), + shape = fieldShape, + ).border( + width = 1.dp, + color = borderColor, + shape = fieldShape, + ), + ) { + ProvideHarvestCircleContentColor(textColor) { + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = visualHeight) + .padding(horizontal = HarvestCircleTheme.foundation.spacing.md), + verticalAlignment = + if (singleLine) Alignment.CenterVertically else Alignment.Top, + ) { + if (leadingIcon != null) { + HarvestCircleIcon( + resource = leadingIcon, + contentDescription = null, + size = HarvestCircleIconSize.Medium, + tone = + if (enabled) { + HarvestCircleContentTone.Secondary + } else { + HarvestCircleContentTone.Disabled + }, + ) + Spacer(Modifier.width(HarvestCircleTheme.foundation.spacing.sm)) + } + + Box( + modifier = + Modifier + .weight(1f) + .padding( + vertical = + if (singleLine) { + 0.dp + } else { + HarvestCircleTheme.foundation.spacing.sm + }, + ), + contentAlignment = + if (singleLine) Alignment.CenterStart else Alignment.TopStart, + ) { + if (value.isEmpty() && placeholder != null) { + HarvestCircleText( + text = placeholder, + role = HarvestCircleTextRole.Body, + tone = + if (enabled) { + HarvestCircleContentTone.Muted + } else { + HarvestCircleContentTone.Disabled + }, + maxLines = if (singleLine) 1 else maxLines, + ) + } + innerTextField() + } + + if (trailingIcon != null) { + Spacer(Modifier.width(HarvestCircleTheme.foundation.spacing.sm)) + ProvideHarvestCircleContentColor( + if (enabled) { + colors.content.secondary + } else { + colors.content.disabled + }, + ) { + trailingIcon() + } + } + } + } + } + } + } + }, + ) + + val supporting = errorMessage ?: supportingText + if (supporting != null) { + ProvideHarvestCircleContentColor(supportingColor) { + HarvestCircleText( + text = supporting, + modifier = Modifier.padding(top = HarvestCircleTheme.foundation.spacing.xs), + role = HarvestCircleTextRole.BodySmall, + tone = HarvestCircleContentTone.Inherit, + maxLines = 3, + ) + } + } + } +} + +/** Search-specific field with canonical magnifier and clear controls. */ +@Composable +public fun HarvestCircleSearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + clearLabel: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + CompositionLocalProvider( + LocalHarvestCircleTextFieldShape provides HarvestCircleTheme.foundation.shapes.pill, + LocalHarvestCircleTextFieldFocusRingShape provides HarvestCircleTheme.foundation.shapes.pillFocusRing, + ) { + HarvestCircleTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + placeholder = placeholder, + leadingIcon = HarvestCircleIcons.Search, + trailingIcon = + if (value.isNotEmpty()) { + { + HarvestCircleIconButton( + onClick = { onValueChange("") }, + icon = HarvestCircleIcons.Close, + label = clearLabel, + modifier = + Modifier.size( + if (HarvestCircleTheme.component.inputMode == HarvestCircleInputMode.Touch) { + HarvestCircleTheme.shell.dimensions.minimumInteractive + } else { + HarvestCircleTheme.shell.dimensions.controlSmall + }, + ), + size = HarvestCircleControlSize.Small, + enabled = enabled, + focusRing = focusRing, + ) + } + } else { + null + }, + enabled = enabled, + singleLine = true, + accessibilityLabel = placeholder, + focusRing = focusRing, + ) + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/chrome/HarvestCircleMacChrome.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/chrome/HarvestCircleMacChrome.kt @@ -0,0 +1,157 @@ +package org.harvestcircle.designsystem.internal.chrome + +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** + * Produces the restrained vertical control gradient used by AppKit before Liquid Glass. + * + * The gradient is deliberately subtle: it should read as a native bezel, not as glossy skeuomorphism. + */ +@Composable +internal fun harvestCircleMacControlBrush( + base: Color, + emphasized: Boolean = false, +): Brush { + if (base.alpha == 0f) return SolidColor(Color.Transparent) + + val dark = HarvestCircleTheme.foundation.colors.isDark + val top = + when { + dark && emphasized -> lerp(base, Color.White, 0.12f) + dark -> lerp(base, Color.White, 0.07f) + emphasized -> lerp(base, Color.White, 0.14f) + else -> lerp(base, Color.White, 0.42f) + } + val bottom = + when { + dark && emphasized -> lerp(base, Color.Black, 0.06f) + dark -> lerp(base, Color.Black, 0.04f) + emphasized -> lerp(base, Color.Black, 0.08f) + else -> lerp(base, Color.Black, 0.035f) + } + + return Brush.verticalGradient( + colors = listOf(top, base, bottom), + ) +} + +/** Border color for a filled control, derived without exposing physical palette tokens. */ +internal fun harvestCircleMacFilledBorder( + container: Color, + dark: Boolean, +): Color = + if (dark) { + lerp(container, Color.White, 0.18f) + } else { + lerp(container, Color.Black, 0.20f) + } + +/** + * A softer filled-control contour for primary actions that already have a strong blue fill. + * + * Standard-contrast themes blend toward white so the bezel remains visible without looking like a + * second focus ring. High-contrast themes intentionally keep the stronger canonical boundary. + */ +internal fun harvestCircleMacSoftFilledBorder( + container: Color, + dark: Boolean, + highContrast: Boolean, +): Color = + if (highContrast) { + harvestCircleMacFilledBorder(container = container, dark = dark) + } else { + lerp(container, Color.White, if (dark) 0.24f else 0.18f) + } + +/** + * Optionally reserves stable space for and draws a macOS-style keyboard focus ring around [content]. + * + * Public controls default [focusRing] to [HarvestCircleFocusRing.WhenFocused]. The layout reserves + * the complete ring width plus the clear gap regardless of focus state, so focus changes never shift + * content. [ringShape] is the mathematically expanded outer contour; it must not reuse the inner + * [shape] on a larger rectangle or the two corner curves will not remain parallel. + */ +@Composable +internal fun HarvestCircleMacFocusFrame( + focused: Boolean, + focusRing: HarvestCircleFocusRing, + modifier: Modifier = Modifier, + shape: Shape = HarvestCircleTheme.foundation.shapes.control, + ringShape: Shape = shape, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit, +) { + if (focusRing == HarvestCircleFocusRing.None) { + Box( + modifier = modifier, + contentAlignment = contentAlignment, + content = content, + ) + return + } + + val frameInset = HarvestCircleTheme.shell.dimensions.focusRingWidth + HarvestCircleTheme.shell.dimensions.focusRingGap + + Box( + modifier = + modifier + .border( + width = HarvestCircleTheme.shell.dimensions.focusRingWidth, + color = if (focused) HarvestCircleTheme.foundation.colors.focus.ring else Color.Transparent, + shape = ringShape, + ).padding(frameInset), + contentAlignment = contentAlignment, + content = content, + ) +} + +/** Draws a flat AppKit-style bezel with optional restrained shadow. */ +@Composable +internal fun HarvestCircleMacBezel( + modifier: Modifier = Modifier, + brush: Brush, + border: BorderStroke, + shape: Shape = HarvestCircleTheme.foundation.shapes.control, + shadowElevation: Dp = 0.dp, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = + modifier + .then( + if (shadowElevation > 0.dp) { + Modifier.shadow( + elevation = shadowElevation, + shape = shape, + clip = false, + ) + } else { + Modifier + }, + ).clip(shape) + .background(brush = brush, shape = shape) + .border(border = border, shape = shape), + contentAlignment = contentAlignment, + content = content, + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/progress/HarvestCircleMacProgress.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/internal/progress/HarvestCircleMacProgress.kt @@ -0,0 +1,121 @@ +package org.harvestcircle.designsystem.internal.progress + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import kotlin.math.floor +import kotlin.math.max + +@Composable +internal fun HarvestCircleMacSpinner( + modifier: Modifier = Modifier, + size: Dp = HarvestCircleTheme.shell.dimensions.iconMedium, + color: Color = HarvestCircleTheme.foundation.colors.content.secondary, +) { + val duration = if (HarvestCircleTheme.component.motion.standardMillis == 0) 0 else 900 + val phase = + if (duration == 0) { + 0f + } else { + val transition = rememberInfiniteTransition(label = "HarvestCircleMacSpinner") + transition + .animateFloat( + initialValue = 0f, + targetValue = 12f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = duration, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "HarvestCircleMacSpinnerPhase", + ).value + } + + Canvas(modifier = modifier.size(size)) { + val radius = this.size.minDimension / 2f + val center = Offset(this.size.width / 2f, this.size.height / 2f) + val inner = radius * 0.43f + val outer = radius * 0.88f + val stroke = max(1f, radius * 0.17f) + val head = floor(phase).toInt().mod(12) + + repeat(12) { index -> + val distance = (index - head + 12).mod(12) + val alpha = 0.16f + ((11 - distance) / 11f) * 0.84f + rotate( + degrees = index * 30f, + pivot = center, + ) { + drawLine( + color = color.copy(alpha = color.alpha * alpha), + start = Offset(center.x, center.y - inner), + end = Offset(center.x, center.y - outer), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } + } + } +} + +@Composable +internal fun HarvestCircleMacIndeterminateBar( + modifier: Modifier = Modifier, + color: Color = HarvestCircleTheme.foundation.colors.action.primary.rest, +) { + val duration = if (HarvestCircleTheme.component.motion.standardMillis == 0) 0 else 1100 + val phase = + if (duration == 0) { + 0.45f + } else { + val transition = rememberInfiniteTransition(label = "HarvestCircleMacProgressBar") + transition + .animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = duration, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "HarvestCircleMacProgressBarPhase", + ).value + } + + val track = HarvestCircleTheme.foundation.colors.surface.sunken + Canvas(modifier = modifier.height(4.dp)) { + val radius = size.height / 2f + drawRoundRect( + color = track, + size = size, + cornerRadius = CornerRadius(radius, radius), + ) + + val segmentWidth = size.width * 0.32f + val travel = size.width + segmentWidth + val x = phase * travel - segmentWidth + drawRoundRect( + color = color, + topLeft = Offset(x, 0f), + size = Size(segmentWidth, size.height), + cornerRadius = CornerRadius(radius, radius), + ) + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/HarvestCircleColors.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/HarvestCircleColors.kt @@ -3,6 +3,16 @@ package org.harvestcircle.designsystem.theme.color import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +/** Canonical pre-Liquid-Glass macOS default-action colors. */ +internal object HarvestCircleMacPrimarySubmitColors { + val Rest = Color(0xFF3478F6) + val Hover = Color(0xFF3B80F7) + val Pressed = Color(0xFF2B69DA) + val ActiveRing = Color(0xFF3A658D) + val InnerEdge = Color(0xFF4290F8) + val Content = Color.White +} + /** * Background and container colors. Names express hierarchy rather than a physical palette. */ diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/internal/chrome/HarvestCircleMacChromeTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/internal/chrome/HarvestCircleMacChromeTest.kt @@ -0,0 +1,38 @@ +package org.harvestcircle.designsystem.internal.chrome + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HarvestCircleMacChromeTest { + @Test + fun softFilledBorderMovesPrimaryBlueTowardWhite() { + val container = Color(0xFF0066CC) + val border = + harvestCircleMacSoftFilledBorder( + container = container, + dark = false, + highContrast = false, + ) + + assertTrue(border.red > container.red) + assertTrue(border.green > container.green) + assertTrue(border.blue > container.blue) + } + + @Test + fun highContrastKeepsCanonicalStrongBoundary() { + val container = Color(0xFF0066CC) + + assertEquals( + expected = harvestCircleMacFilledBorder(container = container, dark = false), + actual = + harvestCircleMacSoftFilledBorder( + container = container, + dark = false, + highContrast = true, + ), + ) + } +} diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -706,6 +706,13 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St } } if is_production_compose(path, &source) + && source.contains("focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.None") + { + findings.push(format!( + "{path}: interactive control defaults to a hidden keyboard focus ring" + )); + } + if is_production_compose(path, &source) && path != "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt" && !path.starts_with(