From b3bf51a1df208c85e51b2292f22810b6939c122f Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 17 Sep 2026 15:13:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=B8=BB=E9=A2=98=20token=E2=80=94?= =?UTF-8?q?=E2=80=94color=20=E6=94=B6=20M3=20token=20=E5=90=8D=EF=BC=8CTex?= =?UTF-8?q?t=20=E5=8A=A0=20style=EF=BC=8CKotlin=20=E7=BB=84=E5=90=88?= =?UTF-8?q?=E6=9C=9F=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主题色与文字样式只以 token 名过桥,Kotlin 从 MaterialTheme 解析;深浅切换、 换主题色时页面不发 patch。color 类型同时收 #hex 与 ColorScheme 字段名, 生成器出联合类型(`#${string}` | ColorToken);Text 的 style 为 Typography 十五个名字,fontSize / fontWeight 覆盖其字段(fontWeight 不再默认 normal)。 词表在 cli schema/tokens.ts,compose Theme.kt 逐一解析,测试保证一致。 NodeScope.modifier 改为 @Composable 以解析 background 的 token。 sample 跟随系统深浅色并加 Surface,todos 页改用 token;Pixel_9 模拟器 浅 / 深两套已核对。规则见 components.md §6。 Co-Authored-By: Claude Opus 5 (1M context) --- compose/api/.compose.klib.api.swp | Bin 0 -> 16384 bytes compose/api/compose.klib.api | 55 +++++++++- .../kotlin/wang/harlon/tinyui/PageHost.kt | 4 +- .../wang/harlon/tinyui/components/Builtins.kt | 8 +- .../components/generated/BuiltinSchemas.kt | 7 +- .../harlon/tinyui/schema/ComponentSchema.kt | 10 +- .../wang/harlon/tinyui/schema/NodeScope.kt | 6 ++ .../kotlin/wang/harlon/tinyui/schema/Theme.kt | 96 ++++++++++++++++++ .../wang/harlon/tinyui/node/NodeTreeTest.kt | 3 +- .../wang/harlon/tinyui/schema/ThemeTest.kt | 32 ++++++ docs/components.md | 13 ++- packages/cli/src/schema/dsl.ts | 1 + packages/cli/src/schema/generate.ts | 19 +++- packages/cli/src/schema/index.ts | 1 + packages/cli/src/schema/tokens.ts | 23 +++++ packages/cli/test/schema.test.ts | 29 ++++++ packages/core/src/generated/components.ts | 10 +- sample/js/src/pages/todos.tsx | 6 +- .../kotlin/wang/harlon/tinyui/sample/App.kt | 15 ++- schema/components/text.ts | 5 +- 20 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 compose/api/.compose.klib.api.swp create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/Theme.kt create mode 100644 compose/src/commonTest/kotlin/wang/harlon/tinyui/schema/ThemeTest.kt create mode 100644 packages/cli/src/schema/tokens.ts create mode 100644 packages/cli/test/schema.test.ts diff --git a/compose/api/.compose.klib.api.swp b/compose/api/.compose.klib.api.swp new file mode 100644 index 0000000000000000000000000000000000000000..1b217a20e5c423d74da49f1a66063a680185901e GIT binary patch literal 16384 zcmeHO-ES0C6u%~@!!m^;boPVc$*ob&t5 zxgTdco$EV3HjbY$`Vn0BA@txI@1GvI_|b;XHz9O}u=2bu6!}-sk65Gy+liV?E$0T6qyfj!iJU$7nr>W_=1TdPB9pn$Z8H^pB|aj}!Vom41h6|2Coj zL+PJT?duhQ5XbLIAI85uq5nGz0$;1Eh%1^Wb|(6q@k&|FHhQ z0)^mZkPkrK1gU~dfgAw?q3>Pi8N<4GEYr*ks zixs9kZn1I?uKN}5#fD*Y<3a(8HzJIA!r|QsL%s@OxaK^qkirqCNT*DvN{!HFExb|2 z4QYIjw5@*YUS2ubH-j1iJ4)f53_db%*~P9DrwqImrpJWS9!yw~J67?mq29_cxX(PR zLJK4EEe*ae0yM_Frg5wap|&mj$CbrOiExgHe z)Kb`k+lKoG!sHodOZ$d!nR@lf)jfcr*S==63C7oA8&THueRgS3B~sug$f6*P8d7Nx zhWCnu8R`Px`N`C5RKlDQ8?ME-U1^VWk=R%aX~lePc|{Bnvm=_a7V{`C5tF9DNKQn& z+@!l2^KD!BK`Q}+@~7ZYTqTP^^kT6HKpt^e7LkRq{-;^G&p;KV5@8ms9m0y_FR&;1 zQ>No75xW-z3U%|=fj%)HTwEjyXHLAug~dz^;>e;X5pGP7h$wm*f)iLunQIe|)+c$i z;K(%(@~&=0&B%V~HZoa@?wVLZ>!Qlb6Zw=fawAa2aNEp>5r>tma>g^0%A1!HsrK8; ziloVysD`|Vmn^lXHk$&!TL6i&Ir<4yDho(0A0&kS=QSW1H&{&oWoQW#Fw z$uN$Mm?o`yu8Qrf;Zkl9+d2nmU{^WqI;?OAeoS(!VtLjgjTwcPdu>WGwx>qAl%v{> z9UAox zi;P-qx~fyN4_0-rDVxhHsv$UC2tEYbz~~Z&t+83N&^4Mo2`fjyUT$F$PXXwfWkqC1 zvt~r!$c|(xKxNv*b#o48Q6M;)=HjV0_qa3m>xRUQ0s~H-v*tMAOW0@DOFqSnROkR6 zlx5q1DQlx4grH|Ho|<>g;35SKJeW)F032<6x=LAr6H|%09PJdOW;;3_t5vsz}uT!%$ykYgMrW--BY<=-abbYst4frMHS@3JxapxTw`ShbPZw8m7GdVLjJ6E@Cc> zayTD$5?8j(bX?i}*Nn^Yxe5Q-S#>*zE5~LkuAKgDjxT&Qy$b`$XB*v`e7Gj6ST zkIKL2?DtvG?qG`Ez-$`I8w^=1z)4c3<4)1Gj-Wg5-Gl{ugtXa_iVcxQoSn9PEe(LDt9FORotaR2}S literal 0 HcmV?d00001 diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 0c24bc3..1040f48 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -50,7 +50,12 @@ abstract interface wang.harlon.tinyui.schema/NodeScope { // wang.harlon.tinyui.s abstract fun RenderChild(wang.harlon.tinyui.node/UINode, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/NodeScope.RenderChild|RenderChild(wang.harlon.tinyui.node.UINode;androidx.compose.runtime.Composer?;kotlin.Int){}[0] abstract fun dispatch(kotlin/String, kotlin/String = ...) // wang.harlon.tinyui.schema/NodeScope.dispatch|dispatch(kotlin.String;kotlin.String){}[0] abstract fun has(kotlin/String): kotlin/Boolean // wang.harlon.tinyui.schema/NodeScope.has|has(kotlin.String){}[0] - abstract fun modifier(kotlin/Boolean = ...): androidx.compose.ui/Modifier // wang.harlon.tinyui.schema/NodeScope.modifier|modifier(kotlin.Boolean){}[0] + abstract fun modifier(kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui/Modifier // wang.harlon.tinyui.schema/NodeScope.modifier|modifier(kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int){}[0] + open fun color(kotlin/String, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics/Color? // wang.harlon.tinyui.schema/NodeScope.color|color(kotlin.String;androidx.compose.runtime.Composer?;kotlin.Int){}[0] + + final class ComposeDefaultImpls { // wang.harlon.tinyui.schema/NodeScope.ComposeDefaultImpls|null[0] + final fun modifier$default(kotlin/Boolean, wang.harlon.tinyui.schema/NodeScope, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.ui/Modifier // wang.harlon.tinyui.schema/NodeScope.ComposeDefaultImpls.modifier$default|modifier$default#static(kotlin.Boolean;wang.harlon.tinyui.schema.NodeScope;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] + } } abstract interface wang.harlon.tinyui/HttpClient { // wang.harlon.tinyui/HttpClient|null[0] @@ -82,6 +87,36 @@ abstract interface wang.harlon.tinyui/Store { // wang.harlon.tinyui/Store|null[0 abstract fun set(kotlin/String, kotlin/String) // wang.harlon.tinyui/Store.set|set(kotlin.String;kotlin.String){}[0] } +sealed interface wang.harlon.tinyui.schema/ColorValue { // wang.harlon.tinyui.schema/ColorValue|null[0] + open fun resolve(androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics/Color // wang.harlon.tinyui.schema/ColorValue.resolve|resolve(androidx.compose.runtime.Composer?;kotlin.Int){}[0] + + final class Literal : wang.harlon.tinyui.schema/ColorValue { // wang.harlon.tinyui.schema/ColorValue.Literal|null[0] + constructor (androidx.compose.ui.graphics/Color) // wang.harlon.tinyui.schema/ColorValue.Literal.|(androidx.compose.ui.graphics.Color){}[0] + + final val color // wang.harlon.tinyui.schema/ColorValue.Literal.color|{}color[0] + final fun (): androidx.compose.ui.graphics/Color // wang.harlon.tinyui.schema/ColorValue.Literal.color.|(){}[0] + + final fun component1(): androidx.compose.ui.graphics/Color // wang.harlon.tinyui.schema/ColorValue.Literal.component1|component1(){}[0] + final fun copy(androidx.compose.ui.graphics/Color = ...): wang.harlon.tinyui.schema/ColorValue.Literal // wang.harlon.tinyui.schema/ColorValue.Literal.copy|copy(androidx.compose.ui.graphics.Color){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // wang.harlon.tinyui.schema/ColorValue.Literal.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // wang.harlon.tinyui.schema/ColorValue.Literal.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // wang.harlon.tinyui.schema/ColorValue.Literal.toString|toString(){}[0] + } + + final class Token : wang.harlon.tinyui.schema/ColorValue { // wang.harlon.tinyui.schema/ColorValue.Token|null[0] + constructor (kotlin/String) // wang.harlon.tinyui.schema/ColorValue.Token.|(kotlin.String){}[0] + + final val name // wang.harlon.tinyui.schema/ColorValue.Token.name|{}name[0] + final fun (): kotlin/String // wang.harlon.tinyui.schema/ColorValue.Token.name.|(){}[0] + + final fun component1(): kotlin/String // wang.harlon.tinyui.schema/ColorValue.Token.component1|component1(){}[0] + final fun copy(kotlin/String = ...): wang.harlon.tinyui.schema/ColorValue.Token // wang.harlon.tinyui.schema/ColorValue.Token.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // wang.harlon.tinyui.schema/ColorValue.Token.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // wang.harlon.tinyui.schema/ColorValue.Token.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // wang.harlon.tinyui.schema/ColorValue.Token.toString|toString(){}[0] + } +} + sealed interface wang.harlon.tinyui.schema/SizeValue { // wang.harlon.tinyui.schema/SizeValue|null[0] final class Fixed : wang.harlon.tinyui.schema/SizeValue { // wang.harlon.tinyui.schema/SizeValue.Fixed|null[0] constructor (androidx.compose.ui.unit/Dp) // wang.harlon.tinyui.schema/SizeValue.Fixed.|(androidx.compose.ui.unit.Dp){}[0] @@ -420,7 +455,7 @@ sealed class wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/P constructor (kotlin/String?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.ColorSpec.|(kotlin.String?;kotlin.Boolean;kotlin.Boolean){}[0] final val default // wang.harlon.tinyui.schema/PropSpec.ColorSpec.default|{}default[0] - final fun (): androidx.compose.ui.graphics/Color? // wang.harlon.tinyui.schema/PropSpec.ColorSpec.default.|(){}[0] + final fun (): wang.harlon.tinyui.schema/ColorValue? // wang.harlon.tinyui.schema/PropSpec.ColorSpec.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.ColorSpec.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } @@ -501,6 +536,10 @@ final object wang.harlon.tinyui.components.generated/BuiltinSchemas { // wang.ha final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.TextField.|(){}[0] final val all // wang.harlon.tinyui.components.generated/BuiltinSchemas.all|{}all[0] final fun (): kotlin.collections/List // wang.harlon.tinyui.components.generated/BuiltinSchemas.all.|(){}[0] + final val colorTokens // wang.harlon.tinyui.components.generated/BuiltinSchemas.colorTokens|{}colorTokens[0] + final fun (): kotlin.collections/Set // wang.harlon.tinyui.components.generated/BuiltinSchemas.colorTokens.|(){}[0] + final val textStyles // wang.harlon.tinyui.components.generated/BuiltinSchemas.textStyles|{}textStyles[0] + final fun (): kotlin.collections/Set // wang.harlon.tinyui.components.generated/BuiltinSchemas.textStyles.|(){}[0] } final object wang.harlon.tinyui.schema/LayoutProps { // wang.harlon.tinyui.schema/LayoutProps|null[0] @@ -508,6 +547,12 @@ final object wang.harlon.tinyui.schema/LayoutProps { // wang.harlon.tinyui.schem final fun (): kotlin.collections/Map // wang.harlon.tinyui.schema/LayoutProps.specs.|(){}[0] } +final object wang.harlon.tinyui.schema/Theme { // wang.harlon.tinyui.schema/Theme|null[0] + final fun color(androidx.compose.material3/ColorScheme, kotlin/String): androidx.compose.ui.graphics/Color? // wang.harlon.tinyui.schema/Theme.color|color(androidx.compose.material3.ColorScheme;kotlin.String){}[0] + final fun isColorToken(kotlin/String): kotlin/Boolean // wang.harlon.tinyui.schema/Theme.isColorToken|isColorToken(kotlin.String){}[0] + final fun textStyle(androidx.compose.material3/Typography, kotlin/String): androidx.compose.ui.text/TextStyle? // wang.harlon.tinyui.schema/Theme.textStyle|textStyle(androidx.compose.material3.Typography;kotlin.String){}[0] +} + final object wang.harlon.tinyui/TinyUI { // wang.harlon.tinyui/TinyUI|null[0] final val engineVersion // wang.harlon.tinyui/TinyUI.engineVersion|{}engineVersion[0] final fun (): kotlin/String // wang.harlon.tinyui/TinyUI.engineVersion.|(){}[0] @@ -525,6 +570,8 @@ final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop // final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop|#static{}wang_harlon_tinyui_node_NodeTree$stableprop[0] final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop|#static{}wang_harlon_tinyui_node_PatchProblem$stableprop[0] final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_UINode$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_UINode$stableprop|#static{}wang_harlon_tinyui_node_UINode$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Literal$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Literal$stableprop|#static{}wang_harlon_tinyui_schema_ColorValue_Literal$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Token$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Token$stableprop|#static{}wang_harlon_tinyui_schema_ColorValue_Token$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop|#static{}wang_harlon_tinyui_schema_ComponentRegistry$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop|#static{}wang_harlon_tinyui_schema_ComponentSchema$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop|#static{}wang_harlon_tinyui_schema_LayoutProps$stableprop[0] @@ -540,6 +587,7 @@ final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stabl final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Fill$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_Theme$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_Theme$stableprop|#static{}wang_harlon_tinyui_schema_Theme$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop|#static{}wang_harlon_tinyui_BuildManifest$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop|#static{}wang_harlon_tinyui_EventBus$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop|#static{}wang_harlon_tinyui_HostException$stableprop[0] @@ -567,6 +615,8 @@ final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop_get final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop_getter|wang_harlon_tinyui_node_NodeTree$stableprop_getter(){}[0] final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop_getter|wang_harlon_tinyui_node_PatchProblem$stableprop_getter(){}[0] final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_UINode$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_UINode$stableprop_getter|wang_harlon_tinyui_node_UINode$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Literal$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Literal$stableprop_getter|wang_harlon_tinyui_schema_ColorValue_Literal$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Token$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ColorValue_Token$stableprop_getter|wang_harlon_tinyui_schema_ColorValue_Token$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter|wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter|wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop_getter|wang_harlon_tinyui_schema_LayoutProps$stableprop_getter(){}[0] @@ -582,6 +632,7 @@ final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stabl final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_Theme$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_Theme$stableprop_getter|wang_harlon_tinyui_schema_Theme$stableprop_getter(){}[0] final fun wang.harlon.tinyui/PageFailureScreen(wang.harlon.tinyui/PageFailure, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui/PageFailureScreen|PageFailureScreen(wang.harlon.tinyui.PageFailure;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final fun wang.harlon.tinyui/TinyUIPage(wang.harlon.tinyui/RuntimeBundle, wang.harlon.tinyui/PageModule, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, wang.harlon.tinyui/HostServices?, kotlin/String?, androidx.compose.ui/Modifier?, wang.harlon.tinyui/SourceMaps?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // wang.harlon.tinyui/TinyUIPage|TinyUIPage(wang.harlon.tinyui.RuntimeBundle;wang.harlon.tinyui.PageModule;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;wang.harlon.tinyui.HostServices?;kotlin.String?;androidx.compose.ui.Modifier?;wang.harlon.tinyui.SourceMaps?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop_getter|wang_harlon_tinyui_BuildManifest$stableprop_getter(){}[0] diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt index 3c0ef2d..bc6391d 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -318,12 +317,13 @@ class PageHost( override fun dispatch(event: String, payload: String) = this@PageHost.dispatch(node.id, event, payload) /** docs/components.md §2: size → clip → background → gesture → padding. */ + @Composable override fun modifier(clickable: Boolean): Modifier { var m: Modifier = Modifier when (val w = get("width")) { is SizeValue.Fixed -> m = m.width(w.dp); SizeValue.Fill -> m = m.fillMaxWidth(); SizeValue.Wrap -> m = m.wrapContentWidth(); null -> {} } when (val h = get("height")) { is SizeValue.Fixed -> m = m.height(h.dp); SizeValue.Fill -> m = m.fillMaxHeight(); SizeValue.Wrap -> m = m.wrapContentHeight(); null -> {} } get("cornerRadius")?.let { m = m.clip(RoundedCornerShape(it)) } - get("background")?.let { m = m.background(it) } + color("background")?.let { m = m.background(it) } if (clickable && has("onClick")) m = m.clickable { dispatch("onClick") } get("padding")?.let { m = m.padding(it) } return m diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt index 426b990..40515e9 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -46,6 +48,7 @@ import wang.harlon.tinyui.components.generated.BuiltinSchemas import wang.harlon.tinyui.schema.Commands import wang.harlon.tinyui.schema.ComponentRegistry import wang.harlon.tinyui.schema.NodeScope +import wang.harlon.tinyui.schema.Theme /** First batch of built-ins (docs/components.md §3); schemas come from schema/ via `tinyui schema`. */ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { @@ -70,9 +73,10 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { val maxLines = scope.get("maxLines")?.toInt()?.takeIf { it > 0 } ?: Int.MAX_VALUE Text( text = scope["text"] ?: "", - color = scope["color"] ?: Color.Unspecified, + style = scope.get("style")?.let { Theme.textStyle(MaterialTheme.typography, it) } ?: LocalTextStyle.current, + color = scope.color("color") ?: Color.Unspecified, fontSize = scope["fontSize"] ?: TextUnit.Unspecified, - fontWeight = when (scope.get("fontWeight")) { "medium" -> FontWeight.Medium; "bold" -> FontWeight.Bold; else -> FontWeight.Normal }, + fontWeight = when (scope.get("fontWeight")) { "normal" -> FontWeight.Normal; "medium" -> FontWeight.Medium; "bold" -> FontWeight.Bold; else -> null }, maxLines = maxLines, overflow = if (maxLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis, textAlign = when (scope.get("align")) { "center" -> TextAlign.Center; "end" -> TextAlign.End; else -> TextAlign.Start }, diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt index 5b629f5..7e1954d 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt @@ -56,9 +56,10 @@ object BuiltinSchemas { type = "Text", props = mapOf( "text" to PropSpec.Str(default = null, required = true, initial = false), + "style" to PropSpec.Enum(values = setOf("displayLarge", "displayMedium", "displaySmall", "headlineLarge", "headlineMedium", "headlineSmall", "titleLarge", "titleMedium", "titleSmall", "bodyLarge", "bodyMedium", "bodySmall", "labelLarge", "labelMedium", "labelSmall"), default = null, required = false, initial = false), "color" to PropSpec.ColorSpec(default = null, required = false, initial = false), "fontSize" to PropSpec.Sp(default = null, required = false, initial = false), - "fontWeight" to PropSpec.Enum(values = setOf("normal", "medium", "bold"), default = "normal", required = false, initial = false), + "fontWeight" to PropSpec.Enum(values = setOf("normal", "medium", "bold"), default = null, required = false, initial = false), "maxLines" to PropSpec.Num(default = 0.0, required = false, initial = false), "align" to PropSpec.Enum(values = setOf("start", "center", "end"), default = "start", required = false, initial = false), ), @@ -137,4 +138,8 @@ object BuiltinSchemas { ) val all: List = listOf(Column, Row, Box, Text, Button, TextField, LazyColumn, Spacer) + + /** Theme token names the schema DSL knows; Theme.kt must resolve each (checked by test). */ + val colorTokens: Set = setOf("primary", "onPrimary", "primaryContainer", "onPrimaryContainer", "inversePrimary", "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", "background", "onBackground", "surface", "onSurface", "surfaceVariant", "onSurfaceVariant", "surfaceTint", "inverseSurface", "inverseOnSurface", "error", "onError", "errorContainer", "onErrorContainer", "outline", "outlineVariant", "scrim", "surfaceBright", "surfaceDim", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest") + val textStyles: Set = setOf("displayLarge", "displayMedium", "displaySmall", "headlineLarge", "headlineMedium", "headlineSmall", "titleLarge", "titleMedium", "titleSmall", "bodyLarge", "bodyMedium", "bodySmall", "labelLarge", "labelMedium", "labelSmall") } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt index 6a45f1b..cd24402 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt @@ -51,10 +51,10 @@ sealed class PropSpec(val kind: String, val required: Boolean, val initial: Bool override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.sp } - /** `#RRGGBB` or `#AARRGGBB`. */ + /** `#RRGGBB` / `#AARRGGBB`, or a theme token name (Theme.kt). */ class ColorSpec(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("color", required, initial) { - override val default: Color? = default?.let(::parseColor) - override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColor) + override val default: ColorValue? = default?.let(::parseColorValue) + override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColorValue) } /** Stored as the enum name; the composable maps it. */ @@ -87,6 +87,10 @@ enum class FieldSpec { } } +internal fun parseColorValue(text: String): ColorValue? = + if (text.startsWith("#")) parseColor(text)?.let { ColorValue.Literal(it) } + else text.takeIf(Theme::isColorToken)?.let { ColorValue.Token(it) } + internal fun parseColor(text: String): Color? { if (!text.startsWith("#")) return null val hex = text.substring(1) diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt index e57cbcd..1c0dda1 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import wang.harlon.tinyui.node.Command import wang.harlon.tinyui.node.UINode @@ -20,7 +21,12 @@ interface NodeScope { /** K2: fire and forget, on any thread. [payload] is the flat JSON object the event schema declares. */ fun dispatch(event: String, payload: String = "{}") + /** A `color` prop with theme tokens resolved against the current MaterialTheme. */ + @Composable + fun color(key: String): Color? = get(key)?.resolve() + /** Layout modifier from the common props; with [clickable] the click gesture is attached when `onClick` is registered. */ + @Composable fun modifier(clickable: Boolean = true): Modifier @Composable diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/Theme.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/Theme.kt new file mode 100644 index 0000000..296eb8e --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/Theme.kt @@ -0,0 +1,96 @@ +package wang.harlon.tinyui.schema + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle + +/** A `color` prop: a literal, or a theme token the host resolves at composition (docs/components.md §6). */ +sealed interface ColorValue { + data class Literal(val color: Color) : ColorValue + data class Token(val name: String) : ColorValue + + @Composable + fun resolve(): Color = when (this) { + is Literal -> color + is Token -> Theme.color(MaterialTheme.colorScheme, name) ?: Color.Unspecified + } +} + +/** Token names → MaterialTheme values. The name lists in the schema DSL (cli `schema/tokens.ts`) must match these. */ +object Theme { + fun isColorToken(name: String): Boolean = color(PROBE, name) != null + + fun color(scheme: ColorScheme, token: String): Color? = when (token) { + "primary" -> scheme.primary + "onPrimary" -> scheme.onPrimary + "primaryContainer" -> scheme.primaryContainer + "onPrimaryContainer" -> scheme.onPrimaryContainer + "inversePrimary" -> scheme.inversePrimary + "secondary" -> scheme.secondary + "onSecondary" -> scheme.onSecondary + "secondaryContainer" -> scheme.secondaryContainer + "onSecondaryContainer" -> scheme.onSecondaryContainer + "tertiary" -> scheme.tertiary + "onTertiary" -> scheme.onTertiary + "tertiaryContainer" -> scheme.tertiaryContainer + "onTertiaryContainer" -> scheme.onTertiaryContainer + "background" -> scheme.background + "onBackground" -> scheme.onBackground + "surface" -> scheme.surface + "onSurface" -> scheme.onSurface + "surfaceVariant" -> scheme.surfaceVariant + "onSurfaceVariant" -> scheme.onSurfaceVariant + "surfaceTint" -> scheme.surfaceTint + "inverseSurface" -> scheme.inverseSurface + "inverseOnSurface" -> scheme.inverseOnSurface + "error" -> scheme.error + "onError" -> scheme.onError + "errorContainer" -> scheme.errorContainer + "onErrorContainer" -> scheme.onErrorContainer + "outline" -> scheme.outline + "outlineVariant" -> scheme.outlineVariant + "scrim" -> scheme.scrim + "surfaceBright" -> scheme.surfaceBright + "surfaceDim" -> scheme.surfaceDim + "surfaceContainer" -> scheme.surfaceContainer + "surfaceContainerHigh" -> scheme.surfaceContainerHigh + "surfaceContainerHighest" -> scheme.surfaceContainerHighest + "surfaceContainerLow" -> scheme.surfaceContainerLow + "surfaceContainerLowest" -> scheme.surfaceContainerLowest + else -> null + } + + fun textStyle(typography: Typography, name: String): TextStyle? = when (name) { + "displayLarge" -> typography.displayLarge + "displayMedium" -> typography.displayMedium + "displaySmall" -> typography.displaySmall + "headlineLarge" -> typography.headlineLarge + "headlineMedium" -> typography.headlineMedium + "headlineSmall" -> typography.headlineSmall + "titleLarge" -> typography.titleLarge + "titleMedium" -> typography.titleMedium + "titleSmall" -> typography.titleSmall + "bodyLarge" -> typography.bodyLarge + "bodyMedium" -> typography.bodyMedium + "bodySmall" -> typography.bodySmall + "labelLarge" -> typography.labelLarge + "labelMedium" -> typography.labelMedium + "labelSmall" -> typography.labelSmall + else -> null + } + + private val PROBE = ColorScheme( + primary = Color.Black, onPrimary = Color.Black, primaryContainer = Color.Black, onPrimaryContainer = Color.Black, inversePrimary = Color.Black, + secondary = Color.Black, onSecondary = Color.Black, secondaryContainer = Color.Black, onSecondaryContainer = Color.Black, + tertiary = Color.Black, onTertiary = Color.Black, tertiaryContainer = Color.Black, onTertiaryContainer = Color.Black, + background = Color.Black, onBackground = Color.Black, surface = Color.Black, onSurface = Color.Black, + surfaceVariant = Color.Black, onSurfaceVariant = Color.Black, surfaceTint = Color.Black, inverseSurface = Color.Black, inverseOnSurface = Color.Black, + error = Color.Black, onError = Color.Black, errorContainer = Color.Black, onErrorContainer = Color.Black, + outline = Color.Black, outlineVariant = Color.Black, scrim = Color.Black, + surfaceBright = Color.Black, surfaceDim = Color.Black, surfaceContainer = Color.Black, surfaceContainerHigh = Color.Black, + surfaceContainerHighest = Color.Black, surfaceContainerLow = Color.Black, surfaceContainerLowest = Color.Black, + ) +} diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt index fd0fc06..9606a8f 100644 --- a/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt @@ -1,6 +1,7 @@ package wang.harlon.tinyui.node import androidx.compose.ui.graphics.Color +import wang.harlon.tinyui.schema.ColorValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import wang.harlon.tinyui.components.registerBuiltins @@ -30,7 +31,7 @@ class NodeTreeTest { fun convertsPropsOnWriteAndRestoresDefaultsOnNull() { tree.apply("""[["c",1,"Text"],["p",1,"text","t"],["p",1,"color","#FF0000"],["p",1,"fontSize",18],["i",0,1,0]]""") val node = tree.node(1)!! - assertEquals(Color(0xFFFF0000), node.props["color"]) + assertEquals(ColorValue.Literal(Color(0xFFFF0000)), node.props["color"]) assertEquals(18.sp, node.props["fontSize"]) tree.apply("""[["p",1,"color",null]]""") assertNull(node.props["color"], "null restores the schema default, which Text.color does not have") diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/schema/ThemeTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/schema/ThemeTest.kt new file mode 100644 index 0000000..4f4b0f4 --- /dev/null +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/schema/ThemeTest.kt @@ -0,0 +1,32 @@ +package wang.harlon.tinyui.schema + +import androidx.compose.material3.Typography +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.json.JsonPrimitive +import wang.harlon.tinyui.components.generated.BuiltinSchemas +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ThemeTest { + @Test + fun everyTokenTheSchemaDslKnowsResolves() { + val scheme = lightColorScheme() + for (token in BuiltinSchemas.colorTokens) assertNotNull(Theme.color(scheme, token), "color token $token") + val typography = Typography() + for (name in BuiltinSchemas.textStyles) assertNotNull(Theme.textStyle(typography, name), "text style $name") + } + + @Test + fun colorPropsTakeLiteralsAndTokensOnly() { + val spec = PropSpec.ColorSpec(default = "primary") + assertEquals(ColorValue.Token("primary"), spec.default) + assertEquals(ColorValue.Literal(Color(0xFF112233)), spec.convert(JsonPrimitive("#112233"))) + assertEquals(ColorValue.Token("onSurfaceVariant"), spec.convert(JsonPrimitive("onSurfaceVariant"))) + assertNull(spec.convert(JsonPrimitive("Primary")), "token names are case-sensitive") + assertNull(spec.convert(JsonPrimitive("#12")), "malformed literal") + assertNull(spec.convert(JsonPrimitive(1))) + } +} diff --git a/docs/components.md b/docs/components.md index 5e97258..0416607 100644 --- a/docs/components.md +++ b/docs/components.md @@ -30,7 +30,7 @@ defineComponent("TextField", { }); ``` -prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。 +prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`,或 §6 的主题 token 名)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。 ## 2. 公共布局 prop 与 Modifier 顺序 @@ -56,7 +56,7 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB |---|---|---|---|---| | `Column` / `Row` | `gap`、`align`(交叉轴 start / center / end)、`justify`(主轴 start / center / end / spaceBetween) | `onClick` | | 是 | | `Box` | `align`(九宫格) | `onClick` | | 是 | -| `Text` | `text`(必填)、`color`、`fontSize`、`fontWeight`(normal / medium / bold)、`maxLines`(0 = 不限,超出省略号)、`align` | `onClick` | | | +| `Text` | `text`(必填)、`style`(M3 文字样式名,§6)、`color`、`fontSize`、`fontWeight`(normal / medium / bold;设了就覆盖 `style` 的对应字段,不设则跟 `style`)、`maxLines`(0 = 不限,超出省略号)、`align` | `onClick` | | | | `Button` | `text`(必填)、`enabled`、`variant`(filled / outlined / text) | `onClick` | | | | `TextField` | `initialText`(initial)、`placeholder`、`singleLine`、`keyboard` | `onChange{text}`、`onCommit{text}`(IME Done 或失焦) | `setText{text}`、`focus`、`blur` | | | `LazyColumn` | `gap` | `onReachEnd`、`onScrollEnd{index}` | `scrollTo{index}` | 是,通常是一个 `` | @@ -73,3 +73,12 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB ## 5. `Placeholder` 未注册的类型渲染 `Placeholder`,同时 E5 上报。debug 构建(Android `FLAG_DEBUGGABLE`、iOS debug binary)画红框加类型名与 id;release 渲染零尺寸的空 `Box`。 + +## 6. 主题 token + +定于 2026-09-17。**主题色与文字样式只能以 token 名过桥,Kotlin 在组合期从 `MaterialTheme` 解析;JS 永远拿不到主题的具体值。** 深浅切换、宿主换主题色时页面不发任何 patch 就跟着变——主题是宿主的运行时状态,不是页面的数据(与 ADR-004"高频状态留 Kotlin"同一原理)。需要派生色(如 primary 变浅)时由宿主注册成新 token,不在页面里算。 + +- `color` 类型的 prop 同时接受字面量(`#RRGGBB` / `#AARRGGBB`)与 token 名;生成器出联合类型(`` `#${string}` | ColorToken ``),拼错的 token 编译期报,Kotlin 侧按首字符 `#` 分流。字面量留给品牌色、图表色这类本来就不属于主题的颜色 +- token 词表就是 Material 3 的字段名:颜色为 `ColorScheme` 的全部字段,文字样式为 `Typography` 的十五个名字(cli `schema/tokens.ts` 是词表真值,`compose/.../schema/Theme.kt` 逐一解析,测试保证两边一致) +- 暂不做:宿主自定义 token(触发条件:某宿主出现 M3 之外的语义色要跨页复用,届时走清单下发 + 宿主类型包生成,与宿主组件同一条路);间距 / 圆角 / 阴影的 token + diff --git a/packages/cli/src/schema/dsl.ts b/packages/cli/src/schema/dsl.ts index 1d1bd72..cf6cb9a 100644 --- a/packages/cli/src/schema/dsl.ts +++ b/packages/cli/src/schema/dsl.ts @@ -48,6 +48,7 @@ export const number = (o: PropOptions & { default?: number } = {}) => prop("numb export const boolean = (o: PropOptions & { default?: boolean } = {}) => prop("boolean", o); export const dp = (o: PropOptions & { default?: number } = {}) => prop("dp", o); export const sp = (o: PropOptions & { default?: number } = {}) => prop("sp", o); +/** `#RRGGBB` / `#AARRGGBB`, or a theme token name (tokens.ts). */ export const color = (o: PropOptions & { default?: string } = {}) => prop("color", o); /** A number in dp, or "fill" / "wrap". */ export const size = (o: PropOptions & { default?: number | "fill" | "wrap" } = {}) => prop("size", o); diff --git a/packages/cli/src/schema/generate.ts b/packages/cli/src/schema/generate.ts index 55cad35..8801409 100644 --- a/packages/cli/src/schema/generate.ts +++ b/packages/cli/src/schema/generate.ts @@ -1,11 +1,20 @@ import type { ComponentDef, FieldDef, PropDef } from "./dsl.ts"; import { LAYOUT_PROPS } from "./dsl.ts"; +import { COLOR_TOKENS, TEXT_STYLES } from "./tokens.ts"; const HEADER = "// Generated by `tinyui schema` from schema/; do not edit."; /** [nodeImport] is where `Node` and `Ref` come from: `@tiny-ui/core` for host packages, `../node.ts` inside core itself. */ export function generateTs(components: ComponentDef[], nodeImport = "@tiny-ui/core"): string { - const out: string[] = [HEADER, `import type { Node, Ref } from ${JSON.stringify(nodeImport)};`, ""]; + const out: string[] = [HEADER]; + if (nodeImport === "@tiny-ui/core") { + out.push(`import type { ColorValue, Node, Ref } from "@tiny-ui/core";`, ""); + } else { + out.push(`import type { Node, Ref } from ${JSON.stringify(nodeImport)};`, ""); + out.push(`export type ColorToken = ${COLOR_TOKENS.map((t) => JSON.stringify(t)).join(" | ")};`); + out.push("/** A literal `#RRGGBB` / `#AARRGGBB`, or a theme token resolved by the host at composition. */"); + out.push("export type ColorValue = `#${string}` | ColorToken;", ""); + } out.push("export interface LayoutProps {"); for (const [k, p] of Object.entries(LAYOUT_PROPS)) out.push(` ${k}?: ${tsPropType(p)};`); out.push("}", ""); @@ -55,6 +64,11 @@ export function generateKt(components: ComponentDef[], pkg: string, objectName: out.push(` )`, ""); } out.push(` val all: List = listOf(${components.map((c) => ktIdent(c.name)).join(", ")})`); + if (objectName === "BuiltinSchemas") { + out.push("", " /** Theme token names the schema DSL knows; Theme.kt must resolve each (checked by test). */"); + out.push(` val colorTokens: Set = setOf(${COLOR_TOKENS.map((t) => JSON.stringify(t)).join(", ")})`); + out.push(` val textStyles: Set = setOf(${TEXT_STYLES.map((t) => JSON.stringify(t)).join(", ")})`); + } out.push("}"); return out.join("\n") + "\n"; } @@ -71,7 +85,8 @@ function tsInterface(name: string, fields: Record): string[] { function tsPropType(p: PropDef): string { switch (p.kind) { - case "string": case "color": return "string"; + case "string": return "string"; + case "color": return "ColorValue"; case "number": case "dp": case "sp": return "number"; case "boolean": return "boolean"; case "size": return 'number | "fill" | "wrap"'; diff --git a/packages/cli/src/schema/index.ts b/packages/cli/src/schema/index.ts index 1b675fc..368924c 100644 --- a/packages/cli/src/schema/index.ts +++ b/packages/cli/src/schema/index.ts @@ -1,2 +1,3 @@ // What schema authors import; the generator and loader stay inside the CLI. export * from "./dsl.ts"; +export * from "./tokens.ts"; diff --git a/packages/cli/src/schema/tokens.ts b/packages/cli/src/schema/tokens.ts new file mode 100644 index 0000000..23d204d --- /dev/null +++ b/packages/cli/src/schema/tokens.ts @@ -0,0 +1,23 @@ +// Theme tokens a page may name instead of a literal value; Kotlin resolves them from MaterialTheme at composition +// (docs/components.md §6). Names are Material 3's own; the Kotlin resolver in compose/.../schema/Theme.kt must cover every entry. + +export const COLOR_TOKENS = [ + "primary", "onPrimary", "primaryContainer", "onPrimaryContainer", "inversePrimary", + "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", + "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", + "background", "onBackground", + "surface", "onSurface", "surfaceVariant", "onSurfaceVariant", "surfaceTint", + "inverseSurface", "inverseOnSurface", + "error", "onError", "errorContainer", "onErrorContainer", + "outline", "outlineVariant", "scrim", + "surfaceBright", "surfaceDim", + "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", +] as const; + +export const TEXT_STYLES = [ + "displayLarge", "displayMedium", "displaySmall", + "headlineLarge", "headlineMedium", "headlineSmall", + "titleLarge", "titleMedium", "titleSmall", + "bodyLarge", "bodyMedium", "bodySmall", + "labelLarge", "labelMedium", "labelSmall", +] as const; diff --git a/packages/cli/test/schema.test.ts b/packages/cli/test/schema.test.ts new file mode 100644 index 0000000..0f80791 --- /dev/null +++ b/packages/cli/test/schema.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { color, defineComponent, enumOf, string, TEXT_STYLES } from "../src/schema/index.ts"; +import { generateKt, generateTs } from "../src/schema/generate.ts"; + +const card = defineComponent("pp.Card", { + props: { title: string({ required: true }), tint: color({ default: "primary" }), style: enumOf(TEXT_STYLES) }, + events: {}, +}); + +describe("schema generator", () => { + it("types color props as ColorValue; host packages import it from core, core defines it", () => { + const host = generateTs([card]); + assert.match(host, /import type \{ ColorValue, Node, Ref \} from "@tiny-ui\/core"/); + assert.match(host, /tint\?: ColorValue;/); + assert.doesNotMatch(host, /export type ColorValue/); + const core = generateTs([card], "../node.ts"); + assert.match(core, /export type ColorValue = `#\$\{string\}` \| ColorToken;/); + assert.match(core, /export type ColorToken = "primary" \|/); + }); + + it("emits the token name sets only for the built-in object", () => { + const builtin = generateKt([card], "x", "BuiltinSchemas"); + assert.match(builtin, /val colorTokens: Set = setOf\("primary"/); + assert.match(builtin, /val textStyles: Set = setOf\("displayLarge"/); + assert.match(builtin, /"tint" to PropSpec\.ColorSpec\(default = "primary"/); + assert.doesNotMatch(generateKt([card], "x", "HostSchemas"), /colorTokens/); + }); +}); diff --git a/packages/core/src/generated/components.ts b/packages/core/src/generated/components.ts index 3f0b2be..68365d9 100644 --- a/packages/core/src/generated/components.ts +++ b/packages/core/src/generated/components.ts @@ -1,11 +1,15 @@ // Generated by `tinyui schema` from schema/; do not edit. import type { Node, Ref } from "../node.ts"; +export type ColorToken = "primary" | "onPrimary" | "primaryContainer" | "onPrimaryContainer" | "inversePrimary" | "secondary" | "onSecondary" | "secondaryContainer" | "onSecondaryContainer" | "tertiary" | "onTertiary" | "tertiaryContainer" | "onTertiaryContainer" | "background" | "onBackground" | "surface" | "onSurface" | "surfaceVariant" | "onSurfaceVariant" | "surfaceTint" | "inverseSurface" | "inverseOnSurface" | "error" | "onError" | "errorContainer" | "onErrorContainer" | "outline" | "outlineVariant" | "scrim" | "surfaceBright" | "surfaceDim" | "surfaceContainer" | "surfaceContainerHigh" | "surfaceContainerHighest" | "surfaceContainerLow" | "surfaceContainerLowest"; +/** A literal `#RRGGBB` / `#AARRGGBB`, or a theme token resolved by the host at composition. */ +export type ColorValue = `#${string}` | ColorToken; + export interface LayoutProps { width?: number | "fill" | "wrap"; height?: number | "fill" | "wrap"; cornerRadius?: number; - background?: string; + background?: ColorValue; padding?: number; } @@ -54,7 +58,9 @@ export interface TextClickPayload { export interface TextProps extends LayoutProps { text: string; - color?: string; + /** Material 3 text style; fontSize / fontWeight override its fields */ + style?: "displayLarge" | "displayMedium" | "displaySmall" | "headlineLarge" | "headlineMedium" | "headlineSmall" | "titleLarge" | "titleMedium" | "titleSmall" | "bodyLarge" | "bodyMedium" | "bodySmall" | "labelLarge" | "labelMedium" | "labelSmall"; + color?: ColorValue; fontSize?: number; fontWeight?: "normal" | "medium" | "bold"; /** 0 = unlimited */ diff --git a/sample/js/src/pages/todos.tsx b/sample/js/src/pages/todos.tsx index 72b151e..90ca236 100644 --- a/sample/js/src/pages/todos.tsx +++ b/sample/js/src/pages/todos.tsx @@ -41,7 +41,7 @@ export default function Todos() { return ( - t.done).length} done`} fontSize={20} fontWeight="bold" /> + t.done).length} done`} style="titleLarge" /> }> {() => ( @@ -63,8 +63,8 @@ export default function Todos() { function TodoRow(props: { todo: Todo; onRemove: () => void }) { return ( - { props.todo.done = !props.todo.done; }}> - + { props.todo.done = !props.todo.done; }}> +