app

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

commit 9fd327ff403d1ffbc686dcbc070079b027a3a507
parent 41786d95eb66d4a283fe57fcf4c77233e0170f02
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 17:36:55 +0000

design: port the approved Studio content anatomy

- own the exact empty-state illustration and spacing grid
- add semantic pane and raised-panel composition primitives
- keep product copy and state outside the design system
- pin the approved empty-state geometry with tests

Diffstat:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellContent.kt | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisuals.kt | 11+++++++++++
Mapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisualsTest.kt | 3+++
3 files changed, 199 insertions(+), 0 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellContent.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellContent.kt @@ -0,0 +1,185 @@ +package org.harvestcircle.designsystem.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +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.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +@Composable +public fun HarvestCircleShellPage( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(HarvestCircleShellPalette.pane) + .padding( + horizontal = HarvestCircleShellMetrics.contentPageHorizontalInset, + vertical = HarvestCircleShellMetrics.contentPageVerticalInset, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) +} + +@Composable +public fun HarvestCircleShellPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val colors = HarvestCircleShellPalette + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.contentPanelRadius) + Column( + modifier = + modifier + .fillMaxWidth() + .clip(shape) + .background(colors.raised) + .border(BorderStroke(1.dp, colors.border), shape) + .padding(HarvestCircleShellMetrics.contentPanelInset), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) +} + +@Composable +public fun HarvestCircleShellEmptyState( + title: String, + body: List<String>, + modifier: Modifier = Modifier, + context: String? = null, + showIllustration: Boolean = true, + actions: @Composable RowScope.() -> Unit = {}, +) { + Box( + modifier = modifier.fillMaxSize().background(HarvestCircleShellPalette.pane), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .width(HarvestCircleShellMetrics.emptyStateWidth) + .offset(y = HarvestCircleShellMetrics.emptyStateVerticalOffset), + horizontalAlignment = Alignment.Start, + ) { + if (showIllustration) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + HarvestCircleStackedDocumentsIllustration() + } + Spacer(Modifier.height(HarvestCircleShellMetrics.emptyStateIllustrationToTitleGap)) + } + context?.let { + HarvestCircleShellText( + it, + role = HarvestCircleShellTextRole.Small, + color = HarvestCircleShellPalette.contentSecondary, + maxLines = 1, + ) + Spacer(Modifier.height(HarvestCircleShellMetrics.emptyStateTitleToBodyGap)) + } + HarvestCircleShellText( + title, + Modifier.semantics { heading() }, + HarvestCircleShellTextRole.SectionTitle, + ) + body.forEachIndexed { index, paragraph -> + Spacer( + Modifier.height( + if (index == 0) { + HarvestCircleShellMetrics.emptyStateTitleToBodyGap + } else { + HarvestCircleShellMetrics.emptyStateParagraphGap + }, + ), + ) + HarvestCircleShellText(paragraph, color = HarvestCircleShellPalette.contentSecondary) + } + Row( + modifier = Modifier.padding(top = HarvestCircleShellMetrics.emptyStateBodyToActionsGap), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleShellMetrics.emptyStateActionGap), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + } + } +} + +@Composable +private fun HarvestCircleStackedDocumentsIllustration() { + val lineColor = HarvestCircleShellPalette.contentMuted + Canvas( + modifier = + Modifier.size( + HarvestCircleShellMetrics.emptyStateIllustrationWidth, + HarvestCircleShellMetrics.emptyStateIllustrationHeight, + ), + ) { + val strokeWidth = HarvestCircleShellMetrics.emptyStateIllustrationStrokeWidth.toPx() + val stroke = Stroke(strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round) + val scaleX = size.width / 56f + val scaleY = size.height / 64f + + fun x(value: Float): Float = value * scaleX + + fun y(value: Float): Float = value * scaleY + + fun sheet( + top: Float, + inset: Float, + ): Path = + Path().apply { + moveTo(x(inset), y(top + 8f)) + lineTo(x(44f), y(top)) + lineTo(x(56f - inset), y(top + 8f)) + lineTo(x(56f - inset), y(top + 22f)) + lineTo(x(28f), y(top + 31f)) + lineTo(x(inset), y(top + 22f)) + close() + } + drawPath(sheet(27f, 10f), lineColor, style = stroke) + drawPath(sheet(22f, 9f), lineColor, style = stroke) + drawPath(sheet(17f, 8f), lineColor, style = stroke) + val topDocument = + Path().apply { + moveTo(x(15f), y(13f)) + lineTo(x(38f), y(5f)) + lineTo(x(49f), y(9f)) + lineTo(x(40f), y(27f)) + lineTo(x(17f), y(33f)) + lineTo(x(9f), y(29f)) + close() + } + drawPath(topDocument, lineColor, style = stroke) + drawLine(lineColor, Offset(x(17f), y(33f)), Offset(x(40f), y(27f)), strokeWidth, StrokeCap.Round) + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisuals.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisuals.kt @@ -144,6 +144,17 @@ public object HarvestCircleShellMetrics { public val contentPageHorizontalInset: Dp = 24.dp public val contentPageVerticalInset: Dp = 24.dp public val emptyStateWidth: Dp = 344.dp + public val emptyStateIllustrationWidth: Dp = 56.dp + public val emptyStateIllustrationHeight: Dp = 64.dp + public val emptyStateIllustrationStrokeWidth: Dp = 1.1.dp + public val emptyStateIllustrationToTitleGap: Dp = 24.dp + public val emptyStateTitleToBodyGap: Dp = 12.dp + public val emptyStateParagraphGap: Dp = 12.dp + public val emptyStateBodyToActionsGap: Dp = 20.dp + public val emptyStateActionGap: Dp = 12.dp + public val emptyStateVerticalOffset: Dp = 8.dp + public val contentPanelRadius: Dp = 12.dp + public val contentPanelInset: Dp = 16.dp public val compactBreakpoint: Dp = 976.dp public val expandedBreakpoint: Dp = 1272.dp } diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisualsTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisualsTest.kt @@ -25,6 +25,9 @@ class HarvestCircleShellVisualsTest { assertEquals(40.dp, HarvestCircleShellMetrics.localHeaderHeight) assertEquals(232.dp, HarvestCircleShellMetrics.sidebarWidth) assertEquals(72.dp, HarvestCircleShellMetrics.collapsedSidebarWidth) + assertEquals(344.dp, HarvestCircleShellMetrics.emptyStateWidth) + assertEquals(56.dp, HarvestCircleShellMetrics.emptyStateIllustrationWidth) + assertEquals(64.dp, HarvestCircleShellMetrics.emptyStateIllustrationHeight) assertEquals(976.dp, HarvestCircleShellMetrics.compactBreakpoint) assertEquals(1272.dp, HarvestCircleShellMetrics.expandedBreakpoint) }