feat: add React card renderer and interactive UML

This commit is contained in:
user
2026-07-16 13:19:22 +02:00
parent da06a2b81c
commit 41804a8ad1
9 changed files with 2143 additions and 37 deletions
+1
View File
@@ -1,6 +1,7 @@
__pycache__/
*.py[cod]
.pytest_cache/
node_modules/
examples/card/build/
examples/card/web/
+605
View File
@@ -0,0 +1,605 @@
{
"name": "stem-card-layouts-react-renderer",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stem-card-layouts-react-renderer",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.6",
"typescript": "^5.8.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "stem-card-layouts-react-renderer",
"private": true,
"type": "module",
"scripts": {
"build:react": "node react/build.mjs",
"typecheck:react": "tsc --noEmit -p react/tsconfig.json"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.6",
"typescript": "^5.8.3"
}
}
+19
View File
@@ -0,0 +1,19 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const outputDirectory = path.resolve(process.argv[2] ?? "web");
const reactDirectory = path.dirname(fileURLToPath(import.meta.url));
await build({
entryPoints: [path.join(reactDirectory, "src/main.tsx")],
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
minify: true,
sourcemap: false,
define: { "process.env.NODE_ENV": '"production"' },
outfile: path.join(outputDirectory, "app.js"),
loader: { ".tsx": "tsx" },
});
+729
View File
@@ -0,0 +1,729 @@
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./react.css";
type Status = "not_started" | "discussed" | "completed";
type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text";
type InteractiveSymbol = {
id: string;
area: InteractiveArea;
declaration: string;
description: string;
};
type UmlStage = {
id: string;
label: string;
description: string;
code_ref?: string;
};
type InteractiveAsset = {
kind: "allocator-memory-flow" | "uml-sequence";
storage_key?: string;
arena_size?: number;
allocations?: number[];
symbols?: InteractiveSymbol[];
stages?: UmlStage[];
};
type Asset = {
label: string;
caption: string;
alt: string;
href: string;
width: number;
kind: string;
source_href?: string;
interactive?: InteractiveAsset | null;
};
type FlowStep = { id: string; title: string; content_html: string };
type FlowBlock = {
id: string;
kind: "block" | "exercise";
title: string;
content_html: string;
steps: FlowStep[];
};
type Task = {
ref: string;
title: string;
uuid?: string;
criterion: string;
prompt_html?: string;
flow: FlowBlock[];
};
type Section = {
id: string;
index: number;
title: string;
header_html: string;
left_margin_html: string;
right_margin_html: string;
content_html: string;
tasks: Task[];
assets: Asset[];
steps: { id: string; title: string }[];
};
type Progress = {
items: Record<string, { status: Status }>;
summary: { total: number; counts: Record<Status, number> };
};
type CardModel = {
schema: string;
card: Record<string, string>;
total_pages: number;
front: any;
sections: Section[];
dictionary: { header_html: string; content_html: string };
toc: { id: string; label: string }[];
};
const Html = ({ html, className }: { html: string; className?: string }) => (
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
);
function Header({ html }: { html: string }) {
return <Html html={html} />;
}
function Margin({ side, html }: { side: "left" | "right"; html: string }) {
return (
<aside
className={`pdf-margin ${side}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
function Sheet({
header,
left = "",
right = "",
children,
className = "",
}: React.PropsWithChildren<any>) {
return (
<article className={`sheet ${className}`}>
<div className="sheet-content">
<Header html={header} />
<Margin side="left" html={left} />
<section className="pdf-main card-section">{children}</section>
<Margin side="right" html={right} />
</div>
</article>
);
}
function Topbar({
toc,
scale,
setScale,
}: {
toc: CardModel["toc"];
scale: number;
setScale: (value: number) => void;
}) {
return (
<nav className="topbar">
<div className="topbar-inner">
<details>
<summary>Spis treści</summary>
<div className="toc-links">
{toc.map((item) => (
<a key={item.id} href={`#${item.id}`}>
{item.label}
</a>
))}
</div>
</details>
<div className="viewer-zoom-controls">
<output className="viewer-zoom-status">
Płótno {Math.round(scale * 100)}% · Treść 100%
</output>
<button className="viewer-zoom-reset" onClick={() => setScale(2.18)}>
Reset zoom
</button>
</div>
</div>
</nav>
);
}
function FrontPage({ front }: { front: any }) {
return (
<Sheet
header={front.header_html}
left={front.left_margin_html}
right={front.right_margin_html}
className="front-sheet"
>
<section className="front-scope front-goal">
<h2>{front.goal_title}</h2>
<Html html={front.goal_html} />
</section>
<section className="front-scope front-card-scope">
<h2>{front.scope_title}</h2>
{front.scope_html && <Html html={front.scope_html} />}
<div className="scope-table-wrap">
<table className="scope-table scope-table--task">
<thead>
<tr>
{front.scope_headers.map((header: string) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
<tbody>
{front.scope_rows.map((row: any) => (
<tr
key={`${row.chapter}-${row.task}`}
className={row.key ? "scope-row--key" : ""}
>
<td>
<code>{row.chapter}</code>
</td>
<td>
<code>{row.task}</code>
</td>
<td dangerouslySetInnerHTML={{ __html: row.idea_html }} />
<td>{row.priority}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</Sheet>
);
}
function TaskStep({ taskRef, step }: { taskRef: string; step: FlowStep }) {
return (
<li id={`${taskRef}-${step.id}`}>
<strong>{step.title}</strong>
<Html html={step.content_html} />
</li>
);
}
function TaskBlock({ taskRef, block }: { taskRef: string; block: FlowBlock }) {
const label = block.kind === "exercise" ? "Ćwiczenie" : "Block";
return (
<section
className={`task-block task-block--${block.kind}`}
id={`${taskRef}-${block.id}`}
>
<header>
<span>{label}</span>
<h4>{block.title}</h4>
</header>
<Html html={block.content_html} />
{block.steps.length > 0 && (
<ol className="block-steps">
{block.steps.map((step) => (
<TaskStep key={step.id} taskRef={taskRef} step={step} />
))}
</ol>
)}
</section>
);
}
function TaskFlow({ task }: { task: Task }) {
if (!task.flow.length)
return (
<article className="task-legacy" id={task.ref}>
<strong>{task.ref.toUpperCase()}</strong>
<Html html={task.prompt_html ?? ""} />
<footer>
<strong>Task acceptance:</strong> {task.criterion}
</footer>
</article>
);
return (
<article className="task-flow" id={task.ref}>
<header className="task-flow-header">
<span>{task.ref.toUpperCase()}</span>
<h3>{task.title}</h3>
{task.uuid && <code>{task.uuid}</code>}
</header>
{task.flow.map((block) => (
<TaskBlock key={block.id} taskRef={task.ref} block={block} />
))}
<footer>
<strong>Task acceptance:</strong> {task.criterion}
</footer>
</article>
);
}
const areaLabels: Record<InteractiveArea, string> = {
compile: "KOMPILACJA",
bss: ".BSS",
stack: "STOS",
registers: "REJESTRY",
text: ".TEXT",
};
const allocationColors = ["#9bcfe2", "#c4dfc0", "#f4c889", "#d2b6dc"];
function InteractiveMemoryFlow({
config,
title,
}: {
config: InteractiveAsset;
title: string;
}) {
const arenaSize = config.arena_size ?? 64;
const allocations = config.allocations ?? [5, 7];
const symbols = config.symbols ?? [];
const storageKey = `${config.storage_key ?? "allocator-memory-flow"}-stage`;
const [stage, setStage] = useState(() =>
Math.min(allocations.length, Number(localStorage.getItem(storageKey) ?? 0)),
);
const [focus, setFocus] = useState(symbols[0]?.id ?? "");
useEffect(
() => localStorage.setItem(storageKey, String(stage)),
[stage, storageKey],
);
const used = allocations.slice(0, stage).reduce((sum, size) => sum + size, 0);
const focused = symbols.find((symbol) => symbol.id === focus) ?? symbols[0];
const cells = Array.from({ length: arenaSize }, (_, index) => index);
const allocationIndex = (cell: number) => {
let end = 0;
for (let index = 0; index < stage; index += 1) {
end += allocations[index];
if (cell < end) return index;
}
return -1;
};
const stageText =
stage === 0
? "allocator_reset(): allocp = allocbuf"
: `alloc_local(${allocations[stage - 1]}): allocp = allocbuf + ${used}`;
const areas = (Object.keys(areaLabels) as InteractiveArea[]).filter((area) =>
symbols.some((symbol) => symbol.area === area),
);
return (
<div className="memory-react" aria-label={`Interaktywny diagram: ${title}`}>
<header>
<div>
<small>INTERAKTYWNY MODEL PAMIĘCI</small>
<h3>{title}</h3>
</div>
<div className="memory-actions">
<button aria-pressed={stage === 0} onClick={() => setStage(0)}>
definicje / reset
</button>
{allocations.map((size, index) => (
<button
key={`${size}-${index}`}
aria-pressed={stage === index + 1}
onClick={() => setStage(index + 1)}
>
alloc({size})
</button>
))}
</div>
</header>
<div className="memory-lanes">
{areas.map((area) => (
<section className={`memory-lane ${area}`} key={area}>
<b>{areaLabels[area]}</b>
{symbols
.filter((symbol) => symbol.area === area)
.map((symbol) => (
<button
key={symbol.id}
className={focus === symbol.id ? "active" : ""}
onClick={() => setFocus(symbol.id)}
>
<code>{symbol.declaration}</code>
</button>
))}
</section>
))}
</div>
<div className="arena-wrap">
<div className="arena-label">
<code>allocbuf[{arenaSize}]</code>
<span>
użyte {used} B / wolne {arenaSize - used} B
</span>
</div>
<div
className="arena"
style={
{
"--allocp": used,
"--arena-size": arenaSize,
} as React.CSSProperties
}
>
{cells.map((cell) => {
const segment = allocationIndex(cell);
return (
<i
key={cell}
style={
segment >= 0
? {
background:
allocationColors[segment % allocationColors.length],
}
: undefined
}
/>
);
})}
<span className="allocp">allocp {used}</span>
</div>
</div>
<p className="memory-stage">
<strong>
{stage + 1}/{allocations.length + 1}
</strong>{" "}
<code>{stageText}</code>
</p>
{focused && (
<aside className="memory-inspector">
<strong>{focused.id}</strong>
<code>{focused.declaration}</code>
<span>{focused.description}</span>
</aside>
)}
</div>
);
}
function InteractiveUmlSequence({
config,
asset,
}: {
config: InteractiveAsset;
asset: Asset;
}) {
const stages = config.stages ?? [];
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
const [stageIndex, setStageIndex] = useState(() =>
Math.max(
0,
Math.min(
stages.length - 1,
Number(localStorage.getItem(storageKey) ?? 0),
),
),
);
useEffect(
() => localStorage.setItem(storageKey, String(stageIndex)),
[stageIndex, storageKey],
);
const stage = stages[stageIndex];
return (
<div className="uml-react">
<header>
<div>
<small>KANONICZNY DIAGRAM UML · PLANTUML</small>
<h3>Reset i dwa przydziały liniowe</h3>
</div>
<div className="uml-actions">
{stages.map((item, index) => (
<button
key={item.id}
aria-pressed={stageIndex === index}
onClick={() => setStageIndex(index)}
>
{item.label}
</button>
))}
</div>
</header>
<a
className="uml-canvas"
href={asset.href}
target="_blank"
rel="noopener"
>
<img src={asset.href} alt={asset.alt} />
</a>
{stage && (
<aside className="uml-stage">
<strong>{stage.label}</strong>
<span>{stage.description}</span>
{stage.code_ref && <code>{stage.code_ref}</code>}
</aside>
)}
</div>
);
}
function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
const interactiveKind = asset.interactive?.kind;
const isInteractive = Boolean(interactiveKind);
return (
<figure
className={`card-asset asset-${asset.kind} ${isInteractive ? "asset-interactive" : ""}`}
id={asset.label.replace(":", "-")}
>
{interactiveKind === "uml-sequence" ? (
<InteractiveUmlSequence config={asset.interactive!} asset={asset} />
) : interactiveKind === "allocator-memory-flow" ? (
<>
<InteractiveMemoryFlow
config={asset.interactive!}
title={asset.caption}
/>
<img
className="asset-static-fallback"
src={asset.href}
alt={asset.alt}
/>
</>
) : (
<a href={asset.href} target="_blank" rel="noopener">
<img
src={asset.href}
alt={asset.alt}
loading="lazy"
style={{ width: `${asset.width * 100}%` }}
/>
</a>
)}
{asset.source_href && (
<a
className="asset-source-link"
href={asset.source_href}
target="_blank"
rel="noopener"
>
Źródło PlantUML
</a>
)}
<figcaption>
Rys. {number}. {asset.caption}
</figcaption>
</figure>
);
}
function StepMap({
steps,
progress,
onCycle,
}: {
steps: Section["steps"];
progress: Progress | null;
onCycle: (id: string, status: Status) => void;
}) {
if (!steps.length) return null;
const labels: Record<Status, string> = {
not_started: "○ do omówienia",
discussed: "◐ omówione",
completed: "● wykonane",
};
return (
<div className="step-map">
{steps.map((step) => {
const status: Status =
progress?.items?.[step.id]?.status ?? "not_started";
return (
<div
className={`step-row ${status === "completed" ? "lesson-progress-completed" : ""}`}
key={step.id}
>
<button
className="lesson-progress-control"
data-status={status}
onClick={() => onCycle(step.id, status)}
disabled={!progress}
>
{labels[status]}
</button>
<span className="step-title">{step.title}</span>
</div>
);
})}
</div>
);
}
function SectionPage({
section,
progress,
onCycle,
figureOffset,
}: {
section: Section;
progress: Progress | null;
onCycle: (id: string, status: Status) => void;
figureOffset: number;
}) {
return (
<Sheet
header={section.header_html}
left={section.left_margin_html}
right={section.right_margin_html}
className="section-sheet"
>
<div id={section.id} className="section-anchor" />
<h2 className="section-heading">
<span className="section-index">{section.index}</span>
<span className="section-title">{section.title}</span>
</h2>
<StepMap steps={section.steps} progress={progress} onCycle={onCycle} />
{section.content_html && <Html html={section.content_html} />}
<div className="tasks">
{section.tasks.map((task) => (
<TaskFlow key={task.ref} task={task} />
))}
</div>
{section.assets.map((asset, index) => (
<AssetFigure
key={asset.label}
asset={asset}
number={figureOffset + index + 1}
/>
))}
</Sheet>
);
}
function ProgressDock({
progress,
error,
}: {
progress: Progress | null;
error: string;
}) {
if (!progress?.summary && !error) return null;
const counts = progress?.summary.counts;
return (
<aside className="lesson-progress-dock">
<strong>Przebieg zajęć</strong>
{counts && (
<p>
Wykonane: {counts.completed}/{progress!.summary.total} · omówione:{" "}
{counts.discussed}/{progress!.summary.total}
</p>
)}
{error && <p className="lesson-progress-error">{error}</p>}
<a href="/api/report/teams.md" target="_blank" rel="noopener">
Otwórz raport do Teams
</a>
</aside>
);
}
function App({ model }: { model: CardModel }) {
const [scale, setScaleState] = useState(2.18);
const [progress, setProgress] = useState<Progress | null>(null);
const [progressError, setProgressError] = useState("");
const setScale = (value: number) =>
setScaleState(Math.max(0.25, Math.min(3, value)));
useEffect(() => {
document.documentElement.style.setProperty(
"--viewer-canvas-scale",
scale.toFixed(3),
);
}, [scale]);
useEffect(() => {
fetch("/api/progress")
.then((response) =>
response.ok
? response.json()
: Promise.reject(new Error(`HTTP ${response.status}`)),
)
.then((data) => setProgress(data.progress))
.catch((error) =>
setProgressError(
`Postęp działa tylko przez serwer karty (${error.message}).`,
),
);
}, []);
useEffect(() => {
const wheel = (event: WheelEvent) => {
if (!event.altKey || event.ctrlKey) return;
event.preventDefault();
setScaleState((current) =>
Math.max(
0.25,
Math.min(
3,
current *
Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
),
),
);
};
window.addEventListener("wheel", wheel, { passive: false });
return () => window.removeEventListener("wheel", wheel);
}, []);
const cycle = async (id: string, current: Status) => {
const next: Record<Status, Status> = {
not_started: "discussed",
discussed: "completed",
completed: "not_started",
};
try {
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: next[current] }),
});
if (!response.ok) throw new Error(await response.text());
setProgress((await response.json()).progress);
setProgressError("");
} catch (error) {
setProgressError(
`Nie zapisano postępu: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
let figureCount = 0;
return (
<>
<Topbar toc={model.toc} scale={scale} setScale={setScale} />
<main className="paper">
<FrontPage front={model.front} />
{model.sections.map((section) => {
const offset = figureCount;
figureCount += section.assets.length;
return (
<SectionPage
key={section.id}
section={section}
progress={progress}
onCycle={cycle}
figureOffset={offset}
/>
);
})}
<Sheet
header={model.dictionary.header_html}
className="dictionary-sheet"
>
<h2>Słownik WE/EN/EK/KW</h2>
<Html html={model.dictionary.content_html} />
</Sheet>
</main>
<ProgressDock progress={progress} error={progressError} />
</>
);
}
async function start() {
const root = createRoot(document.getElementById("root")!);
try {
const response = await fetch("./card-data.json", { cache: "no-store" });
if (!response.ok)
throw new Error(`Nie można wczytać card-data.json: ${response.status}`);
const model = (await response.json()) as CardModel;
root.render(<App model={model} />);
} catch (error) {
root.render(
<main className="app-load-error">
<h1>Nie udało się uruchomić karty</h1>
<pre>{error instanceof Error ? error.message : String(error)}</pre>
</main>,
);
}
}
void start();
+328
View File
@@ -0,0 +1,328 @@
.memory-react {
border: 1px solid #555;
background: #fff;
font:
14px/1.35 system-ui,
sans-serif;
padding: 10px;
color: #1b1b1b;
}
.memory-react > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #bbb;
padding-bottom: 7px;
}
.memory-react > header small {
display: block;
margin-bottom: 2px;
color: #555;
font:
700 10px/1.2 ui-monospace,
monospace;
letter-spacing: 0.08em;
}
.memory-react h3 {
font-size: 15px;
margin: 0;
}
.memory-actions {
display: flex;
gap: 5px;
flex-wrap: wrap;
justify-content: flex-end;
}
.memory-actions button {
border: 1px solid #777;
background: #fff;
padding: 4px 8px;
cursor: pointer;
}
.memory-actions button[aria-pressed="true"] {
background: #dff3fb;
border-color: #237da1;
font-weight: 700;
}
.memory-lanes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
gap: 6px;
margin: 8px 0;
}
.memory-lane {
min-width: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 3px;
border: 1px solid #999;
background: #fafafa;
padding: 7px;
text-align: left;
}
.memory-lane.compile {
background: #fff8dc;
}
.memory-lane.bss {
background: #edf7fb;
}
.memory-lane.stack {
background: #f3f8ef;
}
.memory-lane.registers {
background: #f4f4f4;
}
.memory-lane.text {
background: #f7f0fa;
}
.memory-lane > b {
font-size: 11px;
letter-spacing: 0.04em;
}
.memory-lane button {
border: 1px solid transparent;
background: transparent;
padding: 3px;
text-align: left;
cursor: pointer;
}
.memory-lane button:hover,
.memory-lane button.active {
border-color: #238bb7;
background: #fff;
}
.memory-lane code {
font-size: 10px;
white-space: normal;
}
.arena-wrap {
margin: 12px 0 6px;
}
.arena-label {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.arena {
--allocp: 0;
--arena-size: 64;
position: relative;
display: grid;
grid-template-columns: repeat(var(--arena-size), 1fr);
height: 40px;
border: 1px solid #222;
margin-bottom: 28px;
}
.arena i {
border-right: 1px solid #ddd;
}
.arena .allocp {
position: absolute;
left: calc(var(--allocp) / var(--arena-size) * 100%);
top: 43px;
transform: translateX(-50%);
color: #087da8;
font:
700 11px/1.2 ui-monospace,
monospace;
white-space: nowrap;
}
.memory-stage {
border-top: 1px solid #ddd;
padding-top: 7px;
margin: 0;
}
.memory-inspector {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 10px;
margin-top: 7px;
padding: 7px;
border-left: 3px solid #238bb7;
background: #f5fbfd;
}
.memory-inspector strong {
grid-row: 1/3;
}
.memory-inspector code {
font-size: 11px;
}
.memory-inspector span {
font-size: 12px;
}
.asset-static-fallback {
display: none;
}
.uml-react {
border: 1px solid #555;
background: #fff;
font:
13px/1.35 system-ui,
sans-serif;
color: #1b1b1b;
}
.uml-react > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid #bbb;
background: #fafafa;
}
.uml-react > header small {
display: block;
color: #555;
font:
700 10px/1.2 ui-monospace,
monospace;
letter-spacing: 0.06em;
}
.uml-react h3 {
margin: 2px 0 0;
font-size: 15px;
}
.uml-actions {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.uml-actions button {
border: 1px solid #777;
background: #fff;
padding: 4px 8px;
cursor: pointer;
}
.uml-actions button[aria-pressed="true"] {
border-color: #315f78;
background: #eaf4fa;
font-weight: 700;
}
.uml-canvas {
display: block;
padding: 8px;
text-align: center;
}
.uml-canvas img {
display: block;
width: 100%;
max-height: 760px;
object-fit: contain;
}
.uml-stage {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 10px;
margin: 0 8px 8px;
padding: 8px;
border-left: 3px solid #315f78;
background: #eef7fb;
}
.uml-stage strong {
grid-row: 1/3;
}
.uml-stage code {
grid-column: 2;
font-size: 11px;
}
.asset-source-link {
display: inline-block;
margin: 5px 0;
color: #315f78;
font:
600 11px/1.3 system-ui,
sans-serif;
}
.lesson-progress-control {
display: inline-flex;
align-items: center;
margin-right: 7px;
padding: 2px 7px;
border: 1px solid #8c8c8c;
border-radius: 12px;
background: #fff;
cursor: pointer;
}
.lesson-progress-control:disabled {
cursor: wait;
opacity: 0.6;
}
.lesson-progress-control[data-status="discussed"] {
background: #fff4cc;
border-color: #9b6a00;
}
.lesson-progress-control[data-status="completed"] {
background: #e7f6ed;
border-color: #18794e;
}
.lesson-progress-dock {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 30;
width: min(320px, calc(100vw - 24px));
border: 1px solid #555;
background: #fff;
padding: 10px;
box-shadow: 0 4px 18px #0003;
font:
14px/1.35 system-ui,
sans-serif;
}
.lesson-progress-dock p {
margin: 5px 0;
}
.lesson-progress-error {
color: #9b1c1c;
}
.step-row {
display: flex;
align-items: center;
margin: 4px 0;
}
.section-anchor {
position: relative;
top: -45px;
}
.app-load-error {
max-width: 760px;
margin: 10vh auto;
padding: 24px;
border: 1px solid #9b1c1c;
background: #fff;
font-family: system-ui, sans-serif;
}
.app-load-error pre {
white-space: pre-wrap;
}
@media (max-width: 800px) {
.memory-lanes {
grid-template-columns: 1fr 1fr;
}
.memory-react > header,
.uml-react > header {
align-items: flex-start;
flex-direction: column;
}
.memory-actions {
justify-content: flex-start;
}
}
@media print {
.memory-react {
display: none !important;
}
.asset-static-fallback {
display: block !important;
width: 100%;
}
.uml-actions,
.uml-stage,
.asset-source-link,
.lesson-progress-dock,
.lesson-progress-control {
display: none !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+71
View File
@@ -260,6 +260,10 @@
"html_tree_inspector": {
"type": "boolean"
},
"react_app": {
"type": "boolean",
"description": "Generuje komponentowy podgląd React oraz jawny view-model card-data.json."
},
"bibliography": {
"type": "string"
},
@@ -575,6 +579,16 @@
"type": "string",
"minLength": 1
},
"html_path": {
"type": "string",
"minLength": 1,
"description": "Opcjonalny wariant zasobu dla HTML, np. SVG zamiast PNG używanego w TeX."
},
"source_path": {
"type": "string",
"minLength": 1,
"description": "Opcjonalne edytowalne źródło rysunku, np. plik PlantUML."
},
"caption": {
"type": "string",
"minLength": 1
@@ -596,6 +610,63 @@
},
"full_size_link": {
"type": "boolean"
},
"interactive": {
"$ref": "#/$defs/interactiveAsset"
}
},
"additionalProperties": false
},
"interactiveAsset": {
"type": "object",
"required": ["kind"],
"properties": {
"kind": {
"enum": ["allocator-memory-flow", "uml-sequence"]
},
"storage_key": {
"type": "string",
"minLength": 1
},
"arena_size": {
"type": "integer",
"minimum": 1
},
"allocations": {
"type": "array",
"items": {
"type": "integer",
"minimum": 1
}
},
"symbols": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "area", "declaration", "description"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"area": { "enum": ["compile", "bss", "stack", "registers", "text"] },
"declaration": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
}
},
"stages": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "label", "description"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"code_ref": { "type": "string" }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
+358 -37
View File
@@ -699,6 +699,7 @@ def render_preamble(data: dict) -> list[str]:
r"\usepackage{fancyhdr}",
r"\usepackage{lastpage}",
r"\usepackage{microtype}",
r"\usepackage[most]{tcolorbox}",
r"\usepackage{qrcode}",
r"\IfFileExists{references.bib}{%",
r" \usepackage[backend=biber,style=numeric,sorting=none]{biblatex}%",
@@ -761,6 +762,9 @@ def render_preamble(data: dict) -> list[str]:
r" \noindent{\color{black!18}\leaders\hbox{\rule{0.60em}{0.22pt}\hspace{0.38em}}\hfill\kern0pt}%",
r" \par\vspace{0.10em}%",
r"}",
r"\newtcolorbox{ESCTaskFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm,colbacktitle=black!7,coltitle=black,title={\ttfamily\bfseries TASK\quad #1}}",
r"\newtcolorbox{ESCBlockFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!55,boxsep=0pt,left=1.0mm,right=1.0mm,top=0.6mm,bottom=0.6mm,colbacktitle=black!4,coltitle=black,title={\ttfamily\bfseries BLOCK\quad #1}}",
r"\newtcolorbox{ESCStepFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!28,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.45mm,bottom=0.45mm,colbacktitle=black!2,coltitle=black,title={\ttfamily STEP\quad #1}}",
"",
]
@@ -844,6 +848,48 @@ def render_front_matter(card: dict) -> list[str]:
return lines
def render_front_scope_table_tex(scope: dict) -> list[str]:
table = scope.get("scope_table") or {}
rows = table.get("rows") or []
if not rows:
return []
has_task_column = any(str(row.get("task", "")).strip() for row in rows)
default_headers = (
["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"]
if has_task_column
else ["K&R", "Najważniejsza idea", "Priorytet"]
)
headers = table.get("headers") or default_headers
if len(headers) != len(default_headers):
headers = default_headers
column_spec = (
r"@{}>{\ttfamily}p{1.05cm}>{\ttfamily}p{1.55cm}X>{\raggedright\arraybackslash}p{3.25cm}@{}"
if has_task_column
else r"@{}>{\ttfamily}p{1.25cm}X>{\raggedright\arraybackslash}p{3.55cm}@{}"
)
lines = [
r"\vspace{0.55em}",
rf"\small\begin{{tabularx}}{{\textwidth}}{{{column_spec}}}",
" & ".join(rf"\textbf{{{tex_escape(header)}}}" for header in headers) + r" \\ \hline",
]
for row in rows:
cells = [rf"\texttt{{{tex_escape(row.get('chapter', ''))}}}"]
if has_task_column:
cells.append(rf"\texttt{{{tex_escape(row.get('task', ''))}}}")
cells.extend([row.get("idea_tex", ""), tex_escape(row.get("priority", ""))])
if str(row.get("task", "")).strip().lower() == "task04":
framed_cells = []
for index, cell in enumerate(cells):
alignment = "|l|" if index == 0 else "l|"
framed_cells.append(rf"\multicolumn{{1}}{{{alignment}}}{{\textbf{{{cell}}}}}")
lines.append(r"\hline")
lines.append(" & ".join(framed_cells) + r" \\ \hline")
else:
lines.append(" & ".join(cells) + r" \\ \hline")
lines.extend([r"\end{tabularx}", r"\normalsize", ""])
return lines
def render_front_page_scope(data: dict) -> list[str]:
scope = data.get("front_page_scope")
if not scope:
@@ -853,18 +899,37 @@ def render_front_page_scope(data: dict) -> list[str]:
rf"\noindent{{\Large\bfseries {tex_escape(scope.get('title', 'Zakres i cel opracowania'))}}}\par",
r"\vspace{0.35em}",
scope.get("content_tex", ""),
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
"",
]
if scope.get("scope_title"):
lines.extend(
[
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
r"\vspace{0.7em}",
rf"\noindent{{\Large\bfseries {tex_escape(scope['scope_title'])}}}\par",
r"\vspace{0.35em}",
scope.get("scope_content_tex", ""),
]
)
lines.extend(render_front_scope_table_tex(scope))
if scope.get("scope_footer_tex"):
lines.extend([scope["scope_footer_tex"], ""])
lines.extend(
[
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
"",
]
)
return lines
def render_task_flow_tex(task_ref: str, task: dict) -> list[str]:
title = task.get("title") or task_ref
lines = [
rf"\subsection*{{{tex_escape(task_ref.upper())} · {tex_escape(title)}}}",
rf"\begin{{ESCTaskFrame}}{{{tex_escape(task_ref.upper())} · {tex_escape(title)}}}",
]
if task.get("uuid"):
lines.append(rf"{{\scriptsize\ttfamily {tex_escape(task['uuid'])}}}\par")
@@ -873,32 +938,33 @@ def render_task_flow_tex(task_ref: str, task: dict) -> list[str]:
if kind == "block":
lines.extend(
[
r"\par\medskip\noindent\textcolor{black!35}{\rule{\linewidth}{0.35pt}}\par",
rf"\noindent{{\large\bfseries Block · {tex_escape(item.get('title', ''))}}}\par",
rf"\begin{{ESCBlockFrame}}{{{tex_escape(item.get('title', ''))}}}",
]
)
if item.get("content_tex"):
lines.append(str(item["content_tex"]))
steps = item.get("steps", []) or []
if steps:
lines.append(r"\begin{enumerate}[label=\textbf{K\arabic*.},leftmargin=*]")
for step in steps:
content = str(step.get("content_tex", ""))
suffix = f" {content}" if content else ""
lines.append(rf" \item \textbf{{{tex_escape(step.get('title', ''))}}}{suffix}")
lines.append(r"\end{enumerate}")
lines.append(rf"\begin{{ESCStepFrame}}{{{tex_escape(step.get('id', ''))} · {tex_escape(step.get('title', ''))}}}")
if content:
lines.append(content)
lines.append(r"\end{ESCStepFrame}")
lines.append(r"\end{ESCBlockFrame}")
elif kind == "exercise":
lines.extend(
[
r"\par\medskip\noindent\textcolor{black!55}{\rule{\linewidth}{0.6pt}}\par",
rf"\noindent{{\large\bfseries Exercise · {tex_escape(item.get('title', ''))}}}\par",
rf"\begin{{ESCBlockFrame}}{{Ćwiczenie · {tex_escape(item.get('title', ''))}}}",
str(item.get("prompt_tex", "")),
]
)
if item.get("evidence_tex"):
lines.append(rf"\par\textbf{{Evidence:}} {item['evidence_tex']}")
lines.append(rf"\par\textbf{{Acceptance:}} {tex_escape(item.get('criterion', ''))}")
lines.append(rf"\par\textbf{{Task acceptance:}} {tex_escape(task.get('criterion', ''))}")
lines.append(r"\end{ESCBlockFrame}")
lines.append(rf"\tcblower\textbf{{Task acceptance:}} {tex_escape(task.get('criterion', ''))}")
lines.append(r"\end{ESCTaskFrame}")
return lines
@@ -906,7 +972,11 @@ def render_tasks(section: dict, data: dict) -> list[str]:
tasks = data["tasks"]
lines: list[str] = []
legacy_refs: list[str] = []
for task_ref in section["task_refs"]:
for index, task_ref in enumerate(section["task_refs"]):
if index:
# Kolejny task jest niezależnym etapem karty: jego ramka ma
# zaczynać nową stronę, a nie być kontynuacją poprzedniego.
lines.append(r"\clearpage")
task = tasks[task_ref]
if task.get("flow"):
lines.extend(render_task_flow_tex(task_ref, task))
@@ -1747,9 +1817,22 @@ def render_section_margin(section: dict, data: dict) -> list[str]:
return render_dual_margin_block(general_tags, vocational_tags, voffset="-3.1em", data=data)
def ordered_sections(data: dict) -> list[dict]:
return [
section
for _, section in sorted(
enumerate(data.get("sections", [])),
key=lambda entry: (entry[1].get("order", 1000), entry[0]),
)
]
def render_sections(data: dict) -> list[str]:
lines: list[str] = []
for section in data["sections"]:
for section in ordered_sections(data):
if section.get("content_kind") == "tasks":
# Pierwszy task danej grupy również dostaje własny początek strony.
lines.append(r"\clearpage")
command = r"\section*" if section.get("starred") else r"\section"
lines.append(r"\ESCSectionBlockStart")
lines.append(rf"{command}{{{tex_escape(section['title'])}}}")
@@ -2206,7 +2289,7 @@ def collect_numbered_labels(data: dict) -> tuple[dict[str, int], dict[str, int]]
figure_numbers: dict[str, int] = {}
equation_no = 0
figure_no = 0
for section in data.get("sections", []):
for section in ordered_sections(data):
content = section.get("content_tex", "")
for match in re.finditer(
r"\\begin\{(?P<kind>equation|figure)\}.*?\\label\{(?P<label>[^}]+)\}.*?\\end\{(?P=kind)\}",
@@ -2785,6 +2868,7 @@ def render_bibliography_section_html(data: dict) -> str:
def render_main_html(data: dict) -> str:
equation_numbers, figure_numbers = collect_numbered_labels(data)
sections = ordered_sections(data)
card = data["card"]
use_resource_header = bool(data.get("title_block"))
show_tree_inspector = data.get("generated", {}).get("html_tree_inspector", True)
@@ -2795,10 +2879,10 @@ def render_main_html(data: dict) -> str:
)
toc = "".join(
f'<a href="#{html_escape(html_id("section-" + str(index)))}">{index}. {html_escape(section.get("title", ""))}</a>'
for index, section in enumerate(data.get("sections", []), start=1)
for index, section in enumerate(sections, start=1)
)
bibliography_html = render_bibliography_section_html(data)
total_pages = 2 + len(data.get("sections", [])) + (1 if bibliography_html else 0)
total_pages = 2 + len(sections) + (1 if bibliography_html else 0)
rows = [
("Projekt", card["project"]),
("Seria", card.get("series_title", card["series"])),
@@ -2818,7 +2902,7 @@ def render_main_html(data: dict) -> str:
data=data,
)
sections_html: list[str] = []
for index, section in enumerate(data.get("sections", []), start=1):
for index, section in enumerate(sections, start=1):
page_number = index + 1
content = (
render_tasks_html(section, data, equation_numbers, figure_numbers)
@@ -2854,7 +2938,7 @@ def render_main_html(data: dict) -> str:
f'</div></article>'
)
if bibliography_html:
bibliography_page = len(data.get("sections", [])) + 2
bibliography_page = len(sections) + 2
sections_html.append(
f'<article class="sheet section-sheet" id="bibliografia-i-zrodla">'
f'<div class="sheet-content">'
@@ -2868,12 +2952,46 @@ def render_main_html(data: dict) -> str:
)
scope = data.get("front_page_scope", {})
scope_html = (
f'<section class="front-scope"><h2>{html_escape(scope.get("title", "Zakres i cel opracowania"))}</h2>'
f'{render_latex_fragment_html(scope.get("content_tex", ""), equation_numbers, figure_numbers)}</section>'
if scope
else ""
)
scope_html = ""
if scope:
scope_html = (
f'<section class="front-scope front-goal"><h2>{html_escape(scope.get("title", "Cel karty"))}</h2>'
f'{render_latex_fragment_html(scope.get("content_tex", ""), equation_numbers, figure_numbers)}</section>'
)
if scope.get("scope_title"):
table = scope.get("scope_table") or {}
rows = table.get("rows") or []
has_task_column = any(str(row.get("task", "")).strip() for row in rows)
default_headers = (
["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"]
if has_task_column
else ["K&R", "Najważniejsza idea", "Priorytet"]
)
headers = table.get("headers") or default_headers
if len(headers) != len(default_headers):
headers = default_headers
scope_table_html = ""
if rows:
table_rows = "".join(
f'<tr{" class=\"scope-row--key\"" if str(row.get("task", "")).strip().lower() == "task04" else ""}>'
f'<td><code>{html_escape(row.get("chapter", ""))}</code></td>'
+ (f'<td><code>{html_escape(row.get("task", ""))}</code></td>' if has_task_column else "")
+ f'<td>{latex_inline_to_html(row.get("idea_tex", ""), equation_numbers, figure_numbers)}</td>'
+ f'<td>{html_escape(row.get("priority", ""))}</td></tr>'
for row in rows
)
table_class = "scope-table scope-table--task" if has_task_column else "scope-table"
scope_table_html = (
f'<div class="scope-table-wrap"><table class="{table_class}"><thead><tr>'
+ "".join(f'<th>{html_escape(header)}</th>' for header in headers)
+ f'</tr></thead><tbody>{table_rows}</tbody></table></div>'
)
scope_html += (
f'<section class="front-scope front-card-scope"><h2>{html_escape(scope["scope_title"])}</h2>'
f'{render_latex_fragment_html(scope.get("scope_content_tex", ""), equation_numbers, figure_numbers)}'
f'{scope_table_html}'
f'{render_latex_fragment_html(scope.get("scope_footer_tex", ""), equation_numbers, figure_numbers)}</section>'
)
front_intro_html = (
scope_html
if use_resource_header
@@ -2888,14 +3006,15 @@ def render_main_html(data: dict) -> str:
css = r"""
:root{--ink:#111;--muted:#666;--line:#b9b9b9;--line-soft:#dddddd;--paper:#fff;--desk:#eeeeee;--og:#008000;--tech:#d36b00;--kw:#0018c8;--we:#e00000;--viewer-canvas-scale:1;--viewer-content-scale:1}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--desk);color:var(--ink);font-family:"Latin Modern Roman","LM Roman 12",Georgia,"Times New Roman",serif;line-height:1.25}
.topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:3px 10px;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
.topbar-inner{max-width:1120px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px}.topbar details{min-width:0;flex:1}.topbar summary{cursor:pointer;font-size:12px;line-height:1.35;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:5px}.toc-links a{font-size:11px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:3px;padding:2px 5px}.viewer-zoom-controls{display:flex;align-items:center;gap:7px;flex:none}.viewer-zoom-status{font:600 10px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:#444;white-space:nowrap}.viewer-zoom-reset{border:1px solid #bbb;border-radius:3px;background:#fff;color:#333;padding:1px 6px;font:600 10px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;cursor:pointer}.viewer-zoom-reset:hover{background:#eee}.viewer-zoom-reset:focus-visible{outline:2px solid #555;outline-offset:1px}
.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{position:relative;width:210mm;height:297mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:block;padding:0;overflow:hidden;zoom:var(--viewer-canvas-scale)}.sheet-content{position:relative;width:100%;min-height:297mm;padding:13mm 21.5mm 15mm;transform:scale(var(--sheet-effective-scale,1));transform-origin:top left}.resource-header-enabled .sheet-content{padding-top:5mm}.sheet-content>.page-resource-header{margin-bottom:3mm}
.topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:3px 0;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
.topbar-inner{width:calc(100% - 8px);max-width:none;margin:0 4px;display:flex;align-items:center;justify-content:space-between;gap:6px}.topbar details{min-width:0;flex:1}.topbar summary{cursor:pointer;font-size:16px;line-height:1.35;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:5px}.toc-links a{font-size:16px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:3px;padding:2px 5px}.viewer-zoom-controls{display:flex;align-items:center;gap:7px;flex:none}.viewer-zoom-status{font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:#444;white-space:nowrap}.viewer-zoom-reset{border:1px solid #bbb;border-radius:3px;background:#fff;color:#333;padding:1px 6px;font:600 16px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;cursor:pointer}.viewer-zoom-reset:hover{background:#eee}.viewer-zoom-reset:focus-visible{outline:2px solid #555;outline-offset:1px}
.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{position:relative;width:210mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:block;padding:0;overflow:visible;zoom:var(--viewer-canvas-scale)}.sheet-content{position:relative;width:100%;min-height:297mm;padding:13mm 21.5mm 15mm;transform:scale(var(--sheet-effective-scale,1));transform-origin:top left}.resource-header-enabled .sheet-content{padding-top:5mm}.sheet-content>.page-resource-header{margin-bottom:3mm}
@media(max-width:900px){.sheet{height:auto}}@media print{.sheet{height:auto}}
.pdf-main{min-width:0;width:100%}.running-header{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;border-bottom:1px solid #111;padding-bottom:3px;margin-bottom:26px;font-size:15px}.running-header span{white-space:nowrap}
.hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px}
.meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}
.front-scope{padding:0 0 3mm;border-bottom:1px solid var(--line-soft)}.front-scope h2{font-size:24px;line-height:1.15;margin:0 0 3mm}.front-scope p{font-size:16px}
.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:12%}.scope-table th:last-child,.scope-table td:last-child{width:34%}.scope-table--task th:first-child,.scope-table--task td:first-child{width:9%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:14%}.scope-table--task th:last-child,.scope-table--task td:last-child{width:29%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit}
.card-section h2{font-size:24px;line-height:1.15;margin:0 0 4mm}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;margin-right:12mm;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777}
p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm;padding:0}.pdf-main li{margin:1.2mm 0}
.pdf-margin{position:absolute;top:34mm;width:18mm;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:5px;line-height:1.12;color:#333;max-height:245mm;overflow:hidden}.pdf-margin.left{left:2mm;text-align:left}.pdf-margin.right{right:2mm;text-align:left}
@@ -2907,10 +3026,10 @@ p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm
figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}.card-asset.asset-screenshot img{border:1px solid #aaa}.card-asset a{display:block;cursor:zoom-in}
.table-wrap{overflow:auto;margin:14px 0}table{border-collapse:collapse;width:100%;font-size:16px}th,td{border:1px solid #111;padding:5px 8px;text-align:left;vertical-align:top}th{font-weight:400;background:white}
.empty-check{display:inline-block;width:1em;height:1em;border:1px solid #111;vertical-align:-.12em}.answer-line,.dotfill{display:block;border-bottom:1px dotted #111;height:1.45em;min-width:10em}.write-row{display:block;min-height:2.8em}
code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #aaa;margin:2mm 0 4mm}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:10px;padding:8px 11px;background:#f3f3f3;border-bottom:1px solid #aaa}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#777}.task-block,.task-exercise{margin:10px 12px;padding:0 0 10px;border-bottom:1px solid #ddd}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#666}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{counter-reset:block-step;margin:5px 0 0 24px!important}.block-steps li{margin:7px 0}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace}.task-exercise{border:1px solid #777;border-left:4px solid #111;padding:9px 11px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:8px;padding-top:6px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:7px 0 0}.task-flow>footer{padding:8px 11px;border-top:1px solid #aaa;font-size:12px}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-margin{display:none}
code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #222;margin:1mm 0 2mm;background:#fff}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:6px;padding:5px 7px;background:#eaeaea;border-bottom:1px solid #222}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#222}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#555}.task-block{margin:6px 7px;padding:5px 6px;border:1px solid #777;background:#fff}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:5px;margin-bottom:3px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#555}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{margin:4px 0 0 16px!important;padding:0;display:grid;gap:3px}.block-steps li{margin:0;border:1px solid #b9b9b9;padding:4px 5px;background:#fff}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace;font-weight:700}.block-steps li p:last-child{margin-bottom:0}.task-exercise{margin:6px 7px;border:1px solid #777;border-left:1px solid #222;padding:5px 6px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:5px;padding-top:4px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:4px 0 0}.task-flow>footer{padding:5px 7px;border-top:1px solid #222;font-size:12px;background:#f6f6f6}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-margin{display:none}
.inspector{position:fixed;right:14px;bottom:14px;width:min(360px,calc(100vw - 28px));max-height:46vh;overflow:auto;background:white;border:1px solid #cfcfcf;box-shadow:0 4px 18px rgba(0,0,0,.18);border-radius:6px;padding:12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;z-index:30}.inspector h2{font-size:14px;margin:0 0 8px}.inspector .empty{color:#777}.tree-card h3{font-size:15px;margin:6px 0}.tree-card code{display:inline-block;margin:4px 0}.tree-card .line{font-weight:700}.tree-card .line.OG{color:var(--og)}.tree-card .line.TECH{color:var(--tech)}.kw-list{padding-left:18px}.kw-list code{color:var(--kw)}
@media(max-width:900px){.sheet{display:block;min-height:0}.sheet-content{min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}.viewer-zoom-status{font-size:10px}}
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,figure{break-inside:avoid}}
@media(max-width:900px){.sheet{display:block;min-height:0}.sheet-content{min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}.viewer-zoom-status{font-size:16px}}
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,.block-steps li,figure{break-inside:avoid}}
"""
css += page_resource_header_css(data)
if use_resource_header:
@@ -2957,7 +3076,8 @@ const viewerSheets = Array.from(document.querySelectorAll('.sheet')).map(sheet =
sheet,
content: sheet.querySelector('.sheet-content')
})).filter(item => item.content);
let viewerCanvasScale = 1;
const viewerCanvasDefault = 2.18;
let viewerCanvasScale = viewerCanvasDefault;
let viewerContentScale = 1;
function clampViewerScale(value, minimum, maximum){
return Math.min(maximum, Math.max(minimum, value));
@@ -3029,7 +3149,7 @@ if(viewerPaper){
}
if(viewerZoomReset){
viewerZoomReset.addEventListener('click', () => {
viewerCanvasScale = Math.min(1, viewerCanvasMaximum());
viewerCanvasScale = Math.min(viewerCanvasDefault, viewerCanvasMaximum());
viewerContentScale = 1;
refreshViewerLayout();
});
@@ -6782,7 +6902,8 @@ def prepare_html_figure_assets(data: dict) -> None:
for graphic in graphics_paths(data.get("sections", [])):
add_graphic(graphic)
for asset in section_assets(data.get("sections", [])):
add_graphic(str(asset.get("path", "")))
add_graphic(str(asset.get("html_path") or asset.get("path", "")))
add_graphic(str(asset.get("source_path", "")))
for figure in data.get("figures", []):
add_graphic(figure.get("path", ""))
pdf_links: list[str] = []
@@ -6820,6 +6941,205 @@ def prepare_html_figure_assets(data: dict) -> None:
shutil.copy2(source, output_dir / source.name)
def react_task_model(
task_ref: str,
task: dict,
equation_numbers: dict[str, int],
figure_numbers: dict[str, int],
) -> dict:
flow: list[dict] = []
for item in task.get("flow", []) or []:
flow.append(
{
"id": str(item.get("id", "")),
"kind": str(item.get("kind", "block")),
"title": str(item.get("title", "")),
"content_html": render_latex_fragment_html(
str(item.get("content_tex", "")), equation_numbers, figure_numbers
),
"steps": [
{
"id": str(step.get("id", "")),
"title": str(step.get("title", "")),
"content_html": render_latex_fragment_html(
str(step.get("content_tex", "")), equation_numbers, figure_numbers
),
}
for step in item.get("steps", []) or []
],
}
)
return {
"ref": task_ref,
"title": str(task.get("title") or task_ref),
"uuid": str(task.get("uuid", "")),
"criterion": str(task.get("criterion", "")),
"prompt_html": render_latex_fragment_html(
str(task.get("prompt_tex", "")), equation_numbers, figure_numbers
),
"flow": flow,
}
def react_card_model(data: dict) -> dict:
equation_numbers, figure_numbers = collect_numbered_labels(data)
sections = ordered_sections(data)
page_specs: list[tuple[dict, str | None]] = []
for section in sections:
task_refs = section.get("task_refs", []) or []
if section.get("content_kind") == "tasks" and task_refs:
page_specs.extend((section, str(task_ref)) for task_ref in task_refs)
else:
page_specs.append((section, None))
total_pages = 2 + len(page_specs)
front_left, front_right = render_dual_margin_html(
learning_tree_tags(data, column="ogolne", effect_color="green!50!black"),
learning_tree_tags(data, column="zawodowe", effect_color="orange!85!black"),
data=data,
)
scope = data.get("front_page_scope", {}) or {}
scope_table = scope.get("scope_table", {}) or {}
front = {
"header_html": render_page_header_html(data, 1, total_pages),
"left_margin_html": front_left,
"right_margin_html": front_right,
"goal_title": str(scope.get("title", "Cel karty")),
"goal_html": render_latex_fragment_html(
str(scope.get("content_tex", "")), equation_numbers, figure_numbers
),
"scope_title": str(scope.get("scope_title", "Zakres karty")),
"scope_html": render_latex_fragment_html(
str(scope.get("scope_content_tex", "")), equation_numbers, figure_numbers
),
"scope_headers": list(scope_table.get("headers", [])),
"scope_rows": [
{
"chapter": str(row.get("chapter", "")),
"task": str(row.get("task", "")),
"idea_html": render_latex_fragment_html(
str(row.get("idea_tex", row.get("idea", ""))), equation_numbers, figure_numbers
),
"priority": str(row.get("priority", "")),
"key": bool(row.get("key", str(row.get("task", "")).lower() == "task04")),
}
for row in scope_table.get("rows", []) or []
],
}
section_models: list[dict] = []
for index, (section, task_ref) in enumerate(page_specs, start=1):
left_margin, right_margin = render_section_margins_html(section, data)
task_refs = section.get("task_refs", []) or []
tasks = (
[react_task_model(task_ref, data["tasks"][task_ref], equation_numbers, figure_numbers)]
if task_ref
else []
)
assets = []
include_section_assets = not task_ref or task_ref == str(task_refs[0])
for asset in (section.get("assets", []) or []) if include_section_assets else []:
source_path = str(asset.get("html_path") or asset.get("path", ""))
assets.append(
{
"label": str(asset.get("label", "")),
"caption": str(asset.get("caption", "")),
"alt": str(asset.get("alt") or asset.get("caption", "")),
"href": html_figure_asset(source_path),
"source_href": html_figure_asset(str(asset.get("source_path", "")))
if asset.get("source_path")
else "",
"width": float(asset.get("width", 1.0)),
"kind": str(asset.get("kind", "diagram")),
"interactive": asset.get("interactive"),
}
)
title = str(section.get("title", ""))
if task_ref and len(task_refs) > 1:
task_title = str(data["tasks"][task_ref].get("title", "")).strip()
title = f'{task_ref.upper()}{"" + task_title if task_title else ""}'
section_models.append(
{
"id": html_id(f'section-{index}-{task_ref or "content"}'),
"index": index,
"title": title,
"header_html": render_page_header_html(data, index + 1, total_pages),
"left_margin_html": left_margin,
"right_margin_html": right_margin,
"content_html": ""
if section.get("content_kind") == "tasks"
else render_latex_fragment_html(
str(section.get("content_tex", "")), equation_numbers, figure_numbers
),
"tasks": tasks,
"assets": assets,
"steps": [
{"id": str(step.get("id", "")), "title": str(step.get("title", ""))}
for step in section.get("steps", []) or []
] if (not task_ref or task_ref == str(task_refs[0])) else [],
}
)
return {
"schema": "esc-card-react-view.v1",
"card": data.get("card", {}),
"total_pages": total_pages,
"front": front,
"sections": section_models,
"dictionary": {
"header_html": render_page_header_html(data, total_pages, total_pages),
"content_html": render_learning_tree_overview_html(data),
},
"toc": [
{"id": section["id"], "label": f'{section["index"]}. {section["title"]}'}
for section in section_models
],
}
def normalize_generated_text(content: str) -> str:
return "\n".join(line.rstrip() for line in content.splitlines()) + "\n"
def write_react_app(data: dict, template: dict, legacy_html: str) -> list[Path]:
if not data.get("generated", {}).get("react_app"):
return []
if template_emitter(template, "html") != "classic":
raise RuntimeError("React renderer obsługuje obecnie emitter HTML 'classic'")
web_root = html_output_dir(data)
web_root.mkdir(parents=True, exist_ok=True)
style_match = re.search(r"<style>(.*?)</style>", legacy_html, re.S)
base_css = style_match.group(1) if style_match else ""
model_path = web_root / "card-data.json"
style_path = web_root / "style.css"
index_path = web_root / "index.html"
model_path.write_text(json.dumps(react_card_model(data), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
style_path.write_text(normalize_generated_text(base_css), encoding="utf-8")
index_path.write_text(
"""<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="card-renderer" content="react">
<title>Karta pracy</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="app.css">
</head>
<body class="resource-header-enabled">
<div id="root"></div>
<script type="module" src="app.js"></script>
</body>
</html>
""",
encoding="utf-8",
)
build_script = REPO_ROOT / "react" / "build.mjs"
subprocess.run(
["node", str(build_script), str(web_root)],
cwd=REPO_ROOT,
check=True,
)
return [index_path, style_path, model_path, web_root / "app.js", web_root / "app.css"]
def write_outputs(data: dict, template: dict, force_web: bool = False) -> list[Path]:
prepare_html_figure_assets(data)
outputs = {
@@ -6840,9 +7160,10 @@ def write_outputs(data: dict, template: dict, force_web: bool = False) -> list[P
continue
path = CARD_ROOT / output_ref
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
path.write_text(normalize_generated_text(content), encoding="utf-8")
paths.append(path)
paths.extend(write_web_outputs(data, template, force_web))
paths.extend(write_react_app(data, template, outputs.get("html", "")))
return paths