From aee51b28e68e56d0d89e1e655f1126ff221f48a2 Mon Sep 17 00:00:00 2001 From: salutedevs Date: Thu, 15 Oct 2026 14:03:36 +0300 Subject: [PATCH 01/10] chore: Bump versions for changed modules (#949) --- gradle/libs.versions.toml | 6 +- .../sandbox-compose/gradle.properties | 2 +- .../sandbox-core/gradle.properties | 2 +- .../sandbox-ksp/gradle.properties | 2 +- .../sandbox-view/gradle.properties | 2 +- .../plugin_theme_builder/gradle.properties | 2 +- sdds-core/uikit-compose/gradle.properties | 2 +- sdds-core/uikit/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- .../plasma-stards-compose/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- tokens/plasma-stards-view/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- tokens/plasma.giga.compose/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- .../plasma.homeds.compose/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- .../gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- .../plasma.sd.service.view/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- tokens/sdds-finai-compose/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- tokens/sdds-sbcom-compose/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- tokens/sdds.serv.compose/gradle.properties | 2 +- .../docs/override-docs/versionsArchived.json | 3 +- tokens/sdds.serv.view/gradle.properties | 2 +- tokens/summary.json | 84 +++++++++---------- 29 files changed, 82 insertions(+), 72 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ecb846fb..83a80fe92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,9 +53,9 @@ glide = "4.16.0" koilVersion-compose = "3.2.0" ktor-client = "3.1.3" -sdds-uikit = "0.52.0" -sdds-uikit-compose = "0.54.0" -sdds-theme-builder = "0.51.0" +sdds-uikit = "0.53.0" +sdds-uikit-compose = "0.55.0" +sdds-theme-builder = "0.52.0" sdds-dsbuilder = "0.46.0" sdds-haze = "0.5.1" diff --git a/integration-core/sandbox-compose/gradle.properties b/integration-core/sandbox-compose/gradle.properties index 50ab3b95d..a9cc86a4b 100644 --- a/integration-core/sandbox-compose/gradle.properties +++ b/integration-core/sandbox-compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=sdds-sandbox-compose nexus.snapshot=false nexus.description=SDDS base sandbox for Jetpack Compose versionMajor=0 -versionMinor=15 +versionMinor=16 versionPatch=0 components-version=0.13.0 diff --git a/integration-core/sandbox-core/gradle.properties b/integration-core/sandbox-core/gradle.properties index f331c8486..d2bc6524c 100644 --- a/integration-core/sandbox-core/gradle.properties +++ b/integration-core/sandbox-core/gradle.properties @@ -2,5 +2,5 @@ nexus.artifactId=sdds-sandbox-core nexus.snapshot=false nexus.description=SDDS base sandbox versionMajor=0 -versionMinor=15 +versionMinor=16 versionPatch=0 diff --git a/integration-core/sandbox-ksp/gradle.properties b/integration-core/sandbox-ksp/gradle.properties index 6685b0115..bb7ab7681 100644 --- a/integration-core/sandbox-ksp/gradle.properties +++ b/integration-core/sandbox-ksp/gradle.properties @@ -2,5 +2,5 @@ nexus.artifactId=sdds-sandbox-ksp nexus.snapshot=false nexus.description=SDDS sandbox ksp plugin versionMajor=0 -versionMinor=15 +versionMinor=16 versionPatch=0 diff --git a/integration-core/sandbox-view/gradle.properties b/integration-core/sandbox-view/gradle.properties index 6c9ce5c94..ebf15b09f 100644 --- a/integration-core/sandbox-view/gradle.properties +++ b/integration-core/sandbox-view/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=sdds-sandbox-view nexus.snapshot=false nexus.description=SDDS base sandbox for android view system versionMajor=0 -versionMinor=15 +versionMinor=16 versionPatch=0 components-version=0.13.0 diff --git a/sdds-core/plugin_theme_builder/gradle.properties b/sdds-core/plugin_theme_builder/gradle.properties index c825ea48d..d1c4a883b 100644 --- a/sdds-core/plugin_theme_builder/gradle.properties +++ b/sdds-core/plugin_theme_builder/gradle.properties @@ -1,4 +1,4 @@ versionMajor=0 -versionMinor=51 +versionMinor=52 versionPatch=0 nexus.artifactId=sdds-theme-builder \ No newline at end of file diff --git a/sdds-core/uikit-compose/gradle.properties b/sdds-core/uikit-compose/gradle.properties index be8374ef4..890f9340d 100644 --- a/sdds-core/uikit-compose/gradle.properties +++ b/sdds-core/uikit-compose/gradle.properties @@ -2,5 +2,5 @@ nexus.artifactId=sdds-uikit-compose nexus.snapshot=false nexus.description=SDDS UIKit library with base components for android jetpack compose versionMajor=0 -versionMinor=54 +versionMinor=55 versionPatch=0 diff --git a/sdds-core/uikit/gradle.properties b/sdds-core/uikit/gradle.properties index 39dc637f2..de85a4df0 100644 --- a/sdds-core/uikit/gradle.properties +++ b/sdds-core/uikit/gradle.properties @@ -2,5 +2,5 @@ nexus.artifactId=sdds-uikit nexus.snapshot=false nexus.description=SDDS UIKit library with base components for android view system versionMajor=0 -versionMinor=52 +versionMinor=53 versionPatch=0 diff --git a/tokens/plasma-stards-compose/docs/override-docs/versionsArchived.json b/tokens/plasma-stards-compose/docs/override-docs/versionsArchived.json index 4648d8674..9da083124 100644 --- a/tokens/plasma-stards-compose/docs/override-docs/versionsArchived.json +++ b/tokens/plasma-stards-compose/docs/override-docs/versionsArchived.json @@ -35,5 +35,6 @@ "0.37.1": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.37.1/", "0.38.0": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.38.0/", "0.39.0": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.39.0/", - "0.40.0": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.40.0/" + "0.40.0": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.40.0/", + "0.41.0": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.41.0/" } \ No newline at end of file diff --git a/tokens/plasma-stards-compose/gradle.properties b/tokens/plasma-stards-compose/gradle.properties index 66a5fe15b..87c59f919 100644 --- a/tokens/plasma-stards-compose/gradle.properties +++ b/tokens/plasma-stards-compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=plasma-star-ds-compose nexus.snapshot=false nexus.description=plasma-star-ds library for compose framework versionMajor=0 -versionMinor=41 +versionMinor=42 versionPatch=0 theme-url=https://github.com/salute-developers/theme-converter/raw/main/themes/plasma_stards/0.7.0-alpha.zip diff --git a/tokens/plasma-stards-view/docs/override-docs/versionsArchived.json b/tokens/plasma-stards-view/docs/override-docs/versionsArchived.json index 943818928..a1d964e85 100644 --- a/tokens/plasma-stards-view/docs/override-docs/versionsArchived.json +++ b/tokens/plasma-stards-view/docs/override-docs/versionsArchived.json @@ -30,5 +30,6 @@ "2.39.0": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.39.0/", "2.40.0": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.40.0/", "2.41.0": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.41.0/", - "2.42.0": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.42.0/" + "2.42.0": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.42.0/", + "2.43.0": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.43.0/" } \ No newline at end of file diff --git a/tokens/plasma-stards-view/gradle.properties b/tokens/plasma-stards-view/gradle.properties index 66af5fe0d..ccb6d2d21 100644 --- a/tokens/plasma-stards-view/gradle.properties +++ b/tokens/plasma-stards-view/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=plasma-star-ds nexus.snapshot=false nexus.description=plasma-star-ds library for view framework versionMajor=2 -versionMinor=43 +versionMinor=44 versionPatch=0 summary.key=plasmaStarDS diff --git a/tokens/plasma.giga.compose/docs/override-docs/versionsArchived.json b/tokens/plasma.giga.compose/docs/override-docs/versionsArchived.json index 335feb060..d50765c47 100644 --- a/tokens/plasma.giga.compose/docs/override-docs/versionsArchived.json +++ b/tokens/plasma.giga.compose/docs/override-docs/versionsArchived.json @@ -35,5 +35,6 @@ "0.35.1": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.35.1/", "0.36.0": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.36.0/", "0.37.0": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.37.0/", - "0.38.0": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.38.0/" + "0.38.0": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.38.0/", + "0.39.0": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.39.0/" } \ No newline at end of file diff --git a/tokens/plasma.giga.compose/gradle.properties b/tokens/plasma.giga.compose/gradle.properties index 7f6f4fb39..b111d2600 100644 --- a/tokens/plasma.giga.compose/gradle.properties +++ b/tokens/plasma.giga.compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=plasma-giga-compose nexus.snapshot=false nexus.description=plasma-giga library for compose framework versionMajor=0 -versionMinor=39 +versionMinor=40 versionPatch=0 theme-version=latest diff --git a/tokens/plasma.homeds.compose/docs/override-docs/versionsArchived.json b/tokens/plasma.homeds.compose/docs/override-docs/versionsArchived.json index 643e9a196..660718b65 100644 --- a/tokens/plasma.homeds.compose/docs/override-docs/versionsArchived.json +++ b/tokens/plasma.homeds.compose/docs/override-docs/versionsArchived.json @@ -34,5 +34,6 @@ "0.27.1": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.27.1/", "0.28.0": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.28.0/", "0.29.0": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.29.0/", - "0.30.0": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.30.0/" + "0.30.0": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.30.0/", + "0.31.0": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.31.0/" } \ No newline at end of file diff --git a/tokens/plasma.homeds.compose/gradle.properties b/tokens/plasma.homeds.compose/gradle.properties index 8763699d2..d05a06048 100644 --- a/tokens/plasma.homeds.compose/gradle.properties +++ b/tokens/plasma.homeds.compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=plasma-homeds-compose nexus.snapshot=false nexus.description=plasma-homeds library for compose framework versionMajor=0 -versionMinor=31 +versionMinor=32 versionPatch=0 theme-version=0.4.0 diff --git a/tokens/plasma.sd.service.compose/docs/override-docs/versionsArchived.json b/tokens/plasma.sd.service.compose/docs/override-docs/versionsArchived.json index 72266bd62..07fc15e05 100644 --- a/tokens/plasma.sd.service.compose/docs/override-docs/versionsArchived.json +++ b/tokens/plasma.sd.service.compose/docs/override-docs/versionsArchived.json @@ -35,5 +35,6 @@ "0.43.1": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.43.1/", "0.44.0": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.44.0/", "0.45.0": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.45.0/", - "0.46.0": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.46.0/" + "0.46.0": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.46.0/", + "0.47.0": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.47.0/" } \ No newline at end of file diff --git a/tokens/plasma.sd.service.compose/gradle.properties b/tokens/plasma.sd.service.compose/gradle.properties index 6229d3100..d8bf9018e 100644 --- a/tokens/plasma.sd.service.compose/gradle.properties +++ b/tokens/plasma.sd.service.compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=plasma-sd-service-compose nexus.snapshot=false nexus.description=plasma-sd-service library for compose framework versionMajor=0 -versionMinor=47 +versionMinor=48 versionPatch=0 # ?????? plasma_b2c_ACTUAL_TYPOGRAPHY diff --git a/tokens/plasma.sd.service.view/docs/override-docs/versionsArchived.json b/tokens/plasma.sd.service.view/docs/override-docs/versionsArchived.json index 44d4aa1be..faa3f7aeb 100644 --- a/tokens/plasma.sd.service.view/docs/override-docs/versionsArchived.json +++ b/tokens/plasma.sd.service.view/docs/override-docs/versionsArchived.json @@ -30,5 +30,6 @@ "0.38.0": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.38.0/", "0.39.0": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.39.0/", "0.40.0": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.40.0/", - "0.41.0": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.41.0/" + "0.41.0": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.41.0/", + "0.42.0": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.42.0/" } \ No newline at end of file diff --git a/tokens/plasma.sd.service.view/gradle.properties b/tokens/plasma.sd.service.view/gradle.properties index 96defa4c1..063afb446 100644 --- a/tokens/plasma.sd.service.view/gradle.properties +++ b/tokens/plasma.sd.service.view/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=plasma-sd-service nexus.snapshot=false nexus.description=plasma-sd-service theme library for view framework versionMajor=0 -versionMinor=42 +versionMinor=43 versionPatch=0 theme-version=0.1.0 diff --git a/tokens/sdds-finai-compose/docs/override-docs/versionsArchived.json b/tokens/sdds-finai-compose/docs/override-docs/versionsArchived.json index 0407a0b2b..d2ab09489 100644 --- a/tokens/sdds-finai-compose/docs/override-docs/versionsArchived.json +++ b/tokens/sdds-finai-compose/docs/override-docs/versionsArchived.json @@ -3,5 +3,6 @@ "0.1.1": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.1.1/", "0.2.0": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.2.0/", "0.3.0": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.3.0/", - "0.4.0": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.4.0/" + "0.4.0": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.4.0/", + "0.5.0": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.5.0/" } \ No newline at end of file diff --git a/tokens/sdds-finai-compose/gradle.properties b/tokens/sdds-finai-compose/gradle.properties index 180f83dbb..974f3b999 100644 --- a/tokens/sdds-finai-compose/gradle.properties +++ b/tokens/sdds-finai-compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=sdds-finai-compose nexus.snapshot=false nexus.description=sdds finai library for compose framework versionMajor=0 -versionMinor=5 +versionMinor=6 versionPatch=0 theme-version=0.8.1 diff --git a/tokens/sdds-sbcom-compose/docs/override-docs/versionsArchived.json b/tokens/sdds-sbcom-compose/docs/override-docs/versionsArchived.json index 52c422b87..ceded5c9f 100644 --- a/tokens/sdds-sbcom-compose/docs/override-docs/versionsArchived.json +++ b/tokens/sdds-sbcom-compose/docs/override-docs/versionsArchived.json @@ -18,5 +18,6 @@ "0.12.1": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.12.1/", "0.13.0": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.13.0/", "0.14.0": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.14.0/", - "0.15.0": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.15.0/" + "0.15.0": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.15.0/", + "0.16.0": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.16.0/" } \ No newline at end of file diff --git a/tokens/sdds-sbcom-compose/gradle.properties b/tokens/sdds-sbcom-compose/gradle.properties index d186bee2d..36e58c17c 100644 --- a/tokens/sdds-sbcom-compose/gradle.properties +++ b/tokens/sdds-sbcom-compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=sdds-sbcom-compose nexus.snapshot=false nexus.description=sdds sbcom library for compose framework versionMajor=0 -versionMinor=16 +versionMinor=17 versionPatch=0 components-version=0.9.0 diff --git a/tokens/sdds.serv.compose/docs/override-docs/versionsArchived.json b/tokens/sdds.serv.compose/docs/override-docs/versionsArchived.json index 7172dc4a1..c3769f8a0 100644 --- a/tokens/sdds.serv.compose/docs/override-docs/versionsArchived.json +++ b/tokens/sdds.serv.compose/docs/override-docs/versionsArchived.json @@ -35,5 +35,6 @@ "0.42.1": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.42.1/", "0.43.0": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.43.0/", "0.44.0": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.44.0/", - "0.45.0": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.45.0/" + "0.45.0": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.45.0/", + "0.46.0": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.46.0/" } \ No newline at end of file diff --git a/tokens/sdds.serv.compose/gradle.properties b/tokens/sdds.serv.compose/gradle.properties index 11673c036..448c67b0e 100644 --- a/tokens/sdds.serv.compose/gradle.properties +++ b/tokens/sdds.serv.compose/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=sdds-serv-compose nexus.snapshot=false nexus.description=sdds-serv token library for compose framework versionMajor=0 -versionMinor=46 +versionMinor=47 versionPatch=0 theme-version=0.9.0 diff --git a/tokens/sdds.serv.view/docs/override-docs/versionsArchived.json b/tokens/sdds.serv.view/docs/override-docs/versionsArchived.json index cd5a8cc6d..3fda0264d 100644 --- a/tokens/sdds.serv.view/docs/override-docs/versionsArchived.json +++ b/tokens/sdds.serv.view/docs/override-docs/versionsArchived.json @@ -30,5 +30,6 @@ "0.42.0": "https://plasma.sberdevices.ru/xml/sdds-serv/0.42.0/", "0.43.0": "https://plasma.sberdevices.ru/xml/sdds-serv/0.43.0/", "0.44.0": "https://plasma.sberdevices.ru/xml/sdds-serv/0.44.0/", - "0.45.0": "https://plasma.sberdevices.ru/xml/sdds-serv/0.45.0/" + "0.45.0": "https://plasma.sberdevices.ru/xml/sdds-serv/0.45.0/", + "0.46.0": "https://plasma.sberdevices.ru/xml/sdds-serv/0.46.0/" } \ No newline at end of file diff --git a/tokens/sdds.serv.view/gradle.properties b/tokens/sdds.serv.view/gradle.properties index dd4d1f77f..03b85698f 100644 --- a/tokens/sdds.serv.view/gradle.properties +++ b/tokens/sdds.serv.view/gradle.properties @@ -2,7 +2,7 @@ nexus.artifactId=sdds-serv nexus.snapshot=false nexus.description=sdds-serv token library for view framework versionMajor=0 -versionMinor=46 +versionMinor=47 versionPatch=0 theme-version=0.9.0 diff --git a/tokens/summary.json b/tokens/summary.json index a821c8a0c..a4954f05f 100644 --- a/tokens/summary.json +++ b/tokens/summary.json @@ -2,13 +2,13 @@ "plasmaStarDS": { "viewSystem": { "title": "XML", - "version": "2.43.0", + "version": "2.44.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.43.0/" + "href": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.44.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.43.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/plasma-star-ds/2.44.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/plasma-star-ds/changelog.json" @@ -17,13 +17,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.41.0", + "version": "0.42.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.41.0/" + "href": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.42.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.41.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/0.42.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/plasma-star-ds-compose/changelog.json" @@ -34,13 +34,13 @@ "plasmaGigaApp": { "viewSystem": { "title": "XML", - "version": "0.39.0", + "version": "0.40.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/plasma-giga-compose/0.39.0/" + "href": "https://plasma.sberdevices.ru/xml/plasma-giga-compose/0.40.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/plasma-giga-compose/0.39.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/plasma-giga-compose/0.40.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/plasma-giga-compose/changelog.json" @@ -49,13 +49,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.39.0", + "version": "0.40.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.39.0/" + "href": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.40.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.39.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/0.40.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/plasma-giga-compose/changelog.json" @@ -66,13 +66,13 @@ "plasmaHomeDS": { "viewSystem": { "title": "XML", - "version": "0.31.0", + "version": "0.32.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/plasma-homeds-compose/0.31.0/" + "href": "https://plasma.sberdevices.ru/xml/plasma-homeds-compose/0.32.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/plasma-homeds-compose/0.31.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/plasma-homeds-compose/0.32.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/plasma-homeds-compose/changelog.json" @@ -81,13 +81,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.31.0", + "version": "0.32.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.31.0/" + "href": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.32.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.31.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/0.32.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/plasma-homeds-compose/changelog.json" @@ -98,13 +98,13 @@ "plasmaSDService": { "viewSystem": { "title": "XML", - "version": "0.42.0", + "version": "0.43.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.42.0/" + "href": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.43.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.42.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/plasma-sd-service/0.43.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/plasma-sd-service/changelog.json" @@ -113,13 +113,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.47.0", + "version": "0.48.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.47.0/" + "href": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.48.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.47.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/0.48.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/plasma-sd-service-compose/changelog.json" @@ -130,13 +130,13 @@ "sdds-finai": { "viewSystem": { "title": "XML", - "version": "0.5.0", + "version": "0.6.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/sdds-finai-compose/0.5.0/" + "href": "https://plasma.sberdevices.ru/xml/sdds-finai-compose/0.6.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/sdds-finai-compose/0.5.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/sdds-finai-compose/0.6.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/sdds-finai-compose/changelog.json" @@ -145,13 +145,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.5.0", + "version": "0.6.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.5.0/" + "href": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.6.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.5.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/0.6.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/sdds-finai-compose/changelog.json" @@ -162,13 +162,13 @@ "sdds-sbcom": { "viewSystem": { "title": "XML", - "version": "0.16.0", + "version": "0.17.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/sdds-sbcom-compose/0.16.0/" + "href": "https://plasma.sberdevices.ru/xml/sdds-sbcom-compose/0.17.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/sdds-sbcom-compose/0.16.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/sdds-sbcom-compose/0.17.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/sdds-sbcom-compose/changelog.json" @@ -177,13 +177,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.16.0", + "version": "0.17.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.16.0/" + "href": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.17.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.16.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/0.17.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/sdds-sbcom-compose/changelog.json" @@ -194,13 +194,13 @@ "SDDSService": { "viewSystem": { "title": "XML", - "version": "0.46.0", + "version": "0.47.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/xml/sdds-serv/0.46.0/" + "href": "https://plasma.sberdevices.ru/xml/sdds-serv/0.47.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/xml/sdds-serv/0.46.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/xml/sdds-serv/0.47.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/xml/sdds-serv/changelog.json" @@ -209,13 +209,13 @@ }, "composeUi": { "title": "Compose UI", - "version": "0.46.0", + "version": "0.47.0", "links": { "documentation": { - "href": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.46.0/" + "href": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.47.0/" }, "changelog": { - "href": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.46.0/CHANGELOG/" + "href": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/0.47.0/CHANGELOG/" }, "changelogData": { "href": "https://plasma.sberdevices.ru/compose/sdds-serv-compose/changelog.json" From 746d051933f5dfcb03001cced692279d3ee4feeb Mon Sep 17 00:00:00 2001 From: Sergey Velesko <53015097+raininforest@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:28:54 +0300 Subject: [PATCH 02/10] feat: compose preview web (#942) --- .github/scripts/check-preview-contract.sh | 41 + .github/workflows/check-preview-contract.yml | 40 + .gitignore | 3 + .ignore-changed-modules | 3 + .../preview-compose-plugin/POC_RESULTS.md | 61 + .../preview-compose-plugin/README.md | 260 +++ .../preview-compose-plugin/build.gradle.kts | 231 +++ .../src/browserTest/png.mjs | 74 + .../browserTest/resources/browser-test.html | 341 ++++ .../resources/canonical-basic-button.json | 70 + .../src/browserTest/resources/gallery.html | 200 +++ .../src/browserTest/run-browser-test.mjs | 409 +++++ .../compose/plugin/PendingRenderResults.kt | 37 + .../plugin/PreviewRequestCoordinator.kt | 94 ++ .../plugin/BasicButtonDescriptionTest.kt | 44 + .../compose/plugin/ComponentNotFoundTest.kt | 44 + .../plugin/PendingRenderResultsTest.kt | 57 + .../plugin/PreviewRequestCoordinatorTest.kt | 151 ++ .../plugin/StoryRegistryEntriesTest.kt | 76 + .../src/icons/preview-icons.txt | 26 + .../compose/plugin/PreviewComponentGroup.kt | 56 + .../compose/plugin/ComposeTestSupport.kt | 30 + .../plugin/GeneratedFactoryValidationTest.kt | 74 + .../plugin/GeneratedFactoryValueKindsTest.kt | 120 ++ .../compose/plugin/HostDefaultsTest.kt | 88 + .../plugin/PreviewComponentsFromTest.kt | 56 + .../compose/plugin/PreviewGroupsTest.kt | 60 + .../plugin/ReferenceStyleComparisonTest.kt | 244 +++ .../plugin/StyleFactoryCoverageTest.kt | 91 + .../src/manifest/preview-plugin.template.json | 18 + .../plugin/HttpPreviewAssetResolver.kt | 27 + .../com/sdds/preview/compose/plugin/Main.kt | 165 ++ .../src/wasmJsMain/resources/index.html | 257 +++ integration-core/preview-contract/README.md | 42 + .../preview-contract/build.gradle.kts | 29 + .../contract/PreviewComponentDescription.kt | 118 ++ .../sdds/preview/contract/PreviewContract.kt | 308 ++++ ...nt-description-result-invalid-default.json | 12 + .../preview-payload-invalid-font-asset.json | 9 + .../preview-payload-invalid-property.json | 8 + .../preview-payload-missing-component.json | 1 + .../negative/preview-result-invalid-type.json | 1 + .../preview-component-description-result.json | 21 + .../fixtures/v1/positive/preview-payload.json | 27 + .../preview-result-with-warnings.json | 1 + .../fixtures/v1/positive/preview-result.json | 1 + .../v1/shared/invalid/alpha-on-style-ref.json | 35 + .../v1/shared/invalid/alpha-out-of-range.json | 35 + .../v1/shared/invalid/duplicate-when.json | 45 + .../v1/shared/invalid/empty-when.json | 42 + .../v1/shared/invalid/gradient-one-stop.json | 49 + .../shared/invalid/shadow-without-spread.json | 45 + .../v1/shared/invalid/states-as-map.json | 39 + .../v1/shared/invalid/unknown-asset-type.json | 40 + .../invalid/wrong-protocol-version.json | 34 + .../v1/shared/valid/alpha-literal.json | 35 + .../v1/shared/valid/asset-ref-svg.json | 47 + .../valid/gradient-and-shadow-spread.json | 65 + .../fixtures/v1/shared/valid/minimal.json | 34 + .../fixtures/v1/shared/valid/state-set.json | 64 + .../fixtures/v1/shared/valid/style-ref.json | 53 + .../commonMain/resources/schemas/v1/DIGEST | 16 + ...w-component-description-result.schema.json | 104 ++ .../schemas/v1/preview-payload.schema.json | 559 ++++++ .../schemas/v1/preview-result.schema.json | 77 + .../preview/contract/PreviewContractTest.kt | 47 + .../preview/contract/SchemaContractTest.kt | 135 ++ .../preview-sdk-compose/README.md | 8 + .../preview-sdk-compose/build.gradle.kts | 26 + .../preview/compose/ComposePreviewRuntime.kt | 231 +++ .../compose/ComposePreviewStyleFactory.kt | 26 + .../com/sdds/preview/compose/ExampleState.kt | 35 + .../preview/compose/GeneratedStyleSupport.kt | 255 +++ .../sdds/preview/compose/PayloadValidation.kt | 174 ++ .../preview/compose/PreparedComposeTheme.kt | 192 +++ .../com/sdds/preview/compose/PreviewAssets.kt | 67 + .../preview/compose/PreviewHostDefaults.kt | 83 + .../com/sdds/preview/compose/PreviewIcons.kt | 29 + .../compose/PreviewPreparationException.kt | 8 + .../sdds/preview/compose/PreviewStateOwner.kt | 24 + .../sdds/preview/compose/PropertyValues.kt | 74 + .../compose/CachingFontFamilyFactoryTest.kt | 45 + .../compose/ComponentDescriptionTest.kt | 34 + .../compose/ComposePreviewRuntimeTest.kt | 87 + .../sdds/preview/compose/ExampleStateTest.kt | 43 + .../preview/compose/FontPreparationTest.kt | 67 + .../sdds/preview/compose/GradientAlphaTest.kt | 67 + .../compose/NestedStyleValidationTest.kt | 117 ++ .../preview/compose/PayloadValidationTest.kt | 166 ++ .../compose/PreparedComposeThemeTest.kt | 89 + .../sdds/preview/compose/PreviewAssetsTest.kt | 51 + .../sdds/preview/compose/PreviewIconsTest.kt | 81 + .../preview/compose/PreviewStateMapTest.kt | 46 + .../preview/compose/PreviewStateOwnerTest.kt | 28 + .../preview/compose/PropertyValuesTest.kt | 65 + .../preview/compose/StatefulValueOrderTest.kt | 46 + .../sdds/preview/compose/WarningRulesTest.kt | 92 + .../com/sdds/preview/compose/WasmFonts.kt | 25 + .../sdds/compose/sandbox/ComposeBaseStory.kt | 129 -- .../com/sdds/compose/sandbox/ComposeStory.kt | 31 + .../sdds/compose/sandbox/internal/MenuItem.kt | 1 + .../sandbox-core/build.gradle.kts | 4 +- .../sdds/compose/sandbox/ComposeBaseStory.kt | 57 + .../kotlin/com/sdds/sandbox/BaseStory.kt | 8 +- .../kotlin/com/sdds/sandbox/StoryEntry.kt | 17 + .../kotlin/com/sdds/sandbox/StoryRegistry.kt | 35 + integration-core/sandbox-ksp/build.gradle.kts | 1 + .../ksp/internal/ComponentIdValidation.kt | 22 + .../ksp/internal/SandboxStoryProcessor.kt | 38 + .../sdds/sandbox/ksp/internal/StoryData.kt | 4 + .../sandbox/ksp/internal/StoryDeclaration.kt | 7 + .../ksp/internal/StoryRegistryGenerator.kt | 12 +- .../ksp/internal/ComponentIdValidationTest.kt | 55 + integration-core/settings.gradle.kts | 3 + .../stories/accordion/AccordionStory.kt | 2 +- .../accordionitem/AccordionItemStory.kt | 58 + .../stories/aiheader/AiHeaderStory.kt | 2 +- .../fixtures/stories/aiinput/AiInputStory.kt | 2 +- .../aiusermessage/AiUserMessageStory.kt | 2 +- .../fixtures/stories/answer/AiAnswerStory.kt | 2 +- .../stories/autocomplete/AutocompleteStory.kt | 2 +- .../stories/avatar/AvatarGroupStory.kt | 2 +- .../fixtures/stories/avatar/AvatarStory.kt | 2 +- .../fixtures/stories/badge/BadgeStory.kt | 2 +- .../stories/bottomsheet/BottomSheetStory.kt | 2 +- .../stories/button/ButtonGroupStory.kt | 2 +- .../fixtures/stories/button/ButtonStory.kt | 2 +- .../uikit/fixtures/stories/card/CardStory.kt | 2 +- .../stories/carousel/CarouselStory.kt | 2 +- .../uikit/fixtures/stories/cell/CellStory.kt | 2 +- .../stories/checkbox/CheckBoxStory.kt | 2 +- .../checkboxgroup/CheckBoxGroupStory.kt | 2 +- .../uikit/fixtures/stories/chip/ChipStory.kt | 2 +- .../stories/chipgroup/ChipGroupStory.kt | 2 +- .../circularprogress/CircularProgressStory.kt | 2 +- .../stories/codefield/CodeFieldStory.kt | 2 +- .../stories/codeinput/CodeInputStory.kt | 2 +- .../CollapsingNavigationBarStory.kt | 2 +- .../stories/combobox/ComboBoxStory.kt | 2 +- .../fixtures/stories/counter/CounterStory.kt | 2 +- .../fixtures/stories/divider/DividerStory.kt | 2 +- .../fixtures/stories/drawer/DrawerStory.kt | 2 +- .../DropdownEmptyStateStory.kt | 82 + .../DropdownLoadingStateStory.kt | 62 + .../stories/dropdownmenu/DropdownMenuStory.kt | 2 +- .../stories/dropzone/DropZoneStory.kt | 2 +- .../stories/editable/EditableStory.kt | 2 +- .../uikit/fixtures/stories/file/FileStory.kt | 2 +- .../fixtures/stories/form/FormItemStory.kt | 2 +- .../stories/iconbadge/IconBadgeStory.kt | 2 +- .../stories/iconbutton/IconButtonStory.kt | 2 +- .../stories/icontabitem/IconTabItemStory.kt | 60 + .../fixtures/stories/image/ImageStory.kt | 2 +- .../stories/indicator/IndicatorStory.kt | 2 +- .../stories/linkbutton/LinkButtonStory.kt | 2 +- .../uikit/fixtures/stories/list/ListStory.kt | 2 +- .../stories/listitem/ListItemStory.kt | 71 + .../fixtures/stories/loader/LoaderStory.kt | 2 +- .../fixtures/stories/modal/ModalStory.kt | 2 +- .../navigationbar/NavigationBarStory.kt | 2 +- .../navigationdrawer/NavigationDrawerStory.kt | 2 +- .../NavigationDrawerItemStory.kt | 68 + .../uikit/fixtures/stories/note/NoteStory.kt | 4 +- .../stories/notification/NotificationStory.kt | 2 +- .../NotificationContentStory.kt | 2 +- .../fixtures/stories/overlay/OverlayStory.kt | 2 +- .../paginationdots/PaginationDotsStory.kt | 2 +- .../fixtures/stories/popover/PopoverStory.kt | 2 +- .../stories/progress/ProgressStory.kt | 2 +- .../stories/radiobox/RadioBoxStory.kt | 2 +- .../radioboxgroup/RadioBoxGroupStory.kt | 2 +- .../stories/rectskeleton/RectSkeletonStory.kt | 2 +- .../stories/scrollbar/ScrollBarStory.kt | 2 +- .../fixtures/stories/segment/SegmentStory.kt | 2 +- .../stories/segmentitem/SegmentItemStory.kt | 2 +- .../fixtures/stories/select/SelectStory.kt | 2 +- .../stories/selectitem/SelectItemStory.kt | 72 + .../fixtures/stories/slider/SliderStory.kt | 2 +- .../fixtures/stories/spinner/SpinnerStory.kt | 2 +- .../stories/splitter/SplitterStory.kt | 2 +- .../fixtures/stories/switcher/SwitchStory.kt | 2 +- .../fixtures/stories/tabbar/TabBarStory.kt | 2 +- .../stories/tabbaritem/TabBarItemStory.kt | 66 + .../fixtures/stories/tabitem/TabItemStory.kt | 70 + .../uikit/fixtures/stories/tabs/TabsStory.kt | 2 +- .../stories/textfield/TextAreaStory.kt | 2 +- .../stories/textfield/TextFieldStory.kt | 2 +- .../stories/textskeleton/TextSkeletonStory.kt | 21 +- .../fixtures/stories/toast/ToastStory.kt | 2 +- .../fixtures/stories/toolbar/ToolBarStory.kt | 2 +- .../fixtures/stories/tooltip/TooltipStory.kt | 2 +- .../fixtures/stories/wheel/WheelStory.kt | 2 +- .../.openspec.yaml | 2 + .../2026-07-22-add-preview-contract/design.md | 42 + .../proposal.md | 27 + .../specs/preview-contract/spec.md | 54 + .../2026-07-22-add-preview-contract/tasks.md | 28 + .../.openspec.yaml | 2 + .../design.md | 169 ++ .../proposal.md | 47 + .../compose-preview-browser-bridge/spec.md | 114 ++ .../tasks.md | 59 + .../.openspec.yaml | 2 + .../2026-07-23-preview-sdk-compose/design.md | 170 ++ .../proposal.md | 35 + .../specs/compose-preview-sdk/spec.md | 99 ++ .../specs/preview-contract/spec.md | 49 + .../sandbox-compose-multiplatform/spec.md | 32 + .../2026-07-23-preview-sdk-compose/tasks.md | 62 + .../.openspec.yaml | 2 + .../design.md | 238 +++ .../proposal.md | 64 + .../compose-preview-browser-bridge/spec.md | 40 + .../compose-preview-story-description/spec.md | 45 + .../specs/compose-preview-viewport/spec.md | 34 + .../preview-component-description/spec.md | 44 + .../tasks.md | 57 + .../.openspec.yaml | 2 + .../design.md | 30 + .../proposal.md | 26 + .../specs/compose-preview-sdk/spec.md | 58 + .../specs/preview-contract/spec.md | 126 ++ .../tasks.md | 23 + .../.openspec.yaml | 2 + .../design.md | 243 +++ .../proposal.md | 61 + .../spec.md | 108 ++ .../specs/compose-preview-sdk/spec.md | 32 + .../compose-preview-style-generation/spec.md | 276 +++ .../tasks.md | 54 + .../design.md | 42 + .../proposal.md | 32 + .../compose-preview-browser-bridge/spec.md | 47 + .../specs/compose-preview-sdk/spec.md | 18 + .../tasks.md | 11 + .../design.md | 35 + .../proposal.md | 33 + .../specs/compose-preview-icons/spec.md | 23 + .../compose-preview-style-generation/spec.md | 33 + .../tasks.md | 12 + .../design.md | 25 + .../proposal.md | 28 + .../compose-preview-gradient-alpha/spec.md | 21 + .../tasks.md | 9 + .../design.md | 15 + .../proposal.md | 28 + .../compose-preview-host-defaults/spec.md | 21 + .../tasks.md | 11 + .../design.md | 23 + .../proposal.md | 28 + .../compose-preview-component-groups/spec.md | 23 + .../tasks.md | 9 + .../design.md | 45 + .../proposal.md | 45 + .../compose-preview-style-generation/spec.md | 56 + .../tasks.md | 24 + .../compose-preview-browser-bridge/spec.md | 191 +++ .../compose-preview-component-groups/spec.md | 27 + .../spec.md | 112 ++ .../compose-preview-gradient-alpha/spec.md | 25 + .../compose-preview-host-defaults/spec.md | 25 + openspec/specs/compose-preview-icons/spec.md | 27 + openspec/specs/compose-preview-sdk/spec.md | 146 ++ .../compose-preview-story-description/spec.md | 49 + .../compose-preview-style-generation/spec.md | 308 ++++ .../specs/compose-preview-viewport/spec.md | 38 + .../preview-component-description/spec.md | 48 + openspec/specs/preview-contract/spec.md | 164 ++ .../sandbox-compose-multiplatform/spec.md | 22 +- .../plugin/themebuilder/DsBuilderExtension.kt | 28 + .../plugin/themebuilder/DsBuilderPlugin.kt | 14 + .../themebuilder/UikitComposeApiMetaTask.kt | 40 +- .../universal/compose/ComposeComponentMeta.kt | 6 + .../universal/compose/OverloadRules.kt | 38 + .../UniversalComposeVariationGenerator.kt | 30 - .../preview/GenerateComposePreviewTask.kt | 63 + .../preview/PreviewFactoryGenerator.kt | 424 +++++ .../themebuilder/preview/PreviewOverloads.kt | 40 + .../preview/GenerateComposePreviewTaskTest.kt | 124 ++ .../AvatarGroupPreviewStyleFactory.kt.txt | 64 + .../BasicButtonPreviewStyleFactory.kt.txt | 93 + .../CheckBoxPreviewStyleFactory.kt.txt | 98 ++ .../GeneratedPreviewStyleFactories.kt.txt | 14 + .../generated/TabsPreviewStyleFactory.kt.txt | 102 ++ .../preview/golden/components.txt.txt | 4 + .../uikit-compose-api-meta-sample.json | 1497 +++++++++++++++++ 286 files changed, 16163 insertions(+), 252 deletions(-) create mode 100755 .github/scripts/check-preview-contract.sh create mode 100644 .github/workflows/check-preview-contract.yml create mode 100644 integration-core/preview-compose-plugin/POC_RESULTS.md create mode 100644 integration-core/preview-compose-plugin/README.md create mode 100644 integration-core/preview-compose-plugin/build.gradle.kts create mode 100644 integration-core/preview-compose-plugin/src/browserTest/png.mjs create mode 100644 integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html create mode 100644 integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json create mode 100644 integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html create mode 100644 integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs create mode 100644 integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt create mode 100644 integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt create mode 100644 integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt create mode 100644 integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt create mode 100644 integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt create mode 100644 integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt create mode 100644 integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt create mode 100644 integration-core/preview-compose-plugin/src/icons/preview-icons.txt create mode 100644 integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt create mode 100644 integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt create mode 100644 integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json create mode 100644 integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt create mode 100644 integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt create mode 100644 integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html create mode 100644 integration-core/preview-contract/README.md create mode 100644 integration-core/preview-contract/build.gradle.kts create mode 100644 integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt create mode 100644 integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST create mode 100644 integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json create mode 100644 integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json create mode 100644 integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt create mode 100644 integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt create mode 100644 integration-core/preview-sdk-compose/README.md create mode 100644 integration-core/preview-sdk-compose/build.gradle.kts create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewStyleFactory.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ExampleState.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/GeneratedStyleSupport.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PayloadValidation.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PreparedComposeTheme.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PreviewAssets.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PreviewHostDefaults.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PreviewIcons.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PreviewPreparationException.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PreviewStateOwner.kt create mode 100644 integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/PropertyValues.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/CachingFontFamilyFactoryTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/ComponentDescriptionTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/ComposePreviewRuntimeTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/ExampleStateTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/FontPreparationTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/GradientAlphaTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/NestedStyleValidationTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PayloadValidationTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PreparedComposeThemeTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PreviewAssetsTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PreviewIconsTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PreviewStateMapTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PreviewStateOwnerTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/PropertyValuesTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/StatefulValueOrderTest.kt create mode 100644 integration-core/preview-sdk-compose/src/commonTest/kotlin/com/sdds/preview/compose/WarningRulesTest.kt create mode 100644 integration-core/preview-sdk-compose/src/wasmJsMain/kotlin/com/sdds/preview/compose/WasmFonts.kt delete mode 100644 integration-core/sandbox-compose/src/commonMain/kotlin/com/sdds/compose/sandbox/ComposeBaseStory.kt create mode 100644 integration-core/sandbox-compose/src/commonMain/kotlin/com/sdds/compose/sandbox/ComposeStory.kt create mode 100644 integration-core/sandbox-core/src/commonMain/kotlin/com/sdds/compose/sandbox/ComposeBaseStory.kt create mode 100644 integration-core/sandbox-core/src/commonMain/kotlin/com/sdds/sandbox/StoryEntry.kt create mode 100644 integration-core/sandbox-ksp/src/main/kotlin/com/sdds/sandbox/ksp/internal/ComponentIdValidation.kt create mode 100644 integration-core/sandbox-ksp/src/main/kotlin/com/sdds/sandbox/ksp/internal/StoryDeclaration.kt create mode 100644 integration-core/sandbox-ksp/src/test/kotlin/com/sdds/sandbox/ksp/internal/ComponentIdValidationTest.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/accordionitem/AccordionItemStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/dropdownemptystate/DropdownEmptyStateStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/dropdownloadingstate/DropdownLoadingStateStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/icontabitem/IconTabItemStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/listitem/ListItemStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/navigationdraweritem/NavigationDrawerItemStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/selectitem/SelectItemStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/tabbaritem/TabBarItemStory.kt create mode 100644 integration-core/uikit-compose-fixtures/src/commonMain/kotlin/com/sdds/compose/uikit/fixtures/stories/tabitem/TabItemStory.kt create mode 100644 openspec/changes/archive/2026-07-22-add-preview-contract/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-22-add-preview-contract/design.md create mode 100644 openspec/changes/archive/2026-07-22-add-preview-contract/proposal.md create mode 100644 openspec/changes/archive/2026-07-22-add-preview-contract/specs/preview-contract/spec.md create mode 100644 openspec/changes/archive/2026-07-22-add-preview-contract/tasks.md create mode 100644 openspec/changes/archive/2026-07-23-complete-compose-preview-browser-bridge/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-23-complete-compose-preview-browser-bridge/design.md create mode 100644 openspec/changes/archive/2026-07-23-complete-compose-preview-browser-bridge/proposal.md create mode 100644 openspec/changes/archive/2026-07-23-complete-compose-preview-browser-bridge/specs/compose-preview-browser-bridge/spec.md create mode 100644 openspec/changes/archive/2026-07-23-complete-compose-preview-browser-bridge/tasks.md create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/design.md create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/proposal.md create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/specs/compose-preview-sdk/spec.md create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/specs/preview-contract/spec.md create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/specs/sandbox-compose-multiplatform/spec.md create mode 100644 openspec/changes/archive/2026-07-23-preview-sdk-compose/tasks.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/design.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/proposal.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/specs/compose-preview-browser-bridge/spec.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/specs/compose-preview-story-description/spec.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/specs/compose-preview-viewport/spec.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/specs/preview-component-description/spec.md create mode 100644 openspec/changes/archive/2026-07-27-refine-compose-preview-viewport-and-story-properties/tasks.md create mode 100644 openspec/changes/archive/2026-10-12-extend-preview-payload/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-12-extend-preview-payload/design.md create mode 100644 openspec/changes/archive/2026-10-12-extend-preview-payload/proposal.md create mode 100644 openspec/changes/archive/2026-10-12-extend-preview-payload/specs/compose-preview-sdk/spec.md create mode 100644 openspec/changes/archive/2026-10-12-extend-preview-payload/specs/preview-contract/spec.md create mode 100644 openspec/changes/archive/2026-10-12-extend-preview-payload/tasks.md create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/design.md create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/proposal.md create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/specs/compose-preview-component-registry/spec.md create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/specs/compose-preview-sdk/spec.md create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/specs/compose-preview-style-generation/spec.md create mode 100644 openspec/changes/archive/2026-10-14-compose-preview-core-components/tasks.md create mode 100644 openspec/changes/archive/2026-10-14-confirm-preview-render/design.md create mode 100644 openspec/changes/archive/2026-10-14-confirm-preview-render/proposal.md create mode 100644 openspec/changes/archive/2026-10-14-confirm-preview-render/specs/compose-preview-browser-bridge/spec.md create mode 100644 openspec/changes/archive/2026-10-14-confirm-preview-render/specs/compose-preview-sdk/spec.md create mode 100644 openspec/changes/archive/2026-10-14-confirm-preview-render/tasks.md create mode 100644 openspec/changes/archive/2026-10-15-add-compose-preview-icons/design.md create mode 100644 openspec/changes/archive/2026-10-15-add-compose-preview-icons/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-add-compose-preview-icons/specs/compose-preview-icons/spec.md create mode 100644 openspec/changes/archive/2026-10-15-add-compose-preview-icons/specs/compose-preview-style-generation/spec.md create mode 100644 openspec/changes/archive/2026-10-15-add-compose-preview-icons/tasks.md create mode 100644 openspec/changes/archive/2026-10-15-apply-alpha-to-gradient-properties/design.md create mode 100644 openspec/changes/archive/2026-10-15-apply-alpha-to-gradient-properties/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-apply-alpha-to-gradient-properties/specs/compose-preview-gradient-alpha/spec.md create mode 100644 openspec/changes/archive/2026-10-15-apply-alpha-to-gradient-properties/tasks.md create mode 100644 openspec/changes/archive/2026-10-15-apply-host-defaults-in-plugin/design.md create mode 100644 openspec/changes/archive/2026-10-15-apply-host-defaults-in-plugin/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-apply-host-defaults-in-plugin/specs/compose-preview-host-defaults/spec.md create mode 100644 openspec/changes/archive/2026-10-15-apply-host-defaults-in-plugin/tasks.md create mode 100644 openspec/changes/archive/2026-10-15-expose-component-groups-in-manifest/design.md create mode 100644 openspec/changes/archive/2026-10-15-expose-component-groups-in-manifest/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-expose-component-groups-in-manifest/specs/compose-preview-component-groups/spec.md create mode 100644 openspec/changes/archive/2026-10-15-expose-component-groups-in-manifest/tasks.md create mode 100644 openspec/changes/archive/2026-10-16-move-preview-generation-to-ds-builder-plugin/design.md create mode 100644 openspec/changes/archive/2026-10-16-move-preview-generation-to-ds-builder-plugin/proposal.md create mode 100644 openspec/changes/archive/2026-10-16-move-preview-generation-to-ds-builder-plugin/specs/compose-preview-style-generation/spec.md create mode 100644 openspec/changes/archive/2026-10-16-move-preview-generation-to-ds-builder-plugin/tasks.md create mode 100644 openspec/specs/compose-preview-browser-bridge/spec.md create mode 100644 openspec/specs/compose-preview-component-groups/spec.md create mode 100644 openspec/specs/compose-preview-component-registry/spec.md create mode 100644 openspec/specs/compose-preview-gradient-alpha/spec.md create mode 100644 openspec/specs/compose-preview-host-defaults/spec.md create mode 100644 openspec/specs/compose-preview-icons/spec.md create mode 100644 openspec/specs/compose-preview-sdk/spec.md create mode 100644 openspec/specs/compose-preview-story-description/spec.md create mode 100644 openspec/specs/compose-preview-style-generation/spec.md create mode 100644 openspec/specs/compose-preview-viewport/spec.md create mode 100644 openspec/specs/preview-component-description/spec.md create mode 100644 openspec/specs/preview-contract/spec.md create mode 100644 sdds-core/plugin_theme_builder/src/main/kotlin/com/sdds/plugin/themebuilder/internal/universal/compose/OverloadRules.kt create mode 100644 sdds-core/plugin_theme_builder/src/main/kotlin/com/sdds/plugin/themebuilder/preview/GenerateComposePreviewTask.kt create mode 100644 sdds-core/plugin_theme_builder/src/main/kotlin/com/sdds/plugin/themebuilder/preview/PreviewFactoryGenerator.kt create mode 100644 sdds-core/plugin_theme_builder/src/main/kotlin/com/sdds/plugin/themebuilder/preview/PreviewOverloads.kt create mode 100644 sdds-core/plugin_theme_builder/src/test/kotlin/com/sdds/plugin/themebuilder/preview/GenerateComposePreviewTaskTest.kt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/golden/com/sdds/preview/compose/generated/AvatarGroupPreviewStyleFactory.kt.txt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/golden/com/sdds/preview/compose/generated/BasicButtonPreviewStyleFactory.kt.txt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/golden/com/sdds/preview/compose/generated/CheckBoxPreviewStyleFactory.kt.txt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/golden/com/sdds/preview/compose/generated/GeneratedPreviewStyleFactories.kt.txt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/golden/com/sdds/preview/compose/generated/TabsPreviewStyleFactory.kt.txt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/golden/components.txt.txt create mode 100644 sdds-core/plugin_theme_builder/src/test/resources/preview/uikit-compose-api-meta-sample.json diff --git a/.github/scripts/check-preview-contract.sh b/.github/scripts/check-preview-contract.sh new file mode 100755 index 000000000..3f11c6c7e --- /dev/null +++ b/.github/scripts/check-preview-contract.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Сверяет отпечатки копии Preview Protocol (integration-core/preview-contract) с каноническими из design-system-builder. +# +# Переменные: +# DSB_CONTRACT_REF ветка или тег design-system-builder (по умолчанию dev) +# CONTRACT_URL полный адрес CONTRACT.sha256 (для проверки скрипта) +# MODE warn - расхождение даёт предупреждение GitHub Actions, error - ошибку (по умолчанию error) +set -uo pipefail + +ref="${DSB_CONTRACT_REF:-dev}" +mode="${MODE:-error}" +digest="integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST" +url="${CONTRACT_URL:-https://raw.githubusercontent.com/salute-developers/design-system-builder/${ref}/backend-kt/preview/contract/CONTRACT.sha256}" + +report() { + if [ "$mode" = "warn" ]; then + echo "::warning title=Preview contract::$1" + exit 0 + fi + echo "::error title=Preview contract::$1" + exit 1 +} + +canonical="$(mktemp)" +if ! curl -fsSL --retry 3 "$url" -o "$canonical"; then + report "Не удалось скачать $url" +fi + +# В копии фикстуры лежат в fixtures/v1/shared, в каноническом каталоге - в fixtures/v1. +local_normalized="$(sed -E 's# fixtures/v1/shared/# fixtures/v1/#' "$digest" | LC_ALL=C sort -k2)" +canonical_sorted="$(LC_ALL=C sort -k2 "$canonical")" + +if [ "$local_normalized" = "$canonical_sorted" ]; then + echo "Копия Preview Protocol совпадает с design-system-builder@${ref}" + exit 0 +fi + +changed="$(diff <(echo "$canonical_sorted") <(echo "$local_normalized") | grep '^[<>]' | awk '{print $3}' | sort -u | tr '\n' ' ')" +echo "Расхождение с design-system-builder@${ref}:" +diff <(echo "$canonical_sorted") <(echo "$local_normalized") || true +report "Копия контракта отличается от design-system-builder@${ref}: ${changed}. Обновите: backend-kt/preview/contract/fingerprint.sh --sync-to integration-core/preview-contract/src/commonMain/resources" diff --git a/.github/workflows/check-preview-contract.yml b/.github/workflows/check-preview-contract.yml new file mode 100644 index 000000000..e2ee7ea84 --- /dev/null +++ b/.github/workflows/check-preview-contract.yml @@ -0,0 +1,40 @@ +name: 'Preview contract check' + +permissions: + contents: read + +# Копия схемы Preview Protocol должна совпадать с каноническим контрактом из design-system-builder. +# В pull request расхождение даёт предупреждение (копия может отставать, пока схема не влита в dev design-system-builder), +# в релизных ветках, main и в ночном запуске - ошибку. +on: + pull_request: + branches: + - main + - develop + - feature/* + - release/* + - hotfix/* + paths: + - 'integration-core/preview-contract/**' + - '.github/scripts/check-preview-contract.sh' + - '.github/workflows/check-preview-contract.yml' + push: + branches: + - main + - release/* + - hotfix/* + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + check: + name: Compare with design-system-builder + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Compare fingerprints + env: + DSB_CONTRACT_REF: ${{ vars.DSB_CONTRACT_REF || 'dev' }} + MODE: ${{ github.event_name == 'pull_request' && 'warn' || 'error' }} + run: .github/scripts/check-preview-contract.sh diff --git a/.gitignore b/.gitignore index 1f4813e5b..03030a6f6 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ google-services.json # Kotlin/JS local dependency store tokens/kotlin-js-store/ **/contents.xcworkspacedata + +# Артефакты сборки Kotlin/JS (Wasm) в integration-core +integration-core/kotlin-js-store/ diff --git a/.ignore-changed-modules b/.ignore-changed-modules index c926e490e..da1d2ecb2 100644 --- a/.ignore-changed-modules +++ b/.ignore-changed-modules @@ -8,3 +8,6 @@ :integration-core:uikit-compose-testcases :integration-core:uikit-fixtures :integration-core:uikit-testcases +:integration-core:preview-contract +:integration-core:preview-sdk-compose +:integration-core:preview-compose-plugin diff --git a/integration-core/preview-compose-plugin/POC_RESULTS.md b/integration-core/preview-compose-plugin/POC_RESULTS.md new file mode 100644 index 000000000..b5bf7afbc --- /dev/null +++ b/integration-core/preview-compose-plugin/POC_RESULTS.md @@ -0,0 +1,61 @@ +# Compose Preview Plugin + +## Сборка artifact + +```bash +./gradlew -p integration-core :preview-compose-plugin:previewPluginArtifact +``` + +Результат: `preview-compose-plugin/build/distributions/preview-compose-plugin.zip`. Архив содержит +HTML shell, manifest, production JavaScript и все Kotlin/Skiko Wasm binaries. + +## Подтверждено + +- Wasm executable регистрирует существующие stories через generated `registerStories()`. +- Полный JSON payload декодируется и проходит путь theme preparation → example state → + `BasicButtonPreviewStyleFactory` → `BasicButtonStory.Content`. +- Общий `ComposePreviewRuntime` связывает story и style factory через `previewComponent(...)`; + отдельные runtime/renderer/prepared-классы для каждого компонента не требуются. +- Экспортированная `submitPreviewPayload(json)` принимает следующий полный payload и заменяет + Compose state без перезапуска Wasm application. +- JVM test подтверждает замену `requestId` и `ButtonUiState` последовательными payload. +- Wasm target с byte-backed TTF/OTF factory успешно компилируется на Compose Multiplatform 1.8.2. + +## Поддержанные форматы + +- Font assets: TTF и OTF, integer weight, normal/italic. +- Font-family fallback: только явный `allowFallback=true`, результат — `FontFamily.Default`. +- BasicButton: поддержанный вручную набор root, color и dimension properties SDK. +- Example props: boolean, int, float, string и single-choice значения sandbox contracts. + +## Production browser-проверка + +`previewPluginBrowserTest` обслуживает распакованный production zip по HTTP и загружает plugin в +iframe Headless Chrome. Canonical полный `BasicButton` payload использует реальный +`s_b_sans_text_regular.otf`; успешный result подтверждает загрузку bytes, создание runtime +`FontFamily`, подготовку typography и commit Compose render state. + +Проверено: + +- `sdds.preview.ready` с Preview Protocol v1; +- коррелированные success/failure envelopes и direct Promise API; +- замена token/property/example state вторым полным payload без reload; +- latest-request-wins и `superseded`; +- сохранение последнего успешного preview после invalid payload и недоступного обязательного font. + +Контрольный запуск 23 июля 2026: macOS arm64, Node.js 24.4.0, Headless Chrome 150.0.0.0, +Kotlin 2.1.10 / Kotlin-Wasm stdlib 2.1.21, Compose Multiplatform 1.8.2. First-render latency от +отправки canonical payload после ready до коррелированного success составила **6 ms**. Значение +диагностическое и зависит от browser/toolchain/cache; task печатает актуальное значение каждого +запуска. + +## Известные ограничения + +- PoC типизирован только для BasicButton и не является универсальным registry factories. +- WOFF/WOFF2 и variable-font axes не поддерживаются. +- HTML shell предоставляет Promise-based `submitPreviewPayload(payload)` и двусторонние + `sdds.preview.payload` / `sdds.preview.result` messages. +- HTTP resolver использует browser `fetch`; внешний asset URL должен быть доступен согласно CORS. +- Byte-backed fonts поддерживают TTF/OTF, integer weight и normal/italic; WOFF/WOFF2, + variable-font axes и проверка glyph rasterization не входят в PoC. +- Artifact не включает publication, подпись и production trust policy. diff --git a/integration-core/preview-compose-plugin/README.md b/integration-core/preview-compose-plugin/README.md new file mode 100644 index 000000000..5fc34c8f3 --- /dev/null +++ b/integration-core/preview-compose-plugin/README.md @@ -0,0 +1,260 @@ +# Compose Preview Plugin + +Self-contained Wasm-плагин для отображения SDDS Compose-компонентов из полного +`PreviewPayload`. Плагин поддерживает все компоненты `uikit-compose` (79 компонентов api-меты); список лежит +в `components` manifest. + +## Сборка + +Из корня репозитория: + +```bash +./gradlew -p integration-core :preview-compose-plugin:previewPluginArtifact +``` + +Готовый архив: + +```text +integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip +``` + +Архив содержит: + +- `index.html` — browser entrypoint; +- `preview-plugin.json` — manifest (собирается при сборке, см. «Как добавить компонент»); +- `preview-compose-plugin.js` — загрузчик приложения; +- Kotlin/Wasm и Skiko Wasm binaries; +- `composeResources/` — Compose-ресурсы историй (иллюстрации и т.п.); без них истории, использующие ресурсы, + падают при отрисовке. + +## Размещение + +Распакуйте архив в отдельный каталог web-сервера и публикуйте его содержимое из одного URL: + +```text +https://preview.example.com/compose/ +├── index.html +├── preview-plugin.json +├── preview-compose-plugin.js +├── composeResources/ +└── *.wasm +``` + +Плагин необходимо открывать по HTTP/HTTPS. Запуск `index.html` через `file://` не поддерживается, +поскольку браузеру требуется загружать Wasm-файлы и font assets. + +Для локальной проверки можно распаковать архив и запустить любой статический HTTP-сервер: + +```bash +unzip integration-core/preview-compose-plugin/build/distributions/preview-compose-plugin.zip \ + -d /tmp/preview-compose-plugin +python3 -m http.server 8080 --directory /tmp/preview-compose-plugin +``` + +После этого плагин доступен по адресу `http://localhost:8080/`. + +## Как добавить компонент + +Компонент попадает в preview без кода в плагине: достаточно истории и записи в api-мете. + +1. Компонент должен быть в `uikit-compose-api-meta.json` (его строит `uikit-compose`). Для каждой записи меты при сборке + плагина генерируется фабрика стиля (задача `generateComposePreview` плагина Design System Builder, блок `dsBuilder.preview`): + метод билдера выбирается по тем же правилам, что и в Theme Builder (общий файл `OverloadRules.kt`), вызовы порождаются + как обычный код. Набор компонентов можно ограничить свойством `-PpreviewComponents=A,B`. +2. В `uikit-compose-fixtures` для компонента должна быть история `@Story(componentId = "")`. + `componentId` совпадает с именем компонента в мете и может отличаться от `ComponentKey.value` + (`CheckBox` и `Checkbox`). История без `componentId` остаётся в демо, но в preview не участвует. +3. `previewComponentsFrom` собирает компоненты плагина из записей `StoryRegistry.previewEntries()` и сгенерированных + фабрик; `components` в `preview-plugin.json` генерируется из списка фабрик (шаблон в `src/manifest`). + +Проверки: `StyleFactoryCoverageTest` сверяет мету, фабрики и истории (исключения перечислены в тесте с причиной), +браузерный тест рисует каждый компонент (галерея) и сверяет manifest с описаниями компонентов. + +## Передача payload + +Плагин принимает полный `PreviewPayload`, а не patch. Поддерживаются два способа подключения. + +### Прямой вызов + +Если host и плагин работают в одном window: + +```javascript +const result = await window.submitPreviewPayload(payload); +if (result.type === "failure") { + console.error(result.code, result.message); +} +``` + +`payload` может быть JavaScript-объектом или сериализованной JSON-строкой: + +```javascript +await window.submitPreviewPayload(JSON.stringify(payload)); +``` + +Функция становится доступна после загрузки `index.html`, самостоятельно ожидает Wasm runtime и +завершает Promise только после подготовки payload и применения актуального Compose state. +Результат — существующий `PreviewResult.Success` или `PreviewResult.Failure` с тем же +`requestId`. + +Описание controls зарегистрированной story доступно независимо от render state: + +```javascript +const result = await window.describePreviewComponent("BasicButton"); +if (result.type === "success") { + console.log(result.description.properties); +} +``` + +### iframe и postMessage + +Host может загрузить плагин в iframe: + +```html + +``` + +Полный payload передаётся сообщением типа `sdds.preview.payload`: + +```javascript +const iframe = document.getElementById("compose-preview"); + +iframe.contentWindow.postMessage( + { + type: "sdds.preview.payload", + payload, + }, + "https://preview.example.com", +); +``` + +Событие iframe `load` не означает готовность Wasm. Host должен дождаться lifecycle message: + +```javascript +window.addEventListener("message", (event) => { + if ( + event.source === iframe.contentWindow && + event.data?.type === "sdds.preview.ready" && + event.data.protocolVersion === 1 + ) { + // Plugin готов принять первый полный payload. + } +}); +``` + +После обработки plugin отправляет результат в исходный `event.source`, используя исходный +`event.origin` как `targetOrigin`: + +```javascript +window.addEventListener("message", (event) => { + if (event.source !== iframe.contentWindow) return; + if (event.data?.type !== "sdds.preview.result") return; + const result = event.data.result; + console.log(result.requestId, result.type); +}); +``` + +Для получения description через iframe host отправляет отдельный коррелированный request: + +```javascript +iframe.contentWindow.postMessage( + { + type: "sdds.preview.describe", + requestId: "describe-1", + componentId: "BasicButton", + }, + "https://preview.example.com", +); + +window.addEventListener("message", (event) => { + if (event.source !== iframe.contentWindow) return; + if (event.data?.type !== "sdds.preview.description") return; + console.log(event.data.result.requestId, event.data.result); +}); +``` + +Describe requests имеют собственный lifecycle и не отменяют render requests. + +Каждый host request должен иметь уникальный непустой `requestId`. Следующий полный payload +заменяет предыдущие theme, component properties и example state без перезапуска Wasm application. +Если более старый запрос заканчивает подготовку после нового, он получает failure с code +`superseded` и не заменяет актуальный render. + +## Font assets + +Font-family tokens могут ссылаться на TTF/OTF assets из payload: + +```json +{ + "assets": [ + { + "id": "font.body.regular", + "type": "TTF", + "url": "https://assets.example.com/fonts/body-regular.ttf", + "digest": "sha256:..." + } + ] +} +``` + +URL загружается браузером через `fetch`. Сервер assets должен: + +- разрешать CORS для origin плагина; +- возвращать успешный HTTP status; +- отдавать непустое содержимое TTF/OTF. + +Если обязательный font недоступен и `allowFallback=false`, подготовка payload завершается ошибкой. +При `allowFallback=true` используется `FontFamily.Default`. + +## Manifest + +`preview-plugin.json` описывает entrypoint, protocol version, bridge и поддержанные компоненты. +Перед подключением host может прочитать manifest: + +```javascript +const manifest = await fetch( + "https://preview.example.com/compose/preview-plugin.json", +).then((response) => response.json()); +``` + +Текущий идентификатор plugin: `sdds.compose.preview`, версия Preview Protocol: `1`. +Список `components` формируется при сборке из сгенерированных фабрик, вручную его править не нужно. +`payloadBridge` объявляет direct render/describe API и transport message types: +`sdds.preview.payload`, `sdds.preview.ready`, `sdds.preview.result`, +`sdds.preview.describe` и `sdds.preview.description`. + +## Проверка production artifact + +Focused JVM tests, artifact и browser lifecycle запускаются из корня репозитория: + +```bash +./gradlew -p integration-core \ + :preview-compose-plugin:jvmTest \ + :preview-compose-plugin:previewPluginArtifact \ + :preview-compose-plugin:previewPluginBrowserTest +``` + +Browser test использует headless Chrome (с программным WebGL) и настоящий OTF asset. Путь к Chrome можно +переопределить через `-PpreviewChromePath=/path/to/chrome`. + +Кроме функциональных проверок тест запускает галерею: каждый компонент из manifest отрисовывается с пробными +значениями свойств из api-меты, проверяются успех, отсутствие предупреждений и непустой кадр, ошибки страницы +и застывший рендер. Скриншоты и страница `index.html` для просмотра лежат в +`integration-core/preview-compose-plugin/build/browser-test/gallery/`. Тест падает со списком всех проблемных компонентов. + +## Ограничения + +- Иконки (`ImageSource`) и тени (`ShadowAppearance`) в preview пока не поддерживаются: такие свойства + пропускаются с предупреждением `unsupported_property`. +- Исключение при композиции компонента (например, нехватка ресурса) останавливает рендер до перезагрузки + страницы: результат `success` отправляется после применения состояния, до отрисовки. +- Каждый update передаёт полный payload. +- WOFF/WOFF2 и variable-font axes не поддерживаются. +- Проверка доверенного origin, подпись artifact и publication pipeline пока не реализованы. +- Ready message временно отправляется parent с `targetOrigin="*"`, поскольку production origin + allowlist относится к отдельному trust-policy change. Payload results всегда направляются + только исходному sender/origin. +- Host отвечает за sandbox/CSP iframe, проверку `event.source`, `event.origin` и совместимости + manifest. diff --git a/integration-core/preview-compose-plugin/build.gradle.kts b/integration-core/preview-compose-plugin/build.gradle.kts new file mode 100644 index 000000000..bfe4c005d --- /dev/null +++ b/integration-core/preview-compose-plugin/build.gradle.kts @@ -0,0 +1,231 @@ +import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsTargetDsl +import org.gradle.api.tasks.bundling.Zip +import org.gradle.api.tasks.Delete +import org.gradle.api.tasks.Sync +import org.gradle.api.attributes.Usage +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import utils.addDefaultTargets + +plugins { + id("convention.cmp-lib") + id("io.github.salute-developers.design-system-builder") +} + +val cleanPreviewPluginWebpackOutput by tasks.registering(Delete::class) { + delete(layout.buildDirectory.dir("kotlin-webpack/wasmJs/productionExecutable")) +} + +val generatedManifestDir = layout.buildDirectory.dir("generated/preview-manifest") +val generatedFactoriesDir = layout.buildDirectory.dir("generated/preview-factories") +val generatedIconsDir = layout.buildDirectory.dir("generated/preview-icons") +val generatedGroupsFile = layout.buildDirectory.file("generated/preview-groups/preview-groups.json") +val generatePreviewFactories = tasks.named("generateComposePreview") + +// Группы компонентов берутся из реестра историй песочницы (`ComponentKey.group`): их знает только Kotlin-код, +// поэтому JSON строит главная функция JVM-цели модуля. +val generatePreviewGroups by tasks.registering(JavaExec::class) { + group = "build" + description = "Формирует группы компонентов Compose preview по реестру историй песочницы." + dependsOn("compileKotlinJvm") + val jvmMain = provider { kotlin.targets.getByName("jvm").compilations.getByName("main") } + classpath = files( + provider { jvmMain.get().output.allOutputs }, + provider { jvmMain.get().runtimeDependencyFiles }, + ) + mainClass.set("com.sdds.preview.compose.plugin.PreviewComponentGroupKt") + val output = generatedGroupsFile + outputs.file(output) + javaLauncher.set(javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(17)) }) + doFirst { setArgs(listOf(output.get().asFile.absolutePath)) } +} + +// Список `components` manifest берётся из генератора фабрик, а не пишется вручную; тест покрытия и браузерный тест +// сверяют его с реестром историй плагина. +val generatePreviewManifest by tasks.registering { + group = "build" + description = "Собирает preview-plugin.json из шаблона и списка компонентов сгенерированных фабрик." + dependsOn(generatePreviewFactories, generatePreviewGroups) + val template = layout.projectDirectory.file("src/manifest/preview-plugin.template.json") + val components = generatedFactoriesDir.map { it.file("components.txt") } + val output = generatedManifestDir + inputs.file(template) + val groupsFile = generatedGroupsFile + inputs.file(components) + inputs.file(groupsFile) + outputs.dir(output) + doLast { + @Suppress("UNCHECKED_CAST") + val manifest = groovy.json.JsonSlurper().parse(template.asFile) as MutableMap + val componentNames = components.get().asFile.readLines().filter { it.isNotBlank() } + manifest["components"] = componentNames + @Suppress("UNCHECKED_CAST") + val groups = (groovy.json.JsonSlurper().parse(groupsFile.get().asFile) as Map)["groups"] + as List> + // Каждый компонент манифеста входит ровно в одну группу, и групп без компонентов манифеста нет. + val grouped = groups.flatMap { (it["components"] as List<*>).map(Any?::toString) } + val missing = componentNames - grouped.toSet() + val unknown = grouped.toSet() - componentNames.toSet() + val duplicated = grouped.groupBy { it }.filterValues { it.size > 1 }.keys + check(missing.isEmpty() && unknown.isEmpty() && duplicated.isEmpty()) { + "Группы манифеста расходятся с компонентами: без группы $missing, вне манифеста $unknown, в нескольких группах $duplicated" + } + manifest["groups"] = groups + val directory = output.get().asFile + directory.mkdirs() + File(directory, "preview-plugin.json") + .writeText(groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(manifest)) + "\n") + } +} + +val previewPluginArtifact by tasks.registering(Zip::class) { + group = "distribution" + description = "Собирает self-contained Compose preview plugin artifact." + dependsOn("wasmJsBrowserProductionWebpack", "wasmJsProcessResources", generatePreviewManifest) + from(generatedManifestDir) + // Compose-ресурсы историй (иллюстрации, шрифты) загружаются во время работы относительно страницы плагина. + from(layout.buildDirectory.dir("processedResources/wasmJs/main")) { + include("composeResources/**") + } + from(layout.buildDirectory.dir("kotlin-webpack/wasmJs/productionExecutable")) { + include("*.js", "*.wasm") + } + from("src/wasmJsMain/resources") + archiveFileName.set("preview-compose-plugin.zip") + destinationDirectory.set(layout.buildDirectory.dir("distributions")) +} + +val unpackPreviewPluginArtifact by tasks.registering(Sync::class) { + dependsOn(previewPluginArtifact) + from(previewPluginArtifact.map { zipTree(it.archiveFile) }) + into(layout.buildDirectory.dir("browser-test/artifact")) +} + +// Api-мета для галереи браузерного теста: тот же ресурс JVM-артефакта uikit-compose, что и при генерации фабрик. +val extractApiMetaForBrowserTest by tasks.registering(Copy::class) { + from(provider { zipTree(previewApiMeta.files.single { it.name.startsWith("uikit-compose-") || it.name.startsWith("uikit-compose.") }) }) { + include("sdds/api/uikit-compose-api-meta.json") + eachFile { path = "api-meta.json" } + includeEmptyDirs = false + } + into(layout.buildDirectory.dir("browser-test/api-meta")) +} + +val previewPluginBrowserTest by tasks.registering(Exec::class) { + group = "verification" + description = "Проверяет production Compose preview artifact в headless Chrome." + dependsOn(unpackPreviewPluginArtifact, extractApiMetaForBrowserTest) + val chromePath = providers.gradleProperty("previewChromePath") + .orElse("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") + commandLine( + "node", + "src/browserTest/run-browser-test.mjs", + layout.buildDirectory.dir("browser-test/artifact").get().asFile.absolutePath, + file("src/browserTest/resources").absolutePath, + file("../sandbox-compose/src/commonMain/composeResources/font/s_b_sans_text_regular.otf").absolutePath, + chromePath.get(), + layout.buildDirectory.dir("browser-test/gallery").get().asFile.absolutePath, + layout.buildDirectory.dir("browser-test/api-meta").get().asFile.absolutePath, + ) +} + +group = "integration-core" + +// Фабрики стилей генерируются по api-мете, которая лежит ресурсом в JVM-артефакте uikit-compose. +val previewApiMeta: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(KotlinPlatformType.attribute, KotlinPlatformType.jvm) + } +} +dependencies { + previewApiMeta(libs.sdds.uikit.compose) +} + +// Фабрики стилей порождает задача `generateComposePreview` плагина Design System Builder. +// Ограничить набор компонентов можно свойством `-PpreviewComponents=A,B`. +dsBuilder { + preview { + metaClasspath.from(previewApiMeta) + providers.gradleProperty("previewComponents").orNull?.let { components.set(it) } + outputDirectory.set(generatedFactoriesDir) + } +} + +// Иконки, которые плагин рисует по имени из конфигурации компонента (`disclosure.down.fill.16`). Список лежит в +// репозитории: в wasm попадают только перечисленные иконки, а несуществующее имя ломает компиляцию реестра. +val generatePreviewIcons by tasks.registering { + group = "build" + description = "Генерирует реестр иконок Compose preview по src/icons/preview-icons.txt." + val list = layout.projectDirectory.file("src/icons/preview-icons.txt") + val output = generatedIconsDir + inputs.file(list) + outputs.dir(output) + doLast { + val members = list.asFile.readLines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { name -> + name.split('.', '-', '_').filter { it.isNotEmpty() } + .joinToString("") { part -> part.replaceFirstChar { it.uppercaseChar() } } + } + .distinct() + .sorted() + val source = buildString { + appendLine("package com.sdds.preview.compose.generated") + appendLine() + appendLine("import com.sdds.icons.compose.SddsIcons") + members.forEach { appendLine("import com.sdds.icons.compose.$it") } + appendLine("import com.sdds.preview.compose.PreviewIconRegistry") + appendLine() + appendLine("/** Сгенерировано из `src/icons/preview-icons.txt`: иконки, доступные Compose preview по имени. */") + appendLine("internal object GeneratedPreviewIcons {") + appendLine(" val all: PreviewIconRegistry = PreviewIconRegistry(") + appendLine(" mapOf(") + members.forEach { appendLine(" \"$it\" to { SddsIcons.$it },") } + appendLine(" ),") + appendLine(" )") + appendLine("}") + } + val directory = output.get().asFile + directory.mkdirs() + File(directory, "GeneratedPreviewIcons.kt").writeText(source) + } +} + +android { namespace = "com.sdds.preview.compose.plugin" } + +kotlin { + addDefaultTargets(publishLibraryVariants = false) + targets.named("wasmJs") { + binaries.executable() + } + + sourceSets { + commonMain { + kotlin.srcDir(generatePreviewFactories.map { generatedFactoriesDir.get().asFile }) + kotlin.srcDir(generatePreviewIcons.map { generatedIconsDir.get().asFile }) + } + commonMain.dependencies { + implementation(project(":preview-contract")) + implementation(project(":preview-sdk-compose")) + implementation(project(":sandbox-core")) + implementation(project(":uikit-compose-fixtures")) + implementation("sdds-core:icons-compose") + implementation(libs.sdds.uikit.compose) + implementation(compose.foundation) + implementation(compose.runtime) + implementation(compose.ui) + implementation(libs.base.kotlin.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } +} + +tasks.named("wasmJsProductionExecutableCompileSync") { + dependsOn(cleanPreviewPluginWebpackOutput) +} diff --git a/integration-core/preview-compose-plugin/src/browserTest/png.mjs b/integration-core/preview-compose-plugin/src/browserTest/png.mjs new file mode 100644 index 000000000..fa34e8045 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/png.mjs @@ -0,0 +1,74 @@ +import {inflateSync} from "node:zlib"; + +/** + * Минимальный декодер PNG (8 бит на канал, без чересстрочности) для проверки скриншотов галереи: + * достаточно знать, сколько различных цветов в кадре. + */ +export function decodePng(buffer) { + const signature = buffer.subarray(0, 8).toString("hex"); + if (signature !== "89504e470d0a1a0a") throw new Error("Not a PNG"); + let offset = 8; + let header; + const data = []; + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.toString("ascii", offset + 4, offset + 8); + const body = buffer.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + header = { + width: body.readUInt32BE(0), + height: body.readUInt32BE(4), + depth: body[8], + colorType: body[9], + interlace: body[12], + }; + } else if (type === "IDAT") { + data.push(body); + } else if (type === "IEND") { + break; + } + offset += 12 + length; + } + if (!header || header.depth !== 8 || header.interlace !== 0) throw new Error("Unsupported PNG"); + const channels = {2: 3, 6: 4}[header.colorType]; + if (!channels) throw new Error(`Unsupported PNG colour type ${header.colorType}`); + const stride = header.width * channels; + const raw = inflateSync(Buffer.concat(data)); + const pixels = Buffer.alloc(stride * header.height); + for (let row = 0; row < header.height; row += 1) { + const filter = raw[row * (stride + 1)]; + const source = row * (stride + 1) + 1; + for (let i = 0; i < stride; i += 1) { + const left = i >= channels ? pixels[row * stride + i - channels] : 0; + const up = row > 0 ? pixels[(row - 1) * stride + i] : 0; + const upLeft = row > 0 && i >= channels ? pixels[(row - 1) * stride + i - channels] : 0; + const value = raw[source + i]; + let predicted = 0; + if (filter === 1) predicted = left; + else if (filter === 2) predicted = up; + else if (filter === 3) predicted = (left + up) >> 1; + else if (filter === 4) { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + predicted = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + } + pixels[row * stride + i] = (value + predicted) & 0xff; + } + } + return {width: header.width, height: header.height, channels, pixels}; +} + +/** Число различных цветов в кадре и доля пикселей, отличающихся от самого частого цвета (фона). */ +export function colourStatistics(image) { + const counts = new Map(); + for (let i = 0; i < image.pixels.length; i += image.channels) { + const key = image.pixels.readUIntBE(i, 3); + counts.set(key, (counts.get(key) || 0) + 1); + } + const total = image.width * image.height; + let background = 0; + for (const count of counts.values()) background = Math.max(background, count); + return {colours: counts.size, nonBackgroundShare: (total - background) / total}; +} diff --git a/integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html b/integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html new file mode 100644 index 000000000..24635f26b --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/resources/browser-test.html @@ -0,0 +1,341 @@ + + + + +
running
+ + + diff --git a/integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json b/integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json new file mode 100644 index 000000000..79cfef070 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/resources/canonical-basic-button.json @@ -0,0 +1,70 @@ +{ + "protocolVersion": 1, + "requestId": "button-first", + "platform": "COMPOSE", + "assets": [ + { + "id": "font.body.regular", + "type": "OTF", + "url": "__BASE_URL__/test-assets/font.otf" + } + ], + "theme": { + "color.background": {"type": "color", "value": "#2A72E5FF"}, + "color.background.pressed": {"type": "color", "value": "#1959B8FF"}, + "color.content": {"type": "color", "value": "#FFFFFFFF"}, + "size.height": {"type": "dimension", "value": 48.0}, + "size.padding": {"type": "dimension", "value": 20.0}, + "size.icon": {"type": "dimension", "value": 24.0}, + "shape.button": {"type": "shape", "cornerRadii": [12.0, 12.0, 12.0, 12.0]}, + "font.body": { + "type": "fontFamily", + "faces": [{"assetId": "font.body.regular", "weight": 400, "style": "NORMAL"}], + "allowFallback": false + }, + "typography.button": { + "type": "typography", + "fontFamilyTokenId": "font.body", + "fontSize": 16.0, + "lineHeight": 20.0, + "letterSpacing": 0.0, + "weight": 400 + } + }, + "component": { + "id": "BasicButton", + "variations": {"size": "l", "view": "primary"}, + "properties": { + "shape": {"base": {"type": "tokenRef", "tokenId": "shape.button"}}, + "labelStyle": {"base": {"type": "tokenRef", "tokenId": "typography.button"}}, + "contentColor": {"base": {"type": "tokenRef", "tokenId": "color.content", "alpha": 0.9}}, + "backgroundColor": { + "base": {"type": "tokenRef", "tokenId": "color.background"}, + "states": [ + {"when": ["pressed"], "source": {"type": "tokenRef", "tokenId": "color.background.pressed"}}, + {"when": ["pressed", "selected"], "source": {"type": "tokenRef", "tokenId": "color.background.pressed", "alpha": 0.5}}, + {"when": ["fancy"], "source": {"type": "tokenRef", "tokenId": "color.background.pressed"}} + ] + }, + "height": {"base": {"type": "tokenRef", "tokenId": "size.height"}}, + "paddingStart": {"base": {"type": "tokenRef", "tokenId": "size.padding"}}, + "paddingEnd": {"base": {"type": "tokenRef", "tokenId": "size.padding"}}, + "iconSize": {"base": {"type": "tokenRef", "tokenId": "size.icon"}}, + "disableAlpha": {"base": {"type": "literal", "value": 0.4}}, + "loadingAlpha": {"base": {"type": "literal", "value": 0.6}} + } + }, + "example": { + "id": "BasicButton", + "props": { + "label": "Canonical button", + "value": "Value", + "icon": "No", + "spacing": "Packed", + "hasFixedWidth": true, + "enabled": true, + "loading": false + } + }, + "surface": {"width": 360, "height": 160, "background": "#FFFFFFFF"} +} diff --git a/integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html b/integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html new file mode 100644 index 000000000..210f60faa --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/resources/gallery.html @@ -0,0 +1,200 @@ + + + + +
running
+ + + diff --git a/integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs b/integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs new file mode 100644 index 000000000..b5bcbff37 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/browserTest/run-browser-test.mjs @@ -0,0 +1,409 @@ +import {createServer} from "node:http"; +import {mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {extname, join, normalize} from "node:path"; +import {spawn} from "node:child_process"; +import {colourStatistics, decodePng} from "./png.mjs"; + +const [artifactDirectory, testResources, fontPath, chromePath, galleryDirectory, apiMetaDirectory] = + process.argv.slice(2); +const roots = [artifactDirectory, testResources, apiMetaDirectory].filter(Boolean); +const mimeTypes = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".wasm": "application/wasm", + ".otf": "font/otf", +}; + +function resolveFile(pathname) { + if (pathname === "/test-assets/font.otf" || pathname === "/slow-font.otf") return fontPath; + const relative = normalize(decodeURIComponent(pathname)).replace(/^(\.\.(\/|\\|$))+/, "").replace(/^\//, ""); + for (const root of roots) { + const candidate = join(root, relative || "index.html"); + try { + if (statSync(candidate).isFile()) return candidate; + } catch (_) { + // Continue with the next root. + } + } + return null; +} + +// Сколько раз скачан шрифт галереи: плагин должен переиспользовать его между payload. +let fontRequests = 0; + +const server = createServer((request, response) => { + const pathname = new URL(request.url, "http://localhost").pathname; + if (pathname === "/test-assets/font.otf") fontRequests += 1; + const file = resolveFile(pathname); + if (!file) { + response.writeHead(404); + response.end("Not found"); + return; + } + const send = () => { + response.writeHead(200, { + "Content-Type": mimeTypes[extname(file)] || "application/octet-stream", + "Access-Control-Allow-Origin": "*", + }); + response.end(readFileSync(file)); + }; + if (pathname === "/slow-font.otf") setTimeout(send, 500); + else send(); +}); + +function listen(server) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function freePort() { + const probe = createServer(); + await listen(probe); + const port = probe.address().port; + await new Promise((resolve) => probe.close(resolve)); + return port; +} + +async function connectToPage(debugPort) { + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + const targets = await fetch(`http://127.0.0.1:${debugPort}/json`).then((response) => response.json()); + const page = targets.find((target) => target.type === "page"); + if (page) return new WebSocket(page.webSocketDebuggerUrl); + } catch (_) { + // Chrome is still starting. + } + await delay(250); + } + throw new Error("Chrome DevTools endpoint did not become ready"); +} + +function open(socket) { + return new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, {once: true}); + socket.addEventListener("error", reject, {once: true}); + }); +} + +let commandId = 0; +function evaluate(socket, expression) { + return new Promise((resolve, reject) => { + const id = ++commandId; + const onMessage = (event) => { + const message = JSON.parse(event.data); + if (message.id !== id) return; + socket.removeEventListener("message", onMessage); + if (message.error) reject(new Error(message.error.message)); + else resolve(message.result.result.value); + }; + socket.addEventListener("message", onMessage); + socket.send(JSON.stringify({ + id, + method: "Runtime.evaluate", + params: {expression, returnByValue: true}, + })); + }); +} + +function cdp(socket, method, params = {}, awaitPromise = false) { + return new Promise((resolve, reject) => { + const id = ++commandId; + const onMessage = (event) => { + const message = JSON.parse(event.data); + if (message.id !== id) return; + socket.removeEventListener("message", onMessage); + if (message.error) reject(new Error(`${method}: ${message.error.message}`)); + else resolve(message.result); + }; + socket.addEventListener("message", onMessage); + socket.send(JSON.stringify({id, method, params: awaitPromise ? {...params, awaitPromise: true} : params})); + }); +} + +async function evaluateAsync(socket, expression) { + const result = await cdp(socket, "Runtime.evaluate", {expression, returnByValue: true}, true); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || "evaluate failed"); + return result.result.value; +} + +/** Истории, которые рисуют кнопку-триггер: содержимое (попап, шторка, уведомление) появляется после клика. */ +const TRIGGER_COMPONENTS = new Set(["Modal", "ModalBottomSheet", "Popover", "Tooltip", "Toast", "Notification"]); + +/** Группы компонентов с одинаковым кадром по умолчанию: общий стиль (TextArea и TextField, CheckBox и RadioBox). */ +const IDENTICAL_FRAMES = [ + ["TextArea", "TextField"], + ["CheckBox", "RadioBox"], + // Истории Modal, Notification, Toast и Tooltip до клика показывают одну и ту же кнопку «show». + ["Modal", "Notification", "Toast", "Tooltip"], +]; + +function escapeHtml(text) { + return String(text).replace(/[&<>"]/g, (char) => ({"&": "&", "<": "<", ">": ">", '"': """}[char])); +} + +/** + * Галерея: отрисовывает каждый компонент из manifest с пробными значениями свойств из api-меты, проверяет успех, + * отсутствие предупреждений и непустой кадр, сохраняет скриншоты и страницу для просмотра. + * Падает со списком ВСЕХ проблемных компонентов, а не только первого. + */ +async function runGallery(socket, port) { + mkdirSync(galleryDirectory, {recursive: true}); + await cdp(socket, "Page.navigate", {url: `http://127.0.0.1:${port}/gallery.html`}); + let componentIds; + for (let attempt = 0; attempt < 240 && !Array.isArray(componentIds); attempt += 1) { + await delay(250); + try { + componentIds = await evaluateAsync(socket, "window.__galleryReady || null"); + } catch (_) { + componentIds = undefined; + } + const pageState = await evaluate(socket, "document.getElementById('result')?.textContent || ''"); + if (pageState.startsWith("failed")) throw new Error(`Gallery page failed to start: ${pageState}`); + } + if (!Array.isArray(componentIds)) throw new Error("Gallery page did not become ready"); + // Исключение при композиции не возвращается результатом (успех отправляется после применения состояния), + // поэтому ошибки страницы собираются отдельно и приписываются компоненту, который рисовался. + await cdp(socket, "Runtime.enable"); + let pageErrors = []; + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.method === "Runtime.exceptionThrown") { + const details = message.params.exceptionDetails; + pageErrors.push(details.exception?.description || details.text); + } else if (message.method === "Runtime.consoleAPICalled" && message.params.type === "error") { + pageErrors.push(message.params.args.map((arg) => arg.value ?? arg.description).join(" ")); + } + }); + const fontRequestsBeforeGallery = fontRequests; + const cards = []; + const issues = []; + let previousShot = null; + let identicalInRow = 0; + const shots = new Map(); + + const capture = async () => { + const shot = await cdp(socket, "Page.captureScreenshot", { + format: "png", + clip: {x: 0, y: 0, width: 480, height: 320, scale: 1}, + }); + return Buffer.from(shot.data, "base64"); + }; + // `success` приходит до отрисовки: ждём, пока кадр перестанет меняться (два одинаковых снимка подряд), но не дольше 6 с. + const stableShot = async () => { + let previous = await capture(); + for (let attempt = 0; attempt < 30; attempt += 1) { + await delay(200); + const next = await capture(); + if (next.equals(previous)) return next; + previous = next; + } + return previous; + }; + // Истории попап-компонентов рисуют только кнопку-триггер: содержимое появляется после клика в центр. + const click = async (x, y) => { + for (const type of ["mousePressed", "mouseReleased"]) { + await cdp(socket, "Input.dispatchMouseEvent", {type, x, y, button: "left", clickCount: 1}); + } + }; + + // Для отладки одной проблемы: GALLERY_ONLY=Accordion,CheckBox ограничивает галерею перечисленными компонентами. + const only = process.env.GALLERY_ONLY?.split(",").filter(Boolean); + for (const componentId of only ? componentIds.filter((id) => only.includes(id)) : componentIds) { + const rendered = await evaluateAsync(socket, `window.__galleryRender(${JSON.stringify(componentId)})`); + const problems = []; + const {result} = rendered; + if (result.type !== "success") { + problems.push(`${result.code}: ${result.message}`); + } else if (result.warnings?.length) { + problems.push(`предупреждения: ${result.warnings.map((w) => `${w.code} ${w.path ?? ""}`).join("; ")}`); + } + const png = await stableShot(); + // Ошибки страницы, пришедшие с прошлой проверки, принадлежат компоненту, который рисуется сейчас. + const errors = pageErrors.splice(0); + if (errors.length) problems.push(`ошибки страницы: ${errors.slice(0, 2).join(" | ").slice(0, 400)}`); + // Два соседних компонента могут выглядеть одинаково (TextField и TextArea), но три подряд означают, что рендер встал. + identicalInRow = previousShot !== null && previousShot.equals(png) ? identicalInRow + 1 : 0; + if (identicalInRow >= 2) problems.push("три одинаковых кадра подряд (рендер мог остановиться)"); + previousShot = png; + writeFileSync(join(galleryDirectory, `${componentId}.png`), png); + shots.set(componentId, png); + const statistics = colourStatistics(decodePng(png)); + if (result.type === "success" && statistics.colours < 2) problems.push("пустой кадр (один цвет)"); + + let opened = null; + if (TRIGGER_COMPONENTS.has(componentId) && result.type === "success") { + await click(240, 160); + await delay(600); + opened = await stableShot(); + const openErrors = pageErrors.splice(0); + if (openErrors.length) problems.push(`ошибки страницы после клика: ${openErrors.slice(0, 2).join(" | ").slice(0, 400)}`); + writeFileSync(join(galleryDirectory, `${componentId}-open.png`), opened); + if (opened.equals(png)) problems.push("после клика по триггеру кадр не изменился (содержимое не показано)"); + const openStatistics = colourStatistics(decodePng(opened)); + if (openStatistics.colours < 3) problems.push("после клика в кадре меньше трёх цветов"); + } + // Пользователь нажимает на компонент прямо в превью: исключение в обработчике или при перерисовке + // останавливает Compose, и дальше плагин не рисует. Поэтому после клика плагин должен оставаться живым. + if (result.type === "success" && !TRIGGER_COMPONENTS.has(componentId)) { + pageErrors.splice(0); + await click(240, 160); + await delay(500); + const clickErrors = pageErrors.splice(0); + if (clickErrors.length) problems.push(`ошибки страницы после клика: ${clickErrors.slice(0, 2).join(" | ").slice(0, 400)}`); + const again = await evaluateAsync(socket, `window.__galleryRender(${JSON.stringify(componentId)})`); + if (again.result.type !== "success") { + problems.push(`после клика плагин перестал отвечать: ${again.result.code}: ${again.result.message}`); + } + pageErrors.splice(0); + } + if (problems.length) issues.push(`${componentId}: ${problems.join(" | ")}`); + cards.push({componentId, problems, statistics, opened: opened !== null, propertyCount: rendered.propertyCount, styleCount: rendered.styleCount}); + } + + // Все payload галереи ссылаются на один и тот же шрифт: после первой загрузки его не скачивают повторно. + const galleryFontRequests = fontRequests - fontRequestsBeforeGallery; + if (galleryFontRequests > 1) { + issues.push(`шрифт скачан ${galleryFontRequests} раз за ${cards.length} payload (ожидалась одна загрузка)`); + } + + // Стили и текст по умолчанию из payload не ломают render истории со вспомогательными элементами. + if (!only || only.includes("DropZone")) { + const withDefaults = await evaluateAsync(socket, `window.__galleryRenderWithHostDefaults("DropZone")`); + if (withDefaults.type !== "success" || withDefaults.warnings?.length) { + issues.push(`DropZone со стилями по умолчанию: ожидался success без предупреждений, получено ${JSON.stringify(withDefaults)}`); + } + } + // Иконка, которой нет в реестре плагина, не останавливает Compose: компонент рисуется, а результат предупреждает. + if (!only || only.includes("AccordionItem")) { + const unknownIcon = await evaluateAsync( + socket, + `window.__galleryRenderWithProperty("AccordionItem", "closedIcon", {type: "literal", value: "no.such.icon.16"})`, + ); + if (unknownIcon.type !== "success" || !unknownIcon.warnings?.some((w) => w.code === "unknown_icon")) { + issues.push(`AccordionItem с неизвестной иконкой: ожидались success и unknown_icon, получено ${JSON.stringify(unknownIcon)}`); + } + } + // Конфигурация может не содержать `textStyle`: TextSkeleton всё равно должен нарисоваться, а не остановить Compose. + if (!only || only.includes("TextSkeleton")) { + const withoutTextStyle = await evaluateAsync(socket, `window.__galleryRenderWithout("TextSkeleton", "textStyle")`); + if (withoutTextStyle.type !== "success") { + issues.push(`TextSkeleton без textStyle: ${withoutTextStyle.type}/${withoutTextStyle.code ?? ""} ${withoutTextStyle.message ?? ""}`); + } + } + + // Остановившийся Compose не должен сообщать успех (последний шаг: после него плагин мёртв). + const crash = await evaluateAsync(socket, "window.__galleryCrash()"); + if (crash.first.type !== "failure" || crash.first.code !== "render_crashed") { + issues.push(`сбой layout: ожидался отказ render_crashed, получено ${crash.first.type}/${crash.first.code ?? ""} ${crash.first.message ?? ""}`); + } + if (crash.second.type !== "failure" || crash.second.code !== "plugin_crashed") { + issues.push(`после сбоя: ожидался отказ plugin_crashed, получено ${crash.second.type}/${crash.second.code ?? ""}`); + } + // Причина сбоя доходит до хоста: stack или запись консоли, одинаковые у первого и последующих отказов. + for (const [name, result] of [["render_crashed", crash.first], ["plugin_crashed", crash.second]]) { + if (!result.details || (!result.details.stack && !result.details.consoleError)) { + issues.push(`${name}: в отказе нет details.stack и details.consoleError (${result.message ?? ""})`); + } + } + if (!/Constraints/.test(crash.first.message) || !/Constraints/.test(crash.first.details?.consoleError ?? "")) { + issues.push(`render_crashed: ожидалась причина сбоя layout (Constraints), получено ${crash.first.message}`); + } + if (JSON.stringify(crash.first.details) !== JSON.stringify(crash.second.details)) { + issues.push("plugin_crashed отдаёт details, отличные от первого сбоя"); + } + + // Одинаковые кадры у разных компонентов допустимы только из списка (общий стиль или общая история-триггер). + const byContent = new Map(); + for (const [componentId, png] of shots) { + const key = png.toString("base64"); + byContent.set(key, [...(byContent.get(key) ?? []), componentId]); + } + for (const group of byContent.values()) { + if (group.length < 2) continue; + const allowed = IDENTICAL_FRAMES.some((expected) => expected.length === group.length && expected.every((id) => group.includes(id))); + if (!allowed) issues.push(`${group.join(", ")}: одинаковые кадры у разных компонентов вне списка допустимых`); + } + const html = `Compose preview gallery + +

Compose preview: ${cards.length} компонентов, проблем: ${issues.length}

+
${cards.map((card) => `
+${escapeHtml(card.componentId)} +свойств: ${card.propertyCount}, вложенных стилей: ${card.styleCount}, цветов в кадре: ${card.statistics.colours} +${card.opened ? `после клика по триггеру` : ""} +${card.problems.map((problem) => `${escapeHtml(problem)}`).join("")}
`).join("")}
`; + writeFileSync(join(galleryDirectory, "index.html"), html); + writeFileSync(join(galleryDirectory, "results.json"), JSON.stringify({cards, issues}, null, 2)); + console.log(JSON.stringify({gallery: {components: cards.length, issues: issues.length, directory: galleryDirectory}})); + if (issues.length) throw new Error(`Gallery found problems in ${issues.length} components:\n${issues.join("\n")}`); +} + +await listen(server); +const userDataDirectory = mkdtempSync(join(tmpdir(), "preview-browser-test-")); +let chrome; +try { + const {port} = server.address(); + const debugPort = await freePort(); + chrome = spawn(chromePath, [ + "--headless=new", + // Compose рисует в WebGL-холст; без программного рендерера он не попадает в скриншот. + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--ignore-gpu-blocklist", + "--no-sandbox", + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${userDataDirectory}`, + `http://127.0.0.1:${port}/browser-test.html`, + ]); + let stderr = ""; + chrome.stderr.on("data", (chunk) => stderr += chunk); + + const socket = await connectToPage(debugPort); + await open(socket); + let resultText = "running"; + for (let attempt = 0; attempt < 120 && resultText === "running"; attempt += 1) { + resultText = await evaluate( + socket, + "document.getElementById('result')?.textContent || 'running'", + ); + if (resultText === "running") await delay(250); + } + if (resultText === "running") { + const diagnostics = await evaluate( + socket, + `JSON.stringify({ + iframeBody: document.getElementById("plugin")?.contentDocument?.body?.innerText, + directApi: typeof document.getElementById("plugin")?.contentWindow?.submitPreviewPayload, + readyCallback: typeof document.getElementById("plugin")?.contentWindow?.__sddsPreviewReady, + kotlinApi: typeof document.getElementById("plugin")?.contentWindow?.["preview-compose-plugin"] + })`, + ); + socket.close(); + throw new Error(`Browser test timed out: ${diagnostics}\n${stderr}`); + } + const result = JSON.parse(resultText); + console.log(JSON.stringify(result)); + if (result.status !== "passed") { + socket.close(); + throw new Error(result.message || "Browser test failed"); + } + try { + if (galleryDirectory) await runGallery(socket, port); + } finally { + socket.close(); + } +} finally { + if (chrome && chrome.exitCode === null) { + const closed = new Promise((resolve) => chrome.once("close", resolve)); + chrome.kill(); + await closed; + } + await new Promise((resolve) => server.close(resolve)); + rmSync(userDataDirectory, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); +} diff --git a/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt new file mode 100644 index 000000000..d3251ed3f --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PendingRenderResults.kt @@ -0,0 +1,37 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.contract.PreviewResult + +/** + * Откладывает успешный результат до тех пор, пока новое содержимое не нарисовано. + * + * Состояние рендера применяется сразу, а композиция и layout выполняются в следующих кадрах. Исключение + * в них останавливает Compose, но хост уже получил бы `success`. Поэтому результат уходит хосту только после + * [confirm], а если кадр не состоялся, хост не получит результата и сам завершит запрос по таймауту. + */ +internal class PendingRenderResults( + private val deliver: (PreviewResult) -> Unit, +) { + private var pending: PreviewResult.Success? = null + + /** Запоминает успешный результат до подтверждения отрисовки; предыдущий неподтверждённый вытесняется. */ + fun await(result: PreviewResult.Success) { + pending?.let { previous -> + deliver( + PreviewResult.Failure( + requestId = previous.requestId, + code = "superseded", + message = "Preview request superseded by a newer payload", + ), + ) + } + pending = result + } + + /** Отдаёт отложенный результат запроса [requestId], если он всё ещё ожидается. */ + fun confirm(requestId: String) { + val result = pending?.takeIf { it.requestId == requestId } ?: return + pending = null + deliver(result) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt new file mode 100644 index 000000000..e6701dd41 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonMain/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinator.kt @@ -0,0 +1,94 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.compose.PreparedComposePreview +import com.sdds.preview.compose.PreviewPreparationException +import com.sdds.preview.contract.PREVIEW_PROTOCOL_VERSION +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewResult +import com.sdds.preview.contract.TokenValue +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString + +internal class PreviewRequestCoordinator( + private val prepare: suspend (String) -> PreparedComposePreview, + private val apply: (PreparedComposePreview) -> Unit, +) { + private var latestGeneration: Long = 0 + + @Suppress("ReturnCount", "TooGenericExceptionCaught") + suspend fun submit(json: String): PreviewResult { + val generation = ++latestGeneration + val payload = try { + PreviewContractJson.decodeFromString(json) + } catch (error: SerializationException) { + return decodeFailure(json, error) + } catch (error: IllegalArgumentException) { + return decodeFailure(json, error) + } + + if (payload.protocolVersion != PREVIEW_PROTOCOL_VERSION) { + return PreviewResult.Failure( + requestId = payload.requestId, + code = "unsupported_protocol_version", + message = "Preview Protocol ${payload.protocolVersion} не поддерживается", + path = "$.protocolVersion", + ) + } + + val preview = try { + prepare(json) + } catch (error: PreviewPreparationException) { + return PreviewResult.Failure(payload.requestId, error.code, error.message.orEmpty(), error.path) + } catch (error: Throwable) { + return PreviewResult.Failure( + requestId = payload.requestId, + code = classifyPreparationFailure(payload, error), + message = error.message ?: "Preview preparation failed", + ) + } + + if (generation != latestGeneration) { + return PreviewResult.Failure( + requestId = payload.requestId, + code = "superseded", + message = "Preview request superseded by a newer payload", + ) + } + + return try { + apply(preview) + PreviewResult.Success(payload.requestId, preview.warnings) + } catch (error: Throwable) { + PreviewResult.Failure( + requestId = payload.requestId, + code = "render_state_apply_failed", + message = error.message ?: "Preview render state could not be applied", + ) + } + } + + private fun decodeFailure(json: String, error: Throwable): PreviewResult.Failure = + PreviewResult.Failure( + requestId = extractRequestId(json).orEmpty(), + code = "invalid_payload", + message = error.message ?: "Preview payload could not be decoded", + ) + + private fun extractRequestId(json: String): String? = + REQUEST_ID_REGEX.find(json)?.groupValues?.getOrNull(1) + + private fun classifyPreparationFailure(payload: PreviewPayload, error: Throwable): String { + val message = error.message.orEmpty().lowercase() + return when { + "не зарегистрирован" in message || "not registered" in message -> "component_not_found" + payload.theme.values.any { it is TokenValue.FontFamily } -> "asset_loading_failed" + "asset" in message || "font" in message || "http" in message -> "asset_loading_failed" + else -> "preview_preparation_failed" + } + } + + private companion object { + val REQUEST_ID_REGEX = Regex(""""requestId"\s*:\s*"([^"]+)"""") + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt new file mode 100644 index 000000000..fe983c9e6 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/BasicButtonDescriptionTest.kt @@ -0,0 +1,44 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.ButtonStyle +import com.sdds.compose.uikit.fixtures.stories.button.BasicButtonStory +import com.sdds.preview.compose.GeneratedStyleFactory +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.previewComponent +import com.sdds.preview.contract.PreviewExamplePropertyDescription +import kotlin.test.Test +import kotlin.test.assertEquals + +class BasicButtonDescriptionTest { + @Suppress("UNCHECKED_CAST") + @Test + fun `BasicButton description exactly matches generated story properties`() { + val description = previewComponent( + BasicButtonStory, + GeneratedPreviewStyleFactories.all.getValue("BasicButton") as GeneratedStyleFactory, + ).description + + assertEquals("BasicButton", description.componentId) + assertEquals("BasicButton", description.storyId) + assertEquals( + listOf( + PreviewExamplePropertyDescription.StringProperty("label", "label"), + PreviewExamplePropertyDescription.StringProperty("value", ""), + PreviewExamplePropertyDescription.SingleChoiceProperty( + "icon", + "Start", + listOf("Start", "End", "No"), + ), + PreviewExamplePropertyDescription.SingleChoiceProperty( + "spacing", + "Packed", + listOf("Packed", "SpaceBetween"), + ), + PreviewExamplePropertyDescription.BooleanProperty("hasFixedWidth", false), + PreviewExamplePropertyDescription.BooleanProperty("enabled", true), + PreviewExamplePropertyDescription.BooleanProperty("loading", false), + ), + description.properties, + ) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt new file mode 100644 index 000000000..031001fa9 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/ComponentNotFoundTest.kt @@ -0,0 +1,44 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.ui.text.font.FontFamily +import com.sdds.preview.compose.ComposePreviewRuntime +import com.sdds.preview.compose.InMemoryPreviewAssetResolver +import com.sdds.preview.compose.LoadedFontFamilyFactory +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewResult +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ComponentNotFoundTest { + private val runtime = ComposePreviewRuntime( + assetResolver = InMemoryPreviewAssetResolver(emptyMap()), + fontFamilyFactory = LoadedFontFamilyFactory { FontFamily.Default }, + components = emptyList(), + ) + private val coordinator = PreviewRequestCoordinator(prepare = runtime::prepare, apply = {}) + + @Test + fun `payload for an unregistered component fails with component_not_found`() = runTest { + val json = PreviewContractJson.encodeToString( + PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent("Text"), + example = PreviewExample("default"), + ), + ) + + val result = assertIs(coordinator.submit(json)) + + assertEquals("component_not_found", result.code) + assertEquals("r", result.requestId) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt new file mode 100644 index 000000000..39b029b9d --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PendingRenderResultsTest.kt @@ -0,0 +1,57 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.contract.PreviewResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PendingRenderResultsTest { + + private val delivered = mutableListOf() + private val pending = PendingRenderResults { delivered += it } + + @Test + fun `result is held until the render is confirmed`() { + pending.await(PreviewResult.Success("a")) + + assertTrue(delivered.isEmpty()) + + pending.confirm("a") + + assertEquals(listOf(PreviewResult.Success("a")), delivered) + } + + @Test + fun `result is delivered once`() { + pending.await(PreviewResult.Success("a")) + + pending.confirm("a") + pending.confirm("a") + + assertEquals(1, delivered.size) + } + + @Test + fun `confirmation of another request does not release the result`() { + pending.await(PreviewResult.Success("b")) + + pending.confirm("a") + + assertTrue(delivered.isEmpty()) + } + + @Test + fun `an unconfirmed result is superseded by the next one`() { + pending.await(PreviewResult.Success("a")) + pending.await(PreviewResult.Success("b")) + + assertEquals(1, delivered.size) + val failure = delivered.single() as PreviewResult.Failure + assertEquals("a", failure.requestId) + assertEquals("superseded", failure.code) + + pending.confirm("b") + + assertEquals(PreviewResult.Success("b"), delivered.last()) + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt new file mode 100644 index 000000000..4c28dea69 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/PreviewRequestCoordinatorTest.kt @@ -0,0 +1,151 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.runtime.Composable +import com.sdds.preview.compose.PreparedComposePreview +import com.sdds.preview.compose.PreviewPreparationException +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewResult +import com.sdds.preview.contract.PreviewWarning +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class PreviewRequestCoordinatorTest { + @Test + fun `successful preparation applies preview before returning success`() = runTest { + val applied = mutableListOf() + val coordinator = PreviewRequestCoordinator( + prepare = { FakePreview("first") }, + apply = { applied += it.requestId }, + ) + + val result = coordinator.submit(payloadJson("first")) + + assertEquals(listOf("first"), applied) + assertEquals(PreviewResult.Success("first"), result) + } + + @Test + fun `warnings of the prepared preview are returned with success`() = runTest { + val warning = PreviewWarning("unknown_state", "fancy", "$.component.properties.a.states[0]") + val coordinator = PreviewRequestCoordinator( + prepare = { FakePreview("first", listOf(warning)) }, + apply = {}, + ) + + assertEquals(PreviewResult.Success("first", listOf(warning)), coordinator.submit(payloadJson("first"))) + } + + @Test + fun `reference errors keep their stable code and path and leave state untouched`() = runTest { + var applied = "previous" + val coordinator = PreviewRequestCoordinator( + prepare = { throw PreviewPreparationException("style_ref_cycle", "cycle", "$.styles.a") }, + apply = { applied = it.requestId }, + ) + + val result = assertIs(coordinator.submit(payloadJson("first"))) + + assertEquals("style_ref_cycle", result.code) + assertEquals("$.styles.a", result.path) + assertEquals("previous", applied) + } + + @Test + fun `invalid payload returns correlated stable failure and keeps state`() = runTest { + var applied = "previous" + val coordinator = PreviewRequestCoordinator( + prepare = { error("must not prepare") }, + apply = { applied = it.requestId }, + ) + + val result = coordinator.submit("""{"requestId":"broken","platform":"COMPOSE"}""") + + assertEquals("previous", applied) + assertEquals("broken", result.requestId) + assertEquals("invalid_payload", assertIs(result).code) + } + + @Test + fun `sequential payload replaces applied preview`() = runTest { + var applied = "" + val coordinator = PreviewRequestCoordinator( + prepare = { json -> FakePreview(requestId(json)) }, + apply = { applied = it.requestId }, + ) + + coordinator.submit(payloadJson("first")) + val result = coordinator.submit(payloadJson("second")) + + assertEquals("second", applied) + assertEquals(PreviewResult.Success("second"), result) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `older overlapping request is superseded and cannot replace newer preview`() = runTest { + val releaseFirst = CompletableDeferred() + var applied = "" + val coordinator = PreviewRequestCoordinator( + prepare = { json -> + requestId(json).also { if (it == "first") releaseFirst.await() }.let(::FakePreview) + }, + apply = { applied = it.requestId }, + ) + + val first = async { coordinator.submit(payloadJson("first")) } + runCurrent() + val second = coordinator.submit(payloadJson("second")) + releaseFirst.complete(Unit) + + assertEquals(PreviewResult.Success("second"), second) + assertEquals("superseded", assertIs(first.await()).code) + assertEquals("second", applied) + } + + @Test + fun `preparation failures use stable category and preserve previous preview`() = runTest { + var applied = "previous" + val coordinator = PreviewRequestCoordinator( + prepare = { error("Font asset response was HTTP 404") }, + apply = { applied = it.requestId }, + ) + + val result = assertIs(coordinator.submit(payloadJson("font"))) + + assertEquals("asset_loading_failed", result.code) + assertEquals("font", result.requestId) + assertEquals("previous", applied) + } + + private fun payloadJson(requestId: String): String = PreviewContractJson.encodeToString( + PreviewPayload( + requestId = requestId, + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent(id = "BasicButton"), + example = PreviewExample(id = "BasicButton"), + ), + ) + + private fun requestId(json: String): String = + PreviewContractJson.decodeFromString(json).requestId + + private class FakePreview( + override val requestId: String, + override val warnings: List = emptyList(), + ) : PreparedComposePreview { + @Composable + override fun Content() = Unit + } +} diff --git a/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt new file mode 100644 index 000000000..ca03cecef --- /dev/null +++ b/integration-core/preview-compose-plugin/src/commonTest/kotlin/com/sdds/preview/compose/plugin/StoryRegistryEntriesTest.kt @@ -0,0 +1,76 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.sandbox.ComponentKey +import com.sdds.sandbox.StoryRegistry +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Проверяет записи реестра историй, сгенерированные `sandbox-ksp` для реальных историй `uikit-compose-fixtures`. */ +class StoryRegistryEntriesTest { + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + @Test + fun `entries are unique and ordered by component id`() { + val ids = StoryRegistry.previewEntries().map { it.componentId } + + assertTrue(ids.isNotEmpty()) + assertEquals(ids.distinct(), ids) + assertEquals(ids.sorted(), ids) + } + + @Test + fun `entry uses the api meta name and exposes the style class`() { + val entries = StoryRegistry.previewEntries().associateBy { it.componentId } + + val checkBox = assertNotNull(entries["CheckBox"]) + assertEquals("com.sdds.compose.uikit.CheckBoxStyle", checkBox.styleQualifiedName) + // ComponentKey.CheckBox.value это "Checkbox": идентификатор preview берётся из меты, а не из ключа. + assertEquals("Checkbox", ComponentKey.CheckBox.value) + assertEquals(ComponentKey.CheckBox, checkBox.story.component) + assertEquals("com.sdds.compose.uikit.ButtonStyle", entries.getValue("BasicButton").styleQualifiedName) + } + + @Test + fun `stories without component id stay in the registry but are not preview entries`() { + val ids = StoryRegistry.previewEntries().map { it.componentId }.toSet() + + listOf("Icon", "IconTabs", "Mask", "Text").forEach { id -> + assertTrue(id !in ids, "$id не должна участвовать в preview") + } + assertNotNull(StoryRegistry.get(ComponentKey.Text)) + assertNotNull(StoryRegistry.get(ComponentKey.IconTabs)) + assertNull(StoryRegistry.previewEntries().firstOrNull { it.story === StoryRegistry.get(ComponentKey.Text) }) + } + + @Test + fun `bottom sheet story is registered under the api meta name of its style`() { + val entry = assertNotNull(StoryRegistry.previewEntries().firstOrNull { it.componentId == "ModalBottomSheet" }) + + assertEquals("com.sdds.compose.uikit.ModalBottomSheetStyle", entry.styleQualifiedName) + assertEquals(ComponentKey.BottomSheet, entry.story.component) + } + + @Test + fun `item components have their own stories`() { + val ids = StoryRegistry.previewEntries().map { it.componentId }.toSet() + + listOf( + "AccordionItem", "DropdownEmptyState", "DropdownLoadingState", "IconTabItem", "ListItem", + "NavigationDrawerItem", "SelectItem", "TabBarItem", "TabItem", + ).forEach { id -> assertTrue(id in ids, "Нет истории для $id") } + } + + @Test + fun `all stories with a component id are registered`() { + assertEquals(79, StoryRegistry.previewEntries().size) + } +} diff --git a/integration-core/preview-compose-plugin/src/icons/preview-icons.txt b/integration-core/preview-compose-plugin/src/icons/preview-icons.txt new file mode 100644 index 000000000..2b8026aa2 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/icons/preview-icons.txt @@ -0,0 +1,26 @@ +arrow.left.24 +chevron.left.24 +chevron.right.24 +close.16 +close.24 +close.36 +disclosure.down.fill.16 +disclosure.down.fill.24 +disclosure.down.outline.24 +disclosure.down.outline.36 +disclosure.left.outline.24 +disclosure.right.outline.16 +disclosure.right.outline.24 +disclosure.up.fill.16 +disclosure.up.fill.24 +disclosure.up.outline.24 +disclosure.up.outline.36 +done.16 +done.24 +dots.horizontal.outline.24 +info.circle.outline.16 +info.circle.outline.24 +minus.16 +minus.24 +plus.16 +plus.24 diff --git a/integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt b/integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt new file mode 100644 index 000000000..161209c87 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmMain/kotlin/com/sdds/preview/compose/plugin/PreviewComponentGroup.kt @@ -0,0 +1,56 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.sandbox.StoryEntry +import com.sdds.sandbox.StoryRegistry +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.File + +/** Группа компонентов песочницы: имя и компоненты по алфавиту. */ +internal data class PreviewComponentGroup( + val name: String, + val components: List, +) + +/** + * Группы компонентов по реестру историй: порядок групп как в песочнице (`ComponentGroup.ordinal`), внутри группы + * компоненты по алфавиту. Группа компонента — группа ключа его истории. + */ +internal fun previewComponentGroups(entries: List): List = + entries.groupBy { it.story.component.group } + .toSortedMap(compareBy({ it.ordinal }, { it.displayName })) + .map { (group, members) -> + PreviewComponentGroup(group.displayName, members.map { it.componentId }.sorted()) + } + +/** JSON `{"groups": [{"name": ..., "components": [...]}]}`, который `generatePreviewManifest` кладёт в манифест. */ +internal fun List.toManifestJson(): String = + Json { prettyPrint = true }.encodeToString( + JsonObject.serializer(), + JsonObject( + mapOf( + "groups" to JsonArray( + map { group -> + JsonObject( + mapOf( + "name" to JsonPrimitive(group.name), + "components" to JsonArray(group.components.map(::JsonPrimitive)), + ), + ) + }, + ), + ), + ), + ) + +/** Запускается Gradle-задачей `generatePreviewGroups`: первый аргумент — путь выходного файла. */ +public fun main(args: Array) { + require(args.size == 1) { "Ожидается путь выходного файла групп" } + StoryRegistry.clear() + registerStories() + File(args.single()).apply { parentFile.mkdirs() } + .writeText(previewComponentGroups(StoryRegistry.previewEntries()).toManifestJson() + "\n") +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt new file mode 100644 index 000000000..e48c9811f --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ComposeTestSupport.kt @@ -0,0 +1,30 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import kotlin.coroutines.EmptyCoroutineContext + +private class UnitApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit +} + +/** Выполняет composable один раз без UI и возвращает его результат. */ +internal fun composeOnce(content: @Composable () -> T): T { + var result: Any? = null + val recomposer = Recomposer(EmptyCoroutineContext) + val composition = Composition(UnitApplier(), recomposer) + try { + composition.setContent { result = content() } + } finally { + composition.dispose() + recomposer.cancel() + } + @Suppress("UNCHECKED_CAST") + return result as T +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt new file mode 100644 index 000000000..4eded2077 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValidationTest.kt @@ -0,0 +1,74 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.compose.PreviewPreparationException +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.PreviewStyle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** Проверка типов вложенных стилей на настоящих сгенерированных фабриках `Tabs`, `TabItem` и `Avatar`. */ +class GeneratedFactoryValidationTest { + private fun ref(styleId: String) = PreviewPropertyValue(PreviewPropertySource.StyleReference(styleId)) + + private fun validate( + componentId: String, + properties: Map, + styles: Map, + ) { + val payload = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent(componentId, properties = properties), + styles = styles, + example = PreviewExample("default"), + ) + GeneratedPreviewStyleFactories.all.getValue(componentId) + .validateNestedStyles(payload, properties, "$.component.properties") + } + + @Test + fun `tabs accept item and divider styles of the matching components`() { + validate( + "Tabs", + mapOf("tabItemStyle" to ref("item"), "dividerStyle" to ref("divider")), + mapOf("item" to PreviewStyle("TabItem"), "divider" to PreviewStyle("Divider")), + ) + } + + @Test + fun `item style of the icon tab component is accepted because it shares the style class`() { + validate("Tabs", mapOf("tabItemStyle" to ref("item")), mapOf("item" to PreviewStyle("IconTabItem"))) + } + + @Test + fun `divider style given to an item property is rejected`() { + val error = assertFailsWith { + validate("Tabs", mapOf("tabItemStyle" to ref("s")), mapOf("s" to PreviewStyle("Divider"))) + } + + assertEquals("style_type_mismatch", error.code) + assertEquals("$.component.properties.tabItemStyle", error.path) + } + + @Test + fun `style of an unknown component is rejected`() { + val error = assertFailsWith { + validate("Avatar", mapOf("badgeStyle" to ref("s")), mapOf("s" to PreviewStyle("Nope"))) + } + + assertEquals("style_component_not_found", error.code) + } + + @Test + fun `nested badge style is accepted for avatar`() { + validate("Avatar", mapOf("badgeStyle" to ref("b")), mapOf("b" to PreviewStyle("Badge"))) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt new file mode 100644 index 000000000..b827bedb3 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/GeneratedFactoryValueKindsTest.kt @@ -0,0 +1,120 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import com.sdds.compose.uikit.ButtonStyle +import com.sdds.compose.uikit.CodeFieldStyle +import com.sdds.compose.uikit.TabsStyle +import com.sdds.compose.uikit.graphics.Gradients +import com.sdds.compose.uikit.interactions.InteractiveState +import com.sdds.preview.compose.PreviewStateMap +import com.sdds.preview.compose.ResolvedProperty +import com.sdds.preview.compose.ResolvedState +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.prepareComposeTheme +import com.sdds.preview.compose.toInteractiveColor +import com.sdds.preview.contract.GradientLayer +import com.sdds.preview.contract.GradientStop +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.TokenValue +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Виды значений, которые не покрывает сравнение с эталоном: градиент, literal-цвет, `InteractiveColor`. */ +class GeneratedFactoryValueKindsTest { + private val gradient = TokenValue.Gradient( + listOf( + GradientLayer(90.0, listOf(GradientStop("#FF0000FF", 0.0), GradientStop("#0000FFFF", 1.0))), + ), + ) + + private fun payload(componentId: String, properties: Map) = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = mapOf("gradient.main" to gradient, "color.red" to TokenValue.Color("#FF0000FF")), + component = PreviewComponent(componentId, properties = properties), + example = PreviewExample("default"), + ) + + @Suppress("UNCHECKED_CAST") + private fun build(payload: PreviewPayload): S { + val factory = GeneratedPreviewStyleFactories.all.getValue(payload.component.id) + val theme = payload.prepareComposeTheme(mapOf("font.body" to FontFamily.Default)) + return composeOnce { factory.create(payload, theme) } as S + } + + private fun token(id: String, alpha: Double? = null) = PreviewPropertyValue( + PreviewPropertySource.TokenReference(id, alpha), + ) + + @Test + fun `gradient token goes to the Brush overload of a colour property`() { + val style = build(payload("BasicButton", mapOf("backgroundColor" to token("gradient.main")))) + + val expected = Gradients.Linear(listOf(Color(0xFFFF0000), Color(0xFF0000FF)), listOf(0f, 1f), 90f) + assertEquals(expected, style.colors.backgroundBrush.getDefaultValue()) + } + + @Test + fun `gradient switches to the Brush overload when a solid colour uses another overload`() { + // У CodeField.valueColor для сплошного цвета выбирается StatefulValue, а для градиента только Brush. + val solid = build(payload("CodeField", mapOf("valueColor" to token("color.red")))) + val gradientStyle = build(payload("CodeField", mapOf("valueColor" to token("gradient.main")))) + + assertEquals(Color.Red, solid.colors.valueColor.getDefaultValue()) + val expected = Gradients.Linear(listOf(Color(0xFFFF0000), Color(0xFF0000FF)), listOf(0f, 1f), 90f) + assertEquals(expected, gradientStyle.colors.valueBrush.getDefaultValue()) + } + + @Test + fun `gradient for a colour property without a Brush overload is skipped and builds anyway`() { + val factory = GeneratedPreviewStyleFactories.all.getValue("Tabs") + assertTrue("backgroundColor" in factory.gradientUnsupportedProperties) + + val style = build(payload("Tabs", mapOf("backgroundColor" to token("gradient.main")))) + + assertTrue(style.shape.toString().isNotEmpty()) + } + + @Test + fun `literal colour and its alpha are applied`() { + val literal = PreviewPropertySource.Literal(JsonPrimitive("#112233FF")) + val withAlpha = PreviewPropertySource.Literal(JsonPrimitive("#112233FF"), alpha = 0.5) + + val plain = build(payload("BasicButton", mapOf("labelColor" to PreviewPropertyValue(literal)))) + val translucent = + build(payload("BasicButton", mapOf("labelColor" to PreviewPropertyValue(withAlpha)))) + + assertEquals(SolidColor(Color(0xFF112233)), plain.colors.labelBrush.getDefaultValue()) + assertEquals(SolidColor(Color(0xFF112233).copy(alpha = 0.5f)), translucent.colors.labelBrush.getDefaultValue()) + } + + @Test + fun `interactive colour picks the largest matching state set and the later entry among equal sets`() { + val property = ResolvedProperty( + base = Color.Red, + states = listOf( + ResolvedState(setOf("pressed"), Color.Green), + ResolvedState(setOf("pressed"), Color.Blue), + ResolvedState(setOf("pressed", "selected"), Color.White), + ), + ) + val color = property.toInteractiveColor(PreviewStateMap()) + val source = MutableInteractionSource() + + fun probe(vararg states: InteractiveState) = composeOnce { color.colorForInteraction(source, states.toSet()) } + + assertEquals(Color.Red, probe()) + assertEquals(Color.Blue, probe(InteractiveState.Pressed)) + assertEquals(Color.White, probe(InteractiveState.Pressed, InteractiveState.Selected)) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt new file mode 100644 index 000000000..a18f09525 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/HostDefaultsTest.kt @@ -0,0 +1,88 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import com.sdds.compose.uikit.LocalButtonStyle +import com.sdds.compose.uikit.LocalTextStyle +import com.sdds.preview.compose.ProvideHostDefaults +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.prepareComposeTheme +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewDefaultStyles +import com.sdds.preview.contract.PreviewDefaultText +import com.sdds.preview.contract.PreviewDefaults +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.PreviewStyle +import com.sdds.preview.contract.TokenValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class HostDefaultsTest { + private fun token(id: String) = PreviewPropertyValue(PreviewPropertySource.TokenReference(id)) + + private fun payload(defaults: PreviewDefaults?) = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = mapOf("color.red" to TokenValue.Color("#FF0000FF"), "color.green" to TokenValue.Color("#00FF00FF")), + component = PreviewComponent("Avatar", properties = emptyMap()), + styles = mapOf("button.default" to PreviewStyle("BasicButton", mapOf("labelColor" to token("color.red")))), + example = PreviewExample("default"), + defaults = defaults, + ) + + /** Значения локалей внутри содержимого: цвет подписи кнопки по умолчанию и цвет текста. */ + private fun provided(payload: PreviewPayload): Pair { + val theme = payload.prepareComposeTheme(mapOf("font.body" to FontFamily.Default)) + return composeOnce { + var result: Pair? = null + ProvideHostDefaults(payload, theme, GeneratedPreviewStyleFactories.all) { + result = LocalButtonStyle.current.colors.labelBrush.getDefaultValue() to LocalTextStyle.current.color + } + result!! + } + } + + @Test + fun `the default button style and text colour from the payload are provided to the content`() { + val (label, text) = provided( + payload( + PreviewDefaults( + text = PreviewDefaultText(color = "color.green"), + styles = PreviewDefaultStyles(button = "button.default"), + ), + ), + ) + + assertEquals(SolidColor(Color.Red), label) + assertEquals(Color(0xFF00FF00), text) + } + + @Test + fun `without defaults the library values stay`() { + val (label, text) = provided(payload(null)) + + assertNotEquals(SolidColor(Color.Red), label) + assertNotEquals(Color(0xFF00FF00), text) + } + + @Test + fun `an unknown style or token is skipped without failing`() { + val (label, text) = provided( + payload( + PreviewDefaults( + text = PreviewDefaultText(color = "color.missing"), + styles = PreviewDefaultStyles(button = "button.missing"), + ), + ), + ) + + assertNotEquals(SolidColor(Color.Red), label) + assertNotEquals(Color(0xFF00FF00), text) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt new file mode 100644 index 000000000..93a670b88 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewComponentsFromTest.kt @@ -0,0 +1,56 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.previewComponentsFrom +import com.sdds.sandbox.StoryEntry +import com.sdds.sandbox.StoryRegistry +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class PreviewComponentsFromTest { + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + private val entries get() = StoryRegistry.previewEntries() + private val factories = GeneratedPreviewStyleFactories.all + + @Test + fun `every registry entry becomes a component with its api meta id`() { + val components = previewComponentsFrom(entries, factories) + + assertEquals(entries.map { it.componentId }, components.map { it.componentId }) + assertTrue(components.any { it.componentId == "CheckBox" }) + } + + @Test + fun `entry without a factory is skipped instead of failing`() { + val orphan = entries.first().copy(componentId = "NoSuchComponent") + + val components = previewComponentsFrom(entries + orphan, factories) + + assertEquals(entries.size, components.size) + } + + @Test + fun `style class of the story must match the style class of the factory`() { + val wrong = StoryEntry("CheckBox", entries.first { it.componentId == "CheckBox" }.story, "ui.WrongStyle") + + val error = assertFailsWith { previewComponentsFrom(listOf(wrong), factories) } + + assertTrue("CheckBox" in error.message.orEmpty() && "ui.WrongStyle" in error.message.orEmpty()) + } + + @Test + fun `entry whose style class was not resolved is rejected`() { + val unresolved = entries.first { it.componentId == "CheckBox" }.copy(styleQualifiedName = "") + + assertFailsWith { previewComponentsFrom(listOf(unresolved), factories) } + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt new file mode 100644 index 000000000..bb56601e2 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/PreviewGroupsTest.kt @@ -0,0 +1,60 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.sandbox.StoryRegistry +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PreviewGroupsTest { + private val entries get() = StoryRegistry.previewEntries() + + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + @Test + fun `every component of the registry is in exactly one group`() { + val grouped = previewComponentGroups(entries).flatMap { it.components } + + assertEquals(entries.map { it.componentId }.sorted(), grouped.sorted()) + assertEquals(grouped.distinct().size, grouped.size) + } + + @Test + fun `components follow the sandbox groups`() { + val byComponent = previewComponentGroups( + entries, + ).flatMap { group -> group.components.map { it to group.name } }.toMap() + + assertEquals("Data Display", byComponent["CircularProgressBar"]) + assertEquals("Data Display", byComponent["ProgressBar"]) + assertEquals("Navigation", byComponent["DropdownMenu"]) + assertEquals("Data Entry", byComponent["BasicButton"]) + assertEquals("Overlay", byComponent["ModalBottomSheet"]) + } + + @Test + fun `groups keep the sandbox order and components are sorted`() { + val groups = previewComponentGroups(entries) + + assertEquals( + entries.map { + it.story.component.group + }.distinct().sortedBy { it.ordinal }.map { it.displayName }, + groups.map { it.name }, + ) + assertTrue(groups.all { it.components == it.components.sorted() }) + } + + @Test + fun `the manifest json lists groups with their components`() { + val json = listOf(PreviewComponentGroup("Data Display", listOf("Avatar", "Badge"))).toManifestJson() + + assertTrue("\"name\": \"Data Display\"" in json) + assertTrue("\"Avatar\"" in json && "\"Badge\"" in json) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt new file mode 100644 index 000000000..8e3e325e3 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/ReferenceStyleComparisonTest.kt @@ -0,0 +1,244 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sdds.compose.uikit.ButtonStyle +import com.sdds.compose.uikit.CheckBoxStates +import com.sdds.compose.uikit.CheckBoxStyle +import com.sdds.compose.uikit.DividerStyle +import com.sdds.compose.uikit.TabItemStyle +import com.sdds.compose.uikit.TabsStyle +import com.sdds.compose.uikit.basicButtonBuilder +import com.sdds.compose.uikit.interactions.InteractiveState +import com.sdds.compose.uikit.interactions.StatefulValue +import com.sdds.compose.uikit.interactions.ValueState +import com.sdds.compose.uikit.interactions.asStatefulValue +import com.sdds.preview.compose.PreparedComposeTheme +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.prepareComposeTheme +import com.sdds.preview.contract.PreviewComponent +import com.sdds.preview.contract.PreviewExample +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewPlatform +import com.sdds.preview.contract.PreviewPropertySource +import com.sdds.preview.contract.PreviewPropertyValue +import com.sdds.preview.contract.PreviewStateEntry +import com.sdds.preview.contract.PreviewStyle +import com.sdds.preview.contract.TokenValue +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Сгенерированная фабрика должна строить тот же стиль, что и прямой вызов билдера UIKit с теми же значениями. + * Эталон написан вручную и не использует помощники SDK (`toStateful`, выбор перегрузки): сравнивается поведение + * стиля (`getValue` для наборов состояний) и значения, которые видит компонент. По одному компоненту на фазу: + * A (BasicButton), B (CheckBox, кастомные состояния), C (Tabs, вложенные стили). + */ +class ReferenceStyleComparisonTest { + private val background = Color(0xFF2A72E5) + private val pressed = Color(0xFF1959B8) + private val content = Color(0xFF202124) + private val shape = RoundedCornerShape(12.dp, 12.dp, 12.dp, 12.dp) + private val label = TextStyle( + fontFamily = FontFamily.Default, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, + fontWeight = FontWeight(400), + ) + + private val tokens: Map = mapOf( + "color.background" to TokenValue.Color("#2A72E5FF"), + "color.background.pressed" to TokenValue.Color("#1959B8FF"), + "color.content" to TokenValue.Color("#202124FF"), + "size.height" to TokenValue.Dimension(48.0), + "size.padding" to TokenValue.Dimension(20.0), + "shape.button" to TokenValue.Shape(listOf(12.0, 12.0, 12.0, 12.0)), + "font.body" to TokenValue.FontFamily(emptyList(), allowFallback = true), + "typography.button" to TokenValue.Typography("font.body", 16.0, 20.0, 0.0, 400), + ) + + private fun token(id: String, alpha: Double? = null) = PreviewPropertySource.TokenReference(id, alpha) + + private fun entry(source: PreviewPropertySource, vararg states: String) = PreviewStateEntry(states.toList(), source) + + private fun payload( + componentId: String, + properties: Map, + styles: Map = emptyMap(), + ) = PreviewPayload( + requestId = "r", + platform = PreviewPlatform.COMPOSE, + theme = tokens, + component = PreviewComponent(componentId, properties = properties), + styles = styles, + example = PreviewExample("default"), + ) + + private fun theme(payload: PreviewPayload): PreparedComposeTheme = + payload.prepareComposeTheme(mapOf("font.body" to FontFamily.Default)) + + @Suppress("UNCHECKED_CAST") + private fun generated(payload: PreviewPayload): S { + val factory = GeneratedPreviewStyleFactories.all.getValue(payload.component.id) + val theme = theme(payload) + return composeOnce { factory.create(payload, theme) } as S + } + + private fun StatefulValue.probe(vararg sets: Set): List = sets.map { getValue(it) } + + @Test + fun `BasicButton factory matches a style built by hand`() { + val payload = payload( + "BasicButton", + mapOf( + "shape" to PreviewPropertyValue(token("shape.button")), + "labelStyle" to PreviewPropertyValue(token("typography.button")), + "disableAlpha" to PreviewPropertyValue(PreviewPropertySource.Literal(JsonPrimitive(0.4))), + "height" to PreviewPropertyValue(token("size.height")), + "paddingStart" to PreviewPropertyValue(token("size.padding")), + "backgroundColor" to PreviewPropertyValue( + base = token("color.background"), + states = listOf( + entry(token("color.background.pressed"), "pressed"), + entry(token("color.background", alpha = 0.5), "pressed", "selected"), + // Две записи для одного набора: побеждает последняя, как задаёт Preview Protocol. + entry(token("color.content"), "pressed"), + ), + ), + ), + ) + val actual = generated(payload) + + val expected = composeOnce { + ButtonStyle.basicButtonBuilder() + .shape(shape) + .labelStyle(label) + .disableAlpha(0.4f) + .colors { + backgroundColor( + SolidColor(background).asStatefulValue( + setOf(InteractiveState.Pressed) to SolidColor(content), + setOf(InteractiveState.Pressed, InteractiveState.Selected) to + SolidColor(background.copy(alpha = 0.5f)), + ), + ) + } + .dimensions { + height(48.dp) + paddingStart(20.dp) + } + .style() + } + + assertEquals(expected.shapes.getDefaultValue(), actual.shapes.getDefaultValue()) + assertEquals(expected.labelStyles.getDefaultValue(), actual.labelStyles.getDefaultValue()) + assertEquals(expected.disableAlpha, actual.disableAlpha) + assertEquals( + expected.dimensions.heightValues.getDefaultValue(), + actual.dimensions.heightValues.getDefaultValue(), + ) + assertEquals( + expected.dimensions.paddingStartValues.getDefaultValue(), + actual.dimensions.paddingStartValues.getDefaultValue(), + ) + val probes = arrayOf>( + emptySet(), + setOf(InteractiveState.Pressed), + setOf(InteractiveState.Hovered), + setOf(InteractiveState.Pressed, InteractiveState.Selected), + ) + assertEquals(expected.colors.backgroundBrush.probe(*probes), actual.colors.backgroundBrush.probe(*probes)) + } + + @Test + fun `CheckBox factory matches a style built by hand including custom and combined states`() { + val payload = payload( + "CheckBox", + mapOf( + "shape" to PreviewPropertyValue(token("shape.button")), + "toggleColor" to PreviewPropertyValue( + base = token("color.background"), + states = listOf( + entry(token("color.background.pressed"), "checked"), + entry(token("color.background", alpha = 0.5), "Checked", "pressed"), + entry(token("color.content"), "indeterminate"), + ), + ), + ), + ) + val actual = generated(payload) + + val expected = composeOnce { + CheckBoxStyle.builder() + .shape(shape) + .colorValues { + toggleColor( + SolidColor(background).asStatefulValue( + setOf(CheckBoxStates.Checked) to SolidColor(pressed), + setOf(CheckBoxStates.Checked, InteractiveState.Pressed) to + SolidColor(background.copy(alpha = 0.5f)), + setOf(CheckBoxStates.Indeterminate) to SolidColor(content), + ), + ) + } + .style() + } + + assertEquals(expected.shapes.getDefaultValue(), actual.shapes.getDefaultValue()) + val probes = arrayOf>( + emptySet(), + setOf(CheckBoxStates.Checked), + setOf(CheckBoxStates.Checked, InteractiveState.Pressed), + setOf(CheckBoxStates.Indeterminate), + setOf(CheckBoxStates.Error), + ) + assertEquals(expected.colorValues.toggleBrush.probe(*probes), actual.colorValues.toggleBrush.probe(*probes)) + } + + @Test + fun `Tabs factory matches a style built by hand including nested item and divider styles`() { + fun ref(id: String) = PreviewPropertyValue(PreviewPropertySource.StyleReference(id)) + val payload = payload( + "Tabs", + mapOf("tabItemStyle" to ref("item"), "dividerStyle" to ref("divider")), + mapOf( + "item" to PreviewStyle( + "TabItem", + mapOf( + "shape" to PreviewPropertyValue(token("shape.button")), + "labelStyle" to PreviewPropertyValue(token("typography.button")), + ), + ), + "divider" to PreviewStyle( + "Divider", + mapOf("backgroundColor" to PreviewPropertyValue(token("color.content"))), + ), + ), + ) + val actual = generated(payload) + + val expected = composeOnce { + val item = TabItemStyle.builder().shape(shape).labelStyle(label).style() + val divider = DividerStyle.builder().color { backgroundColor(content) }.style() + TabsStyle.builder().tabItemStyle(item).dividerStyle(divider).style() + } + + assertEquals(expected.tabItemStyle.shape, actual.tabItemStyle.shape) + assertEquals( + expected.tabItemStyle.labelStyles.getDefaultValue(), + actual.tabItemStyle.labelStyles.getDefaultValue(), + ) + assertEquals( + expected.dividerStyle.color.backgroundBrush.getDefaultValue(), + actual.dividerStyle.color.backgroundBrush.getDefaultValue(), + ) + } +} diff --git a/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt new file mode 100644 index 000000000..db7cca6dc --- /dev/null +++ b/integration-core/preview-compose-plugin/src/jvmTest/kotlin/com/sdds/preview/compose/plugin/StyleFactoryCoverageTest.kt @@ -0,0 +1,91 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.sandbox.StoryRegistry +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Сверяет три набора: компоненты `uikit-compose-api-meta.json`, сгенерированные фабрики стилей и истории реестра. + * Расхождение, которого нет в [storiesWithoutPreview], роняет тест. + */ +class StyleFactoryCoverageTest { + /** Истории без компонента меты: `ComponentKey.value` на причину. */ + private val storiesWithoutPreview = mapOf( + "Icon" to "стиль NoStyle: у иконки нет настраиваемого стиля", + "Text" to "стиль NoStyle: у текста нет настраиваемого стиля", + "IconTabs" to "компонента IconTabs нет в мете, стиль TabsStyle принадлежит компоненту Tabs", + "Mask" to "компонента Mask нет в мете, стиль TextFieldStyle принадлежит TextField и TextArea", + ) + + private val metaStyles: Map = run { + val text = requireNotNull( + javaClass.classLoader.getResource("sdds/api/uikit-compose-api-meta.json"), + ) { "Нет ресурса api-меты на classpath" }.readText() + Json.parseToJsonElement(text).jsonArray.associate { + val component = it.jsonObject + component.getValue("componentName").jsonPrimitive.content to + component.getValue("styleQualifiedName").jsonPrimitive.content + } + } + + private val factories = GeneratedPreviewStyleFactories.all + + @BeforeTest + fun register() { + StoryRegistry.clear() + registerStories() + } + + @Test + fun `every api meta component has a generated factory and nothing else does`() { + assertEquals(metaStyles.keys, factories.keys) + } + + @Test + fun `factory builds the style class named in the api meta`() { + metaStyles.forEach { (name, style) -> + assertEquals(style, factories.getValue(name).styleQualifiedName, "Стиль фабрики $name") + } + } + + @Test + fun `every api meta component has a story and its style class matches the factory`() { + val entries = StoryRegistry.previewEntries().associateBy { it.componentId } + + assertEquals(metaStyles.keys, entries.keys) + entries.forEach { (id, entry) -> + assertEquals( + factories.getValue(id).styleQualifiedName, + entry.styleQualifiedName, + "Класс стиля истории и фабрики $id", + ) + } + } + + @Test + fun `stories outside the preview registry are exactly the documented exclusions`() { + val previewKeys = StoryRegistry.previewEntries().map { it.story.component }.toSet() + val excluded = StoryRegistry.registeredStories().map { it.component }.filterNot { it in previewKeys } + + assertEquals(storiesWithoutPreview.keys, excluded.map { it.value }.toSet()) + } + + @Test + fun `every nested style type is built by some factory`() { + val builtStyles = factories.values.map { it.styleQualifiedName }.toSet() + + factories.forEach { (name, factory) -> + factory.nestedStyleTypes.forEach { (property, style) -> + assertTrue(style in builtStyles, "$name.$property ожидает $style, которого не строит ни одна фабрика") + } + } + } +} diff --git a/integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json b/integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json new file mode 100644 index 000000000..b0aad9ac2 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/manifest/preview-plugin.template.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "sdds.compose.preview", + "name": "SDDS Compose Preview", + "platform": "compose", + "protocolVersion": 1, + "entrypoint": "index.html", + "payloadBridge": { + "direct": "submitPreviewPayload", + "describe": "describePreviewComponent", + "payloadMessageType": "sdds.preview.payload", + "readyMessageType": "sdds.preview.ready", + "resultMessageType": "sdds.preview.result", + "describeMessageType": "sdds.preview.describe", + "descriptionMessageType": "sdds.preview.description" + }, + "components": [] +} diff --git a/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt new file mode 100644 index 000000000..7b70a9221 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/HttpPreviewAssetResolver.kt @@ -0,0 +1,27 @@ +package com.sdds.preview.compose.plugin + +import com.sdds.preview.compose.PreviewAssetResolver +import com.sdds.preview.contract.PreviewAsset +import kotlinx.coroutines.await +import org.khronos.webgl.Int8Array +import kotlin.js.Promise + +/** Browser resolver, загружающий TTF/OTF assets по URL из payload. */ +public object HttpPreviewAssetResolver : PreviewAssetResolver { + override suspend fun resolve(asset: PreviewAsset): ByteArray { + val bytes: Int8Array = fetchBytes(asset.url).await() + require(bytes.length > 0) { "Ресурс ${asset.id} не содержит данных" } + return ByteArray(bytes.length) { index -> byteAt(bytes, index) } + } +} + +@JsFun( + """(url) => fetch(url).then(response => { + if (!response.ok) throw new Error(`HTTP ${'$'}{response.status} for ${'$'}{url}`); + return response.arrayBuffer(); + }).then(buffer => new Int8Array(buffer))""", +) +private external fun fetchBytes(url: String): Promise + +@JsFun("(array, index) => array[index]") +private external fun byteAt(array: Int8Array, index: Int): Byte diff --git a/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt new file mode 100644 index 000000000..76371f2f1 --- /dev/null +++ b/integration-core/preview-compose-plugin/src/wasmJsMain/kotlin/com/sdds/preview/compose/plugin/Main.kt @@ -0,0 +1,165 @@ +package com.sdds.preview.compose.plugin + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.window.ComposeViewport +import com.sdds.compose.uikit.fixtures.stories.registerStories +import com.sdds.compose.uikit.overlay.OverlayHost +import com.sdds.compose.uikit.overlay.OverlayManagerLifecycle +import com.sdds.compose.uikit.overlay.overlayManager +import com.sdds.preview.compose.CachingLoadedFontFamilyFactory +import com.sdds.preview.compose.CachingPreviewAssetResolver +import com.sdds.preview.compose.ComposePreviewRuntime +import com.sdds.preview.compose.PreparedComposePreview +import com.sdds.preview.compose.WasmLoadedFontFamilyFactory +import com.sdds.preview.compose.generated.GeneratedPreviewIcons +import com.sdds.preview.compose.generated.GeneratedPreviewStyleFactories +import com.sdds.preview.compose.previewComponentsFrom +import com.sdds.preview.contract.PREVIEW_PROTOCOL_VERSION +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewResult +import com.sdds.sandbox.StoryRegistry +import kotlinx.browser.document +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlin.js.ExperimentalJsExport + +private sealed interface RenderState { + data object Empty : RenderState + data class Ready(val preview: PreparedComposePreview) : RenderState +} + +private val scope = MainScope() +private var renderState: RenderState by mutableStateOf(RenderState.Empty) +private val runtime = ComposePreviewRuntime( + // Редактор присылает одни и те же шрифты при каждом переключении компонента: байты и семейства переиспользуются. + assetResolver = CachingPreviewAssetResolver(HttpPreviewAssetResolver), + fontFamilyFactory = CachingLoadedFontFamilyFactory(WasmLoadedFontFamilyFactory), + icons = GeneratedPreviewIcons.all, + components = run { + registerStories() + previewComponentsFrom(StoryRegistry.previewEntries(), GeneratedPreviewStyleFactories.all) + }, +) +private val coordinator = PreviewRequestCoordinator( + prepare = runtime::prepare, + apply = { preview -> + renderState = RenderState.Ready(preview) + document.documentElement?.setAttribute("data-sdds-preview-request-id", preview.requestId) + }, +) + +private val pendingRenders = PendingRenderResults { result -> + notifyPreviewResult(PreviewContractJson.encodeToString(result)) +} + +/** + * Принимает следующий полный payload. Успех сообщается browser bridge только после отрисовки содержимого, + * ошибки подготовки и применения state сообщаются сразу. + */ +@OptIn(ExperimentalJsExport::class) +@JsExport +public fun submitPreviewPayload(json: String) { + scope.launch { + when (val result = coordinator.submit(json)) { + is PreviewResult.Success -> pendingRenders.await(result) + else -> notifyPreviewResult(PreviewContractJson.encodeToString(result)) + } + } +} + +/** Возвращает сериализованное описание зарегистрированной story. */ +@OptIn(ExperimentalJsExport::class) +@JsExport +public fun describePreviewComponent(componentId: String, requestId: String): String = + PreviewContractJson.encodeToString(runtime.describe(componentId, requestId)) + +/** Browser entrypoint типизированного BasicButton PoC. */ +@OptIn(ExperimentalComposeUiApi::class) +public fun main() { + if (document.getElementById("root") == null) { + document.body?.appendChild(document.createElement("div").apply { id = "root" }) + } + ComposeViewport(viewportContainerId = "root") { PreviewViewport() } + notifyPreviewReady(PREVIEW_PROTOCOL_VERSION) +} + +@Composable +private fun PreviewViewport() { + val state = renderState + val backgroundModifier = when (state) { + RenderState.Empty -> Modifier + is RenderState.Ready -> state.preview.surfaceBackground?.let { Modifier.background(it) } ?: Modifier + } + Box( + modifier = Modifier.fillMaxSize().then(backgroundModifier), + contentAlignment = Alignment.Center, + ) { + when (state) { + RenderState.Empty -> Unit + // Toast и Notification показываются через LocalOverlayManager: как и песочница, плагин предоставляет хост оверлеев. + // Каждый payload получает свой менеджер оверлеев: уведомления прошлого render не должны оставаться на экране. + is RenderState.Ready -> key(state.preview.requestId) { + // Эффект запускается после композиции содержимого; два кадра подтверждают, что layout и draw прошли. + // Исключение в кадре останавливает Compose, и подтверждения не будет. + LaunchedEffect(state.preview.requestId) { + withFrameNanos { } + withFrameNanos { } + pendingRenders.confirm(state.preview.requestId) + } + OverlayHost(overlayManager = overlayManager(OverlayManagerLifecycle.ComposableScoped)) { + // OverlayHost кладёт содержимое в левый верхний угол, поэтому центрируем его внутри хоста. + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CrashCause { state.preview.Content() } + } + } + } + } + } +} + +/** + * Исключение Kotlin/Wasm доходит до браузера как `WebAssembly.Exception` без текста и stack, поэтому причину сбоя layout + * фиксируем здесь: [measure] содержимого выполняется внутри `try`, а текст и stack уходят в `reportCrashCause`. + * Размер и положение содержимого остаются теми же, что без обёртки. Сбои композиции и отрисовки обёртка не видит. + */ +@Suppress("TooGenericExceptionCaught") // Любое исключение фиксируется и бросается дальше без изменений. +@Composable +private fun CrashCause(content: @Composable () -> Unit) { + Layout(content = content) { measurables, constraints -> + val placeables = try { + measurables.map { it.measure(constraints) } + } catch (error: Throwable) { + reportCrashCause(error.toString(), error.stackTraceToString()) + throw error + } + layout( + width = placeables.maxOfOrNull { it.width } ?: constraints.minWidth, + height = placeables.maxOfOrNull { it.height } ?: constraints.minHeight, + ) { + placeables.forEach { it.place(0, 0) } + } + } +} + +@JsFun("(result) => globalThis.__sddsPreviewResult(result)") +private external fun notifyPreviewResult(result: String) + +@JsFun("(protocolVersion) => globalThis.__sddsPreviewReady(protocolVersion)") +private external fun notifyPreviewReady(protocolVersion: Int) + +@JsFun("(message, stack) => { globalThis.__sddsPreviewCrashCause = { message, stack }; }") +private external fun reportCrashCause(message: String, stack: String) diff --git a/integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html b/integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html new file mode 100644 index 000000000..b2269957c --- /dev/null +++ b/integration-core/preview-compose-plugin/src/wasmJsMain/resources/index.html @@ -0,0 +1,257 @@ + + + + + + SDDS Compose Preview + + + +
+ + + + diff --git a/integration-core/preview-contract/README.md b/integration-core/preview-contract/README.md new file mode 100644 index 000000000..d50c7654b --- /dev/null +++ b/integration-core/preview-contract/README.md @@ -0,0 +1,42 @@ +# Preview Contract + +Kotlin Multiplatform artifact `io.github.salute-developers:preview-contract` +описывает минимальную renderer-ready инструкцию DS Builder Preview Protocol v1. +Модуль не зависит от Compose, Android UI, renderer или `sandbox-*`. + +## Модель + +`PreviewPayload` содержит только данные, необходимые для одного полного render: +platform, effective theme values, effective component configuration, runtime example +props и необязательный surface. Ответ представлен `PreviewResult.Success` или +`PreviewResult.Failure`. + +Assembler обязан до отправки payload разрешить palette references, применить +platform adjustments, вычислить effective properties и раскрыть dependencies/reuse. +Эта история вычисления не является частью wire contract. + +Incremental patches, plugin manifests, asset loading и расширенная диагностика не +входят в v1. Они должны проектироваться отдельными контрактами при появлении +соответствующих runtime consumers. + +## Source of truth + +JSON Schema Draft 2020-12 в `src/commonMain/resources/schemas/v1` является source +of truth. Canonical fixtures публикуются рядом со schemas. Kotlin-модели +`com.sdds.preview.contract` проверяются schema/serialization round-trip tests. + +## Copy of the canonical contract + +`schemas/v1/preview-payload.schema.json` and `fixtures/v1/shared` are byte-identical copies of +`design-system-builder/backend-kt/preview/contract`. `schemas/v1/DIGEST` records their SHA-256: + +- `SchemaContractTest` fails when a file of the copy no longer matches `DIGEST` (a manual edit of the copy); +- the `Preview contract check` workflow compares `DIGEST` with the canonical `CONTRACT.sha256`: a pull request gets a warning, + `release/*`, `hotfix/*`, `main` and the nightly run fail. The reference is set by the repository variable `DSB_CONTRACT_REF` + (default `dev`). + +Update the copy from a design-system-builder checkout: + +```bash +backend-kt/preview/contract/fingerprint.sh --sync-to /integration-core/preview-contract/src/commonMain/resources +``` diff --git a/integration-core/preview-contract/build.gradle.kts b/integration-core/preview-contract/build.gradle.kts new file mode 100644 index 000000000..7c59bd3ea --- /dev/null +++ b/integration-core/preview-contract/build.gradle.kts @@ -0,0 +1,29 @@ +import utils.addDefaultTargets + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("convention.kmp-lib") + alias(libs.plugins.kotlin.serialization) +} + +group = "integration-core" + +android { + namespace = "com.sdds.preview.contract" +} + +kotlin { + addDefaultTargets() + + sourceSets { + commonMain.dependencies { + api(libs.base.kotlin.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + jvmTest.dependencies { + implementation("com.networknt:json-schema-validator:1.0.87") + } + } +} diff --git a/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt new file mode 100644 index 000000000..6dd73e1d3 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewComponentDescription.kt @@ -0,0 +1,118 @@ +package com.sdds.preview.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Runtime-neutral описание зарегистрированной component story. */ +@Serializable +public data class PreviewComponentDescription( + /** Версия Preview Protocol, в которой опубликовано описание. */ + val protocolVersion: Int = PREVIEW_PROTOCOL_VERSION, + /** Идентификатор компонента, принимаемый renderer. */ + val componentId: String, + /** Идентификатор story, используемой renderer. */ + val storyId: String, + /** Поддерживаемые example properties в порядке story producer. */ + val properties: List, +) + +/** Runtime-neutral описание example property и его типизированного default value. */ +@Serializable +public sealed interface PreviewExamplePropertyDescription { + /** Wire key, принимаемый state transformer. */ + public val name: String + + /** Строковое property. */ + @Serializable + @SerialName("string") + public data class StringProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: String, + ) : PreviewExamplePropertyDescription + + /** Логическое property. */ + @Serializable + @SerialName("boolean") + public data class BooleanProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: Boolean, + ) : PreviewExamplePropertyDescription + + /** Целочисленное property. */ + @Serializable + @SerialName("int") + public data class IntProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: Int, + ) : PreviewExamplePropertyDescription + + /** Property с плавающей точкой. */ + @Serializable + @SerialName("float") + public data class FloatProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: Float, + ) : PreviewExamplePropertyDescription + + /** Property с выбором одного значения из фиксированного списка. */ + @Serializable + @SerialName("singleChoice") + public data class SingleChoiceProperty( + override val name: String, + /** Значение story по умолчанию. */ + val defaultValue: String, + /** Допустимые значения property. */ + val variants: List, + ) : PreviewExamplePropertyDescription +} + +/** Коррелированный результат запроса component description. */ +@Serializable +public sealed interface PreviewComponentDescriptionResult { + /** Идентификатор исходного describe request. */ + public val requestId: String + + /** Description успешно получено. */ + @Serializable + @SerialName("success") + public data class Success( + override val requestId: String, + /** Описание зарегистрированной story. */ + val description: PreviewComponentDescription, + ) : PreviewComponentDescriptionResult + + /** Description не удалось получить. */ + @Serializable + @SerialName("failure") + public data class Failure( + override val requestId: String, + /** Стабильный код ошибки. */ + val code: String, + /** Сообщение для человека. */ + val message: String, + ) : PreviewComponentDescriptionResult +} + +/** Проверяет semantic constraints component description. */ +public fun PreviewComponentDescription.validate(): List { + val errors = mutableListOf() + if (protocolVersion != PREVIEW_PROTOCOL_VERSION) errors += "protocolVersion не поддерживается" + if (componentId.isBlank()) errors += "componentId не должен быть пустым" + if (storyId.isBlank()) errors += "storyId не должен быть пустым" + val duplicateNames = properties.groupingBy { it.name }.eachCount().filterValues { it > 1 }.keys + if (duplicateNames.isNotEmpty()) errors += "Имена properties должны быть уникальны: $duplicateNames" + properties.forEach { property -> + if (property.name.isBlank()) errors += "Имя property не должно быть пустым" + if (property is PreviewExamplePropertyDescription.SingleChoiceProperty) { + if (property.variants.isEmpty()) errors += "${property.name}: variants не должны быть пустыми" + if (property.defaultValue !in property.variants) { + errors += "${property.name}: defaultValue должен входить в variants" + } + } + } + return errors +} diff --git a/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt new file mode 100644 index 000000000..87db92523 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/kotlin/com/sdds/preview/contract/PreviewContract.kt @@ -0,0 +1,308 @@ +package com.sdds.preview.contract + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** Текущая версия Preview Protocol. */ +public const val PREVIEW_PROTOCOL_VERSION: Int = 1 + +/** Платформа preview renderer. */ +@Serializable +public enum class PreviewPlatform { + /** Android View renderer. */ + ANDROID, + + /** Compose renderer. */ + COMPOSE, + + /** Web renderer. */ + WEB, + + /** iOS renderer. */ + IOS, +} + +/** Тип runtime-ресурса preview. */ +@Serializable +public enum class PreviewAssetType { + /** Шрифт TrueType. */ + TTF, + + /** Шрифт OpenType. */ + OTF, + + /** Векторное изображение SVG. */ + SVG, + + /** Растровое изображение PNG. */ + PNG, +} + +/** Описание ресурса, доступного renderer во время выполнения. */ +@Serializable +public data class PreviewAsset( + /** Стабильный идентификатор ресурса. */ val id: String, + /** Тип ресурса. */ val type: PreviewAssetType, + /** URL для загрузки ресурса. */ val url: String, + /** Необязательный digest содержимого для проверки и кеширования. */ val digest: String? = null, +) + +/** Начертание шрифта. */ +@Serializable +public enum class PreviewFontStyle { + /** Обычное начертание. */ + NORMAL, + + /** Курсивное начертание. */ + ITALIC, +} + +/** Начертание font-family, связанное с runtime-ресурсом. */ +@Serializable +public data class PreviewFontFace( + /** Идентификатор font asset. */ val assetId: String, + /** Числовой вес шрифта. */ val weight: Int, + /** Стиль шрифта. */ val style: PreviewFontStyle = PreviewFontStyle.NORMAL, +) + +/** Нормализованное значение токена, готовое к использованию renderer. */ +@Serializable +public sealed interface TokenValue { + /** Цвет. */ + @Serializable + @SerialName("color") + public data class Color(/** Значение цвета. */ val value: String) : TokenValue + + /** Размер в dp. */ + @Serializable + @SerialName("dimension") + public data class Dimension(/** Значение в dp. */ val value: Double) : TokenValue + + /** Форма с радиусами углов в dp. */ + @Serializable + @SerialName("shape") + public data class Shape(/** Радиусы углов. */ val cornerRadii: List) : TokenValue + + /** Тень. */ + @Serializable + @SerialName("shadow") + public data class Shadow(/** Слои тени. */ val layers: List) : TokenValue + + /** Градиент из линейных слоёв. */ + @Serializable + @SerialName("gradient") + public data class Gradient(/** Слои градиента. */ val layers: List) : TokenValue + + /** Семейство runtime-шрифтов. */ + @Serializable + @SerialName("fontFamily") + public data class FontFamily( + /** Доступные начертания семейства. */ val faces: List, + /** Разрешить системный fallback при недоступности font asset. */ val allowFallback: Boolean = false, + ) : TokenValue + + /** Параметры текста. */ + @Serializable + @SerialName("typography") + public data class Typography( + /** Идентификатор font-family token. */ val fontFamilyTokenId: String, + /** Размер шрифта в sp. */ val fontSize: Double, + /** Высота строки в sp. */ val lineHeight: Double, + /** Межбуквенный интервал в sp. */ val letterSpacing: Double, + /** Вес шрифта. */ val weight: Int, + ) : TokenValue +} + +/** Источник effective-значения component property. */ +@Serializable +public sealed interface PreviewPropertySource { + /** Нормализованное literal-значение. */ + @Serializable + @SerialName("literal") + public data class Literal( + /** Значение без platform UI типов. */ val value: JsonElement, + /** Прозрачность от 0 до 1, применяемая к цвету. */ val alpha: Double? = null, + ) : PreviewPropertySource + + /** Ссылка на token из [PreviewPayload.theme]. */ + @Serializable + @SerialName("tokenRef") + public data class TokenReference( + /** Идентификатор token. */ val tokenId: String, + /** Прозрачность от 0 до 1, применяемая к цвету. */ val alpha: Double? = null, + ) : PreviewPropertySource + + /** Ссылка на вложенный стиль из [PreviewPayload.styles]. */ + @Serializable + @SerialName("styleRef") + public data class StyleReference(/** Идентификатор стиля. */ val styleId: String) : PreviewPropertySource + + /** Ссылка на ресурс из [PreviewPayload.assets]. */ + @Serializable + @SerialName("assetRef") + public data class AssetReference(/** Идентификатор ресурса. */ val assetId: String) : PreviewPropertySource +} + +/** Значение property для набора одновременно активных состояний. */ +@Serializable +public data class PreviewStateEntry( + /** Имена состояний; запись действует, когда активны все. */ + @SerialName("when") val states: List, + /** Значение для этого набора состояний. */ val source: PreviewPropertySource, +) + +/** Effective-значение property с необязательными значениями для наборов состояний. */ +@Serializable +public data class PreviewPropertyValue( + /** Базовое значение. */ val base: PreviewPropertySource, + /** Упорядоченные значения для наборов состояний. */ + val states: List = emptyList(), +) + +/** Вложенный стиль компонента, на который ссылается [PreviewPropertySource.StyleReference]. */ +@Serializable +public data class PreviewStyle( + /** Идентификатор компонента, которому принадлежит стиль. */ val component: String, + /** Вычисленные properties стиля. */ val properties: Map = emptyMap(), +) + +/** Точка градиента. */ +@Serializable +public data class GradientStop( + /** Цвет в формате `#RRGGBBAA`. */ val color: String, + /** Позиция от 0 до 1. */ val position: Double, +) + +/** Линейный слой градиента. */ +@Serializable +public data class GradientLayer( + /** Угол в градусах по соглашению CSS: 0° снизу вверх, 90° слева направо. */ val angle: Double, + /** Точки градиента, не менее двух. */ val stops: List, + /** Вид слоя. */ val kind: String = "linear", +) + +/** Один слой тени. */ +@Serializable +public data class ShadowLayer( + /** X-смещение в dp. */ val x: Double, + /** Y-смещение в dp. */ val y: Double, + /** Радиус размытия в dp. */ val blur: Double, + /** Растяжение в dp. */ val spread: Double, + /** Цвет. */ val color: String, +) + +/** Effective-конфигурация компонента, готовая к rendering. */ +@Serializable +public data class PreviewComponent( + /** Идентификатор компонента. */ val id: String, + /** Выбранные styles по variation axes. */ val variations: Map = emptyMap(), + /** Вычисленные component properties. */ val properties: Map = emptyMap(), +) + +/** Runtime-данные демонстрационного экземпляра. */ +@Serializable +public data class PreviewExample( + /** Идентификатор story. */ val id: String, + /** Runtime properties и content. */ val props: Map = emptyMap(), +) + +/** Необязательные параметры области rendering. */ +@Serializable +public data class PreviewSurface( + /** Ширина в pixels. */ val width: Int, + /** Высота в pixels. */ val height: Int, + /** Цвет фона. */ val background: String? = null, +) + +/** Текст по умолчанию: идентификаторы токенов `typography` и `color` темы. */ +@Serializable +public data class PreviewDefaultText( + /** Токен стиля текста. */ val style: String? = null, + /** Токен цвета текста. */ val color: String? = null, +) + +/** Стили по умолчанию: идентификаторы вложенных стилей из `styles` для компонентов, которые истории рисуют без стиля. */ +@Serializable +public data class PreviewDefaultStyles( + /** Стиль кнопок. */ val button: String? = null, + /** Стиль Switch. */ val switch: String? = null, + /** Стиль Spinner. */ val spinner: String? = null, +) + +/** Стили и текст по умолчанию вспомогательных элементов истории; значения берутся из темы дизайн-системы. */ +@Serializable +public data class PreviewDefaults( + /** Текст по умолчанию. */ val text: PreviewDefaultText? = null, + /** Стили по умолчанию. */ val styles: PreviewDefaultStyles? = null, +) + +/** Полная инструкция для одного preview render. */ +@Serializable +public data class PreviewPayload( + /** Версия протокола. */ val protocolVersion: Int = PREVIEW_PROTOCOL_VERSION, + /** Идентификатор render request. */ val requestId: String, + /** Платформа renderer. */ val platform: PreviewPlatform, + /** Runtime-ресурсы, необходимые renderer. */ val assets: List = emptyList(), + /** Вычисленные значения темы по token ID. */ val theme: Map, + /** Effective-конфигурация компонента. */ val component: PreviewComponent, + /** Вложенные стили по идентификаторам. */ val styles: Map = emptyMap(), + /** Runtime-данные примера. */ val example: PreviewExample, + /** Параметры области rendering. */ val surface: PreviewSurface? = null, + /** Стили и текст по умолчанию вспомогательных элементов истории. */ val defaults: PreviewDefaults? = null, +) + +/** Результат обработки preview request. */ +@Serializable +public sealed interface PreviewResult { + /** Идентификатор исходного request. */ + public val requestId: String + + /** Rendering успешно завершён. */ + @Serializable + @SerialName("success") + public data class Success( + override val requestId: String, + /** Предупреждения, не меняющие статус результата. */ val warnings: List = emptyList(), + ) : PreviewResult + + /** Rendering завершён ошибкой. */ + @Serializable + @SerialName("failure") + public data class Failure( + override val requestId: String, + /** Стабильный код ошибки. */ val code: String, + /** Сообщение для человека. */ val message: String, + /** Путь к проблемному полю. */ val path: String? = null, + ) : PreviewResult +} + +/** Предупреждение успешного результата. */ +@Serializable +public data class PreviewWarning( + /** Стабильный код предупреждения. */ val code: String, + /** Сообщение для человека. */ val message: String, + /** Путь к проблемному полю. */ val path: String? = null, +) + +/** JSON-конфигурация Preview Protocol. */ +public val PreviewContractJson: Json = Json { + classDiscriminator = "type" + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true +} + +/** Проверяет поддерживаемую версию payload. */ +public fun PreviewPayload.validateVersion(): PreviewResult.Failure? = + if (protocolVersion == PREVIEW_PROTOCOL_VERSION) { + null + } else { + PreviewResult.Failure( + requestId = requestId, + code = "unsupported_protocol_version", + message = "Preview Protocol $protocolVersion не поддерживается", + path = "$.protocolVersion", + ) + } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json new file mode 100644 index 000000000..a4f4eb5ce --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-component-description-result-invalid-default.json @@ -0,0 +1,12 @@ +{ + "type": "success", + "requestId": "describe-1", + "description": { + "protocolVersion": 1, + "componentId": "BasicButton", + "storyId": "BasicButton", + "properties": [ + { "type": "boolean", "name": "enabled", "defaultValue": "true" } + ] + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json new file mode 100644 index 000000000..3ded111dd --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-font-asset.json @@ -0,0 +1,9 @@ +{ + "protocolVersion": 1, + "requestId": "request-invalid-font", + "platform": "COMPOSE", + "assets": [{ "id": "font", "type": "WOFF2", "url": "font.woff2" }], + "theme": {}, + "component": { "id": "button" }, + "example": { "id": "default" } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json new file mode 100644 index 000000000..3ff664de9 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-invalid-property.json @@ -0,0 +1,8 @@ +{ + "protocolVersion": 1, + "requestId": "request-invalid-property", + "platform": "COMPOSE", + "theme": {}, + "component": { "id": "button", "properties": { "color": { "base": { "type": "tokenRef" } } } }, + "example": { "id": "default" } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json new file mode 100644 index 000000000..24ebd42d2 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-payload-missing-component.json @@ -0,0 +1 @@ +{ "protocolVersion": 1, "requestId": "request-1", "platform": "COMPOSE", "theme": {}, "example": { "id": "basic" } } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json new file mode 100644 index 000000000..dfb2c6590 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/negative/preview-result-invalid-type.json @@ -0,0 +1 @@ +{ "type": "rendered", "requestId": "request-1" } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json new file mode 100644 index 000000000..b290fdf94 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-component-description-result.json @@ -0,0 +1,21 @@ +{ + "type": "success", + "requestId": "describe-1", + "description": { + "protocolVersion": 1, + "componentId": "BasicButton", + "storyId": "BasicButton", + "properties": [ + { "type": "string", "name": "label", "defaultValue": "label" }, + { "type": "boolean", "name": "enabled", "defaultValue": true }, + { "type": "int", "name": "count", "defaultValue": 1 }, + { "type": "float", "name": "progress", "defaultValue": 0.5 }, + { + "type": "singleChoice", + "name": "icon", + "defaultValue": "Start", + "variants": ["Start", "End", "No"] + } + ] + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json new file mode 100644 index 000000000..dfa169702 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-payload.json @@ -0,0 +1,27 @@ +{ + "protocolVersion": 1, + "requestId": "request-1", + "platform": "COMPOSE", + "assets": [ + { "id": "font.inter.regular", "type": "TTF", "url": "https://cdn.example.test/inter-regular.ttf", "digest": "sha256:regular" }, + { "id": "font.inter.italic", "type": "OTF", "url": "https://cdn.example.test/inter-italic.otf" } + ], + "theme": { + "color.primary": { "type": "color", "value": "#006FFFFF" }, + "spacing.m": { "type": "dimension", "value": 16 }, + "shape.round": { "type": "shape", "cornerRadii": [12, 12, 12, 12] }, + "shadow.default": { "type": "shadow", "layers": [{ "x": 0, "y": 2, "blur": 8, "spread": 0, "color": "#33000000" }] }, + "fontFamily.inter": { "type": "fontFamily", "faces": [{ "assetId": "font.inter.regular", "weight": 400 }, { "assetId": "font.inter.italic", "weight": 400, "style": "ITALIC" }] }, + "typography.body": { "type": "typography", "fontFamilyTokenId": "fontFamily.inter", "fontSize": 16, "lineHeight": 20, "letterSpacing": 0.2, "weight": 400 } + }, + "component": { + "id": "button", + "variations": { "view": "primary", "size": "m" }, + "properties": { + "enabled": { "base": { "type": "literal", "value": true } }, + "backgroundColor": { "base": { "type": "tokenRef", "tokenId": "color.primary" }, "states": [{ "when": ["pressed"], "source": { "type": "literal", "value": "#004FBFFF" } }] } + } + }, + "example": { "id": "button-basic", "props": { "text": "Continue" } }, + "surface": { "width": 390, "height": 844, "background": "#FFFFFF" } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json new file mode 100644 index 000000000..52f39e061 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result-with-warnings.json @@ -0,0 +1 @@ +{ "type": "success", "requestId": "request-1", "warnings": [{ "code": "unknown_state", "message": "Состояние fancy не поддерживается", "path": "$.component.properties.backgroundColor.states[0]" }] } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json new file mode 100644 index 000000000..f0a17f05e --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/positive/preview-result.json @@ -0,0 +1 @@ +{ "type": "success", "requestId": "request-1" } diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json new file mode 100644 index 000000000..d44a9ce00 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-on-style-ref.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "styleRef", + "styleId": "x", + "alpha": 0.5 + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json new file mode 100644 index 000000000..c20bbf5ed --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/alpha-out-of-range.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg", + "alpha": 1.5 + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json new file mode 100644 index 000000000..3249d0d91 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/duplicate-when.json @@ -0,0 +1,45 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [ + { + "when": [ + "pressed", + "pressed" + ], + "source": { + "type": "literal", + "value": 1 + } + } + ] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json new file mode 100644 index 000000000..24db7136a --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/empty-when.json @@ -0,0 +1,42 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [ + { + "when": [], + "source": { + "type": "literal", + "value": 1 + } + } + ] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json new file mode 100644 index 000000000..663db827a --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/gradient-one-stop.json @@ -0,0 +1,49 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + }, + "g": { + "type": "gradient", + "layers": [ + { + "kind": "linear", + "angle": 0, + "stops": [ + { + "color": "#FF0000FF", + "position": 0 + } + ] + } + ] + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json new file mode 100644 index 000000000..fbcd07bbb --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/shadow-without-spread.json @@ -0,0 +1,45 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + }, + "s": { + "type": "shadow", + "layers": [ + { + "x": 0, + "y": 0, + "blur": 1, + "color": "#00000040" + } + ] + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json new file mode 100644 index 000000000..77de7b5ca --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/states-as-map.json @@ -0,0 +1,39 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": { + "pressed": { + "type": "literal", + "value": 1 + } + } + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json new file mode 100644 index 000000000..c1aaed730 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/unknown-asset-type.json @@ -0,0 +1,40 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [ + { + "id": "a", + "type": "GIF", + "url": "u" + } + ], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json new file mode 100644 index 000000000..3fdf814d5 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/invalid/wrong-protocol-version.json @@ -0,0 +1,34 @@ +{ + "protocolVersion": 2, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json new file mode 100644 index 000000000..4729b8ed3 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/alpha-literal.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "literal", + "value": "#FF0000FF", + "alpha": 0.4 + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json new file mode 100644 index 000000000..83306c13b --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/asset-ref-svg.json @@ -0,0 +1,47 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [ + { + "id": "icon.close", + "type": "SVG", + "url": "https://example.test/close.svg", + "digest": "abc" + } + ], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + }, + "icon": { + "base": { + "type": "assetRef", + "assetId": "icon.close" + } + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json new file mode 100644 index 000000000..171dcd25b --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/gradient-and-shadow-spread.json @@ -0,0 +1,65 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + }, + "gradient.main": { + "type": "gradient", + "layers": [ + { + "kind": "linear", + "angle": 90, + "stops": [ + { + "color": "#FF0000FF", + "position": 0 + }, + { + "color": "#0000FFFF", + "position": 1 + } + ] + } + ] + }, + "shadow.s": { + "type": "shadow", + "layers": [ + { + "x": 0, + "y": 2, + "blur": 4, + "spread": -1, + "color": "#00000040" + } + ] + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json new file mode 100644 index 000000000..164a4b07d --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/minimal.json @@ -0,0 +1,34 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json new file mode 100644 index 000000000..b52f59324 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/state-set.json @@ -0,0 +1,64 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [ + { + "when": [ + "pressed" + ], + "source": { + "type": "literal", + "value": "#00000080" + } + }, + { + "when": [ + "pressed", + "selected" + ], + "source": { + "type": "tokenRef", + "tokenId": "color.bg" + } + }, + { + "when": [ + "checked" + ], + "source": { + "type": "tokenRef", + "tokenId": "color.bg", + "alpha": 0.5 + } + } + ] + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json new file mode 100644 index 000000000..732aef01c --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/fixtures/v1/shared/valid/style-ref.json @@ -0,0 +1,53 @@ +{ + "protocolVersion": 1, + "requestId": "fixture-1", + "platform": "COMPOSE", + "assets": [], + "theme": { + "color.bg": { + "type": "color", + "value": "#112233FF" + } + }, + "component": { + "id": "button", + "variations": {}, + "properties": { + "backgroundColor": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + }, + "states": [] + }, + "dividerStyle": { + "base": { + "type": "styleRef", + "styleId": "divider.default" + } + } + } + }, + "example": { + "id": "default", + "props": {} + }, + "surface": { + "width": 320, + "height": 120, + "background": "#FFFFFFFF" + }, + "styles": { + "divider.default": { + "component": "divider", + "properties": { + "color": { + "base": { + "type": "tokenRef", + "tokenId": "color.bg" + } + } + } + } + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST new file mode 100644 index 000000000..b90c22079 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/DIGEST @@ -0,0 +1,16 @@ +d21278388436a88c53a9a5a9cb407f1023c21b0ced4b1794251d45f1b7cf788d fixtures/v1/shared/invalid/alpha-on-style-ref.json +5b7b2901fba5696d4d5d40cc74ddfbbd4f4ac8f94825caa70eaefbdcc0da91d7 fixtures/v1/shared/invalid/alpha-out-of-range.json +cc9166ee6f181ccf84d0f5a7ce436286d370e4b22f27b3fd60f19993009fa165 fixtures/v1/shared/invalid/duplicate-when.json +d6ccd194f6ebf281e45323ad7f30d91ac1275e4055bf5845ee0296e181959368 fixtures/v1/shared/invalid/empty-when.json +d5356b033a2a9f25ea365e64ed1689a325372717d026d332bafce81b09de5d28 fixtures/v1/shared/invalid/gradient-one-stop.json +3ea2a049c9af3bf000c56837204a388024ea3beb3385a5800fbb1e1db4a65dc9 fixtures/v1/shared/invalid/shadow-without-spread.json +bcdba1df19fe90d54e5fcbdd0ad0799b02f541ab2426e7e961c0db83a57b7454 fixtures/v1/shared/invalid/states-as-map.json +facc8f3fb1358419fa745de6434e1fc5d918024b7dbe1ab149afea8b01fa5bb0 fixtures/v1/shared/invalid/unknown-asset-type.json +9643732e64208522831a89770e916423e0ded3875fd4f0b2b6414932a485fb7a fixtures/v1/shared/invalid/wrong-protocol-version.json +7920121f085dc2ddc104b6588d5e2256c383bad358798507efaa4da64ef995dc fixtures/v1/shared/valid/alpha-literal.json +19f55c22e19f9de06a17a87432ba766fcdf831bb47cac1a1e5858fb26cf8278d fixtures/v1/shared/valid/asset-ref-svg.json +83c9e23f016eeae3c124af2c4e814eaf02eed6ba56f56f51d71c7bba00558cd2 fixtures/v1/shared/valid/gradient-and-shadow-spread.json +421f953f07649127aa869ed5383163937c9cb78f3b142e4871b9edec0d4a7272 fixtures/v1/shared/valid/minimal.json +264877cdfd91e70b7542ea2a0614ca8c9d0db4e42638eb4a692c3eccfa9ace31 fixtures/v1/shared/valid/state-set.json +45e4ff829d415f0ad2b30de119b7bf7da8d392c23b50914bd6d4a51ca7965014 fixtures/v1/shared/valid/style-ref.json +79da7d4ee214f65f8c7275167ceccf3938940839ec87883e5cd73390198d33db schemas/v1/preview-payload.schema.json diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json new file mode 100644 index 000000000..2b7cbbfa3 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-component-description-result.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sdds.dev/preview/v1/preview-component-description-result.schema.json", + "$defs": { + "property": { + "oneOf": [ + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "string" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "string" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "boolean" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "boolean" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "int" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "integer" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue"], + "properties": { + "type": { "const": "float" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "number" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "defaultValue", "variants"], + "properties": { + "type": { "const": "singleChoice" }, + "name": { "type": "string", "minLength": 1 }, + "defaultValue": { "type": "string" }, + "variants": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string" }, + "contains": { "type": "string" } + } + }, + "additionalProperties": false + } + ] + }, + "description": { + "type": "object", + "required": ["protocolVersion", "componentId", "storyId", "properties"], + "properties": { + "protocolVersion": { "const": 1 }, + "componentId": { "type": "string", "minLength": 1 }, + "storyId": { "type": "string", "minLength": 1 }, + "properties": { + "type": "array", + "items": { "$ref": "#/$defs/property" } + } + }, + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "required": ["type", "requestId", "description"], + "properties": { + "type": { "const": "success" }, + "requestId": { "type": "string", "minLength": 1 }, + "description": { "$ref": "#/$defs/description" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "requestId", "code", "message"], + "properties": { + "type": { "const": "failure" }, + "requestId": { "type": "string", "minLength": 1 }, + "code": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + ] +} diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json new file mode 100644 index 000000000..1c65e01a1 --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-payload.schema.json @@ -0,0 +1,559 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sdds.dev/preview/v1/preview-payload.schema.json", + "type": "object", + "required": [ + "protocolVersion", + "requestId", + "platform", + "theme", + "component", + "example" + ], + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1 + }, + "platform": { + "enum": [ + "ANDROID", + "COMPOSE", + "WEB", + "IOS" + ] + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/$defs/asset" + } + }, + "theme": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/tokenValue" + } + }, + "component": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "variations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/propertyValue" + } + } + }, + "additionalProperties": false + }, + "example": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "props": { + "type": "object" + } + }, + "additionalProperties": false + }, + "surface": { + "type": "object", + "required": [ + "width", + "height" + ], + "properties": { + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "background": { + "type": "string" + } + }, + "additionalProperties": false + }, + "styles": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/style" + } + }, + "defaults": { + "type": "object", + "description": "Стили и текст по умолчанию для вспомогательных элементов истории компонента (кнопки, Switch, Spinner, текст); значения берутся из темы дизайн-системы.", + "properties": { + "text": { + "type": "object", + "properties": { + "style": { + "type": "string", + "description": "Идентификатор токена typography." + }, + "color": { + "type": "string", + "description": "Идентификатор токена color." + } + }, + "additionalProperties": false + }, + "styles": { + "type": "object", + "description": "Идентификаторы стилей из `styles` для компонентов, которые истории рисуют без собственного стиля.", + "properties": { + "button": { + "type": "string" + }, + "switch": { + "type": "string" + }, + "spinner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true, + "$defs": { + "asset": { + "type": "object", + "required": [ + "id", + "type", + "url" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "enum": [ + "TTF", + "OTF", + "SVG", + "PNG" + ] + }, + "url": { + "type": "string", + "minLength": 1 + }, + "digest": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "propertySource": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "literal" + }, + "value": {}, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "tokenId" + ], + "properties": { + "type": { + "const": "tokenRef" + }, + "tokenId": { + "type": "string", + "minLength": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "styleId" + ], + "properties": { + "type": { + "const": "styleRef" + }, + "styleId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "assetId" + ], + "properties": { + "type": { + "const": "assetRef" + }, + "assetId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + ] + }, + "propertyValue": { + "type": "object", + "required": [ + "base" + ], + "properties": { + "base": { + "$ref": "#/$defs/propertySource" + }, + "states": { + "type": "array", + "items": { + "$ref": "#/$defs/stateEntry" + } + } + }, + "additionalProperties": false + }, + "tokenValue": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "color" + }, + "value": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{8}$" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "dimension" + }, + "value": { + "type": "number" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "cornerRadii" + ], + "properties": { + "type": { + "const": "shape" + }, + "cornerRadii": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "layers" + ], + "properties": { + "type": { + "const": "shadow" + }, + "layers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "x", + "y", + "blur", + "spread", + "color" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "blur": { + "type": "number" + }, + "color": { + "type": "string" + }, + "spread": { + "type": "number" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "faces" + ], + "properties": { + "type": { + "const": "fontFamily" + }, + "faces": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "assetId", + "weight" + ], + "properties": { + "assetId": { + "type": "string", + "minLength": 1 + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "style": { + "enum": [ + "NORMAL", + "ITALIC" + ] + } + }, + "additionalProperties": false + } + }, + "allowFallback": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "fontFamilyTokenId", + "fontSize", + "lineHeight", + "letterSpacing", + "weight" + ], + "properties": { + "type": { + "const": "typography" + }, + "fontFamilyTokenId": { + "type": "string", + "minLength": 1 + }, + "fontSize": { + "type": "number" + }, + "lineHeight": { + "type": "number" + }, + "letterSpacing": { + "type": "number" + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "layers" + ], + "properties": { + "type": { + "const": "gradient" + }, + "layers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "kind", + "angle", + "stops" + ], + "properties": { + "kind": { + "const": "linear" + }, + "angle": { + "type": "number" + }, + "stops": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": [ + "color", + "position" + ], + "properties": { + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{8}$" + }, + "position": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + ] + }, + "stateEntry": { + "type": "object", + "required": [ + "when", + "source" + ], + "properties": { + "when": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "source": { + "$ref": "#/$defs/propertySource" + } + }, + "additionalProperties": false + }, + "style": { + "type": "object", + "required": [ + "component", + "properties" + ], + "properties": { + "component": { + "type": "string", + "minLength": 1 + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/propertyValue" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json new file mode 100644 index 000000000..f78ffcdfc --- /dev/null +++ b/integration-core/preview-contract/src/commonMain/resources/schemas/v1/preview-result.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sdds.dev/preview/v1/preview-result.schema.json", + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "requestId" + ], + "properties": { + "type": { + "const": "success" + }, + "requestId": { + "type": "string", + "minLength": 1 + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "requestId", + "code", + "message" + ], + "properties": { + "type": { + "const": "failure" + }, + "requestId": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + } + ] +} diff --git a/integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt b/integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt new file mode 100644 index 000000000..9543df7d3 --- /dev/null +++ b/integration-core/preview-contract/src/commonTest/kotlin/com/sdds/preview/contract/PreviewContractTest.kt @@ -0,0 +1,47 @@ +package com.sdds.preview.contract + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PreviewContractTest { + @Test + fun `current protocol version is accepted`() { + assertNull(payload(protocolVersion = PREVIEW_PROTOCOL_VERSION).validateVersion()) + } + + @Test + fun `unknown protocol version is rejected`() { + val failure = payload(protocolVersion = 2).validateVersion() + assertEquals("unsupported_protocol_version", failure?.code) + assertEquals("$.protocolVersion", failure?.path) + } + + @Test + fun `description semantic validation rejects duplicate names and invalid choices`() { + val description = PreviewComponentDescription( + componentId = "button", + storyId = "basic", + properties = listOf( + PreviewExamplePropertyDescription.StringProperty("label", "Label"), + PreviewExamplePropertyDescription.StringProperty("label", "Other"), + PreviewExamplePropertyDescription.SingleChoiceProperty("icon", "Missing", listOf("Start")), + ), + ) + + val errors = description.validate() + + assertTrue(errors.any { "уникальны" in it }) + assertTrue(errors.any { "defaultValue" in it }) + } + + private fun payload(protocolVersion: Int) = PreviewPayload( + protocolVersion = protocolVersion, + requestId = "request-1", + platform = PreviewPlatform.COMPOSE, + theme = emptyMap(), + component = PreviewComponent("button"), + example = PreviewExample("basic"), + ) +} diff --git a/integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt b/integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt new file mode 100644 index 000000000..6860799b8 --- /dev/null +++ b/integration-core/preview-contract/src/jvmTest/kotlin/com/sdds/preview/contract/SchemaContractTest.kt @@ -0,0 +1,135 @@ +package com.sdds.preview.contract + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion +import kotlinx.serialization.KSerializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.serializer +import java.io.File +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SchemaContractTest { + private val mapper = ObjectMapper() + private val factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + + @Test + fun `canonical fixtures conform to schema and survive Kotlin round trip`() { + roundTrip("preview-payload") + roundTrip("preview-result") + roundTrip("preview-result", "preview-result-with-warnings") + roundTrip("preview-component-description-result") + } + + @Test + fun `negative fixtures report expected paths`() { + assertInvalid("preview-payload", "preview-payload-missing-component", "$.component") + assertInvalid("preview-result", "preview-result-invalid-type", "$.type") + assertInvalid("preview-payload", "preview-payload-invalid-font-asset", "$.assets[0].type") + assertInvalid("preview-payload", "preview-payload-invalid-property", "$.component.properties.color.base") + assertInvalid( + "preview-component-description-result", + "preview-component-description-result-invalid-default", + "$.description.properties[0].defaultValue", + ) + } + + @Test + fun `shared valid fixtures pass the schema and survive Kotlin round trip`() { + val names = sharedFixtures("valid") + assertTrue(names.isNotEmpty()) + names.forEach { name -> + val fixture = resource("fixtures/v1/shared/valid/$name") + assertValid("preview-payload", mapper.readTree(fixture)) + val model = PreviewContractJson.decodeFromString(fixture) + assertValid("preview-payload", mapper.readTree(PreviewContractJson.encodeToString(model))) + } + } + + @Test + fun `shared invalid fixtures are rejected by the schema`() { + val names = sharedFixtures("invalid") + assertTrue(names.isNotEmpty()) + names.forEach { name -> + val messages = schema("preview-payload").validate( + mapper.readTree(resource("fixtures/v1/shared/invalid/$name")), + ) + assertTrue(messages.isNotEmpty(), "Fixture $name must be rejected") + } + } + + @Test + fun `canonical schema and shared fixtures match the recorded digest`() { + val recorded = resource("schemas/v1/DIGEST").lines().filter { it.isNotBlank() } + .associate { line -> line.substringAfter(" ") to line.substringBefore(" ") } + val expectedFiles = listOf("schemas/v1/preview-payload.schema.json") + + sharedFixtures("valid").map { "fixtures/v1/shared/valid/$it" } + + sharedFixtures("invalid").map { "fixtures/v1/shared/invalid/$it" } + assertEquals(expectedFiles.sorted(), recorded.keys.sorted(), "DIGEST lists a different set of files") + expectedFiles.forEach { path -> + val actual = MessageDigest.getInstance("SHA-256").digest(resource(path).toByteArray()) + .joinToString("") { "%02x".format(it) } + assertEquals( + recorded.getValue(path), + actual, + "$path differs from the recorded digest; run " + + "design-system-builder/backend-kt/preview/contract/fingerprint.sh --sync-to ", + ) + } + } + + @Test + fun `schemas and fixtures are packaged as resources`() { + listOf("preview-payload", "preview-result", "preview-component-description-result").forEach { + assertTrue(resource("schemas/v1/$it.schema.json").isNotBlank()) + assertTrue(resource("fixtures/v1/positive/$it.json").isNotBlank()) + } + } + + @Test + fun `public runtime remains platform neutral`() { + val runtimeNames = System.getProperty("java.class.path") + assertTrue("androidx.compose" !in runtimeNames) + assertTrue("com.android" !in runtimeNames) + assertTrue("sandbox" !in runtimeNames) + } + + private fun sharedFixtures(kind: String): List { + val directory = File(requireNotNull(javaClass.classLoader.getResource("fixtures/v1/shared/$kind")).toURI()) + return directory.list().orEmpty().filter { it.endsWith(".json") }.sorted() + } + + private inline fun roundTrip(name: String, fixtureName: String = name) { + val fixture = resource("fixtures/v1/positive/$fixtureName.json") + assertValid(name, mapper.readTree(fixture)) + val serializer: KSerializer = serializer() + val model = PreviewContractJson.decodeFromString(serializer, fixture) + val encoded = PreviewContractJson.encodeToString(serializer, model) + assertValid(name, mapper.readTree(encoded)) + } + + private fun assertInvalid(schemaName: String, fixtureName: String, expectedPath: String) { + val messages = schema(schemaName).validate( + mapper.readTree(resource("fixtures/v1/negative/$fixtureName.json")), + ) + assertTrue(messages.isNotEmpty()) + assertTrue(messages.any { it.path == expectedPath || it.message.contains(expectedPath) }) + } + + private fun assertValid(schemaName: String, node: JsonNode) { + assertEquals(emptySet(), schema(schemaName).validate(node)) + } + + private fun schema(name: String) = factory.getSchema( + mapper.readTree(resource("schemas/v1/$name.schema.json")), + ) + + private fun resource(path: String): String = requireNotNull(javaClass.classLoader.getResource(path)) { + "Resource $path is not packaged" + }.readText() +} diff --git a/integration-core/preview-sdk-compose/README.md b/integration-core/preview-sdk-compose/README.md new file mode 100644 index 000000000..5f74c9143 --- /dev/null +++ b/integration-core/preview-sdk-compose/README.md @@ -0,0 +1,8 @@ +# Compose Preview SDK + +Экспериментальный KMP SDK преобразует renderer-ready `PreviewPayload` в Compose-значения. +MVP поддерживает theme tokens, runtime TTF/OTF fonts и общие contracts style factories. +UIKit-specific BasicButton assembly находится в `integration-core:preview-compose-plugin`. + +В модуль намеренно не входят универсальная генерация style factories, Gradle tooling plugin, +manifest/distribution и browser host bridge. diff --git a/integration-core/preview-sdk-compose/build.gradle.kts b/integration-core/preview-sdk-compose/build.gradle.kts new file mode 100644 index 000000000..686cb834a --- /dev/null +++ b/integration-core/preview-sdk-compose/build.gradle.kts @@ -0,0 +1,26 @@ +import utils.addDefaultTargets + +plugins { + id("convention.cmp-lib") +} + +group = "integration-core" + +android { namespace = "com.sdds.preview.compose" } + +kotlin { + addDefaultTargets() + sourceSets { + commonMain.dependencies { + api(project(":preview-contract")) + api(project(":sandbox-core")) + implementation(libs.sdds.uikit.compose) + implementation(compose.foundation) + implementation(compose.ui) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } +} diff --git a/integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt b/integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt new file mode 100644 index 000000000..2acab6288 --- /dev/null +++ b/integration-core/preview-sdk-compose/src/commonMain/kotlin/com/sdds/preview/compose/ComposePreviewRuntime.kt @@ -0,0 +1,231 @@ +package com.sdds.preview.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.sdds.compose.sandbox.ComposeBaseStory +import com.sdds.compose.uikit.style.Style +import com.sdds.preview.contract.PreviewComponentDescription +import com.sdds.preview.contract.PreviewComponentDescriptionResult +import com.sdds.preview.contract.PreviewContractJson +import com.sdds.preview.contract.PreviewExamplePropertyDescription +import com.sdds.preview.contract.PreviewPayload +import com.sdds.preview.contract.PreviewWarning +import com.sdds.preview.contract.validate +import com.sdds.preview.contract.validateVersion +import com.sdds.sandbox.Property +import com.sdds.sandbox.StoryEntry +import com.sdds.sandbox.UiState +import kotlinx.serialization.decodeFromString + +/** Подготовленный preview, скрывающий конкретные типы story state и style. */ +public interface PreparedComposePreview { + /** Идентификатор исходного render request. */ + public val requestId: String + + /** Предупреждения, которые нужно вернуть вместе с успешным результатом. */ + public val warnings: List + get() = emptyList() + + /** Фон viewport из `PreviewPayload.surface`, если он задан корректным цветом. */ + public val surfaceBackground: Color? + get() = null + + /** Отображает подготовленные style и state через существующую story. */ + @Composable + public fun Content() +} + +/** Type-erased binding существующей story и соответствующей style factory. */ +public interface ComposePreviewComponent { + /** Идентификатор компонента из Preview Protocol. */ + public val componentId: String + + /** Описание example properties зарегистрированной story. */ + public val description: PreviewComponentDescription + + /** Проверяет payload, специфичный для компонента (например, типы вложенных стилей), до подготовки темы. */ + public fun validate(payload: PreviewPayload) {} + + /** Подготавливает component-specific state после подготовки общей темы. */ + public fun prepare(payload: PreviewPayload, theme: PreparedComposeTheme): PreparedComposePreview +} + +/** + * Создаёт preview binding без отдельного renderer-класса для компонента. + * Типы state и style остаются связанными внутри SDK generic implementation. + */ +public fun previewComponent( + story: ComposeBaseStory, + styleFactory: ComposePreviewStyleFactory, +): ComposePreviewComponent = TypedComposePreviewComponent(story, styleFactory) + +/** + * Создаёт preview binding из type-erased истории и фабрики (записи реестра). Совместимость классов стиля + * истории и фабрики + * должна быть проверена вызывающим (реестр знает `styleQualifiedName`). + */ +@Suppress("UNCHECKED_CAST") +public fun previewComponentOf( + componentId: String, + story: ComposeBaseStory<*, *>, + styleFactory: ComposePreviewStyleFactory<*>, +): ComposePreviewComponent = TypedComposePreviewComponent( + story as ComposeBaseStory, + styleFactory as ComposePreviewStyleFactory