Browse Source

first commit

xander 1 week ago
commit
717bb518e6
100 changed files with 6444 additions and 0 deletions
  1. 15 0
      .gitignore
  2. 18 0
      .template/companies/logo.svg
  3. 22 0
      .template/companies/scientia/data.json
  4. 22 0
      .template/companies/technology/data.json
  5. 22 0
      .template/companies/test-company/data.json
  6. 5 0
      .template/companies/test-company/logo.svg
  7. 4 0
      .template/companies/test-company/sign.svg
  8. 7 0
      .template/companies/test-company/stamp.svg
  9. 19 0
      .template/companies/too/data.json
  10. 251 0
      .template/development/docs/ARCHITECTURE.md
  11. 136 0
      .template/development/docs/PLAN.md
  12. 63 0
      .template/development/docs/README.md
  13. 30 0
      .template/development/docs/adr/0001-typst-015.md
  14. 37 0
      .template/development/docs/adr/0002-data-and-assets.md
  15. 28 0
      .template/development/docs/adr/0003-rendering-model.md
  16. 36 0
      .template/development/docs/adr/0004-error-handling.md
  17. 37 0
      .template/development/docs/adr/0005-ddd-boundaries.md
  18. 32 0
      .template/development/docs/adr/0006-visual-regression.md
  19. 39 0
      .template/development/docs/adr/0007-author-workspace.md
  20. 35 0
      .template/development/docs/adr/0008-vscode-onboarding.md
  21. 35 0
      .template/development/docs/adr/0009-single-main-and-public-docs.md
  22. 26 0
      .template/development/docs/adr/0010-private-folder-and-employees.md
  23. 42 0
      .template/development/modules/application-render.md
  24. 42 0
      .template/development/modules/author-workspace.md
  25. 53 0
      .template/development/modules/components.md
  26. 39 0
      .template/development/modules/domain-attachments.md
  27. 39 0
      .template/development/modules/domain-company.md
  28. 40 0
      .template/development/modules/domain-document.md
  29. 40 0
      .template/development/modules/domain-parties.md
  30. 48 0
      .template/development/modules/facade.md
  31. 41 0
      .template/development/modules/infrastructure-assets.md
  32. 42 0
      .template/development/modules/lists.md
  33. 40 0
      .template/development/modules/numbering.md
  34. 43 0
      .template/development/modules/presentation-commercial-offer.md
  35. 44 0
      .template/development/modules/presentation-contract.md
  36. 41 0
      .template/development/modules/presentation-foundation.md
  37. 43 0
      .template/development/modules/presentation-letter.md
  38. 46 0
      .template/development/modules/presentation-report.md
  39. 33 0
      .template/development/modules/private-assets.md
  40. 48 0
      .template/development/modules/references.md
  41. 42 0
      .template/development/modules/starter-packs.md
  42. 44 0
      .template/development/modules/testing.md
  43. 38 0
      .template/development/modules/user-documentation.md
  44. 34 0
      .template/development/modules/vscode-workspace.md
  45. 67 0
      .template/development/tests/domain/core.typ
  46. 12 0
      .template/development/tests/domain/legacy-numbering.typ
  47. 46 0
      .template/development/tests/domain/profiles.typ
  48. 49 0
      .template/development/tests/fixtures/commercial-offer/main.typ
  49. 85 0
      .template/development/tests/fixtures/contract/main.typ
  50. 26 0
      .template/development/tests/fixtures/core-smoke/main.typ
  51. 7 0
      .template/development/tests/fixtures/default-report/main.typ
  52. 13 0
      .template/development/tests/fixtures/facade-smoke/main.typ
  53. 38 0
      .template/development/tests/fixtures/letter/main.typ
  54. 90 0
      .template/development/tests/fixtures/list-counters/main.typ
  55. 60 0
      .template/development/tests/fixtures/modes/main.typ
  56. 5 0
      .template/development/tests/fixtures/negative-company/main.typ
  57. 13 0
      .template/development/tests/fixtures/negative-contract/main.typ
  58. 8 0
      .template/development/tests/fixtures/negative-letter/main.typ
  59. 5 0
      .template/development/tests/fixtures/negative-list-scheme/main.typ
  60. 11 0
      .template/development/tests/fixtures/negative-offer/main.typ
  61. 3 0
      .template/development/tests/fixtures/negative-reference-case/main.typ
  62. 5 0
      .template/development/tests/fixtures/negative-references/main.typ
  63. 39 0
      .template/development/tests/fixtures/no-media/main.typ
  64. 49 0
      .template/development/tests/fixtures/presentation-smoke/main.typ
  65. 49 0
      .template/development/tests/fixtures/reference-cases/main.typ
  66. 71 0
      .template/development/tests/fixtures/report-baseline/main.typ
  67. 90 0
      .template/development/tests/fixtures/report-new/main.typ
  68. 6 0
      .template/development/tests/fixtures/report-new/normative.bib
  69. 8 0
      .template/development/tests/fixtures/report-new/science.bib
  70. 136 0
      .template/development/tests/fixtures/report-stress/main.typ
  71. 453 0
      .template/development/tests/manifest.json
  72. 642 0
      .template/development/tests/run-tests.py
  73. 63 0
      .template/development/tools/use-starter.ps1
  74. 7 0
      .template/development/vscode/README.md
  75. 8 0
      .template/development/vscode/packages/README.md
  76. 15 0
      .template/lib/api.typ
  77. 29 0
      .template/lib/appendices.typ
  78. 45 0
      .template/lib/application/render-document.typ
  79. BIN
      .template/lib/assets/icons/bank.png
  80. BIN
      .template/lib/assets/icons/card.png
  81. BIN
      .template/lib/assets/icons/email.png
  82. BIN
      .template/lib/assets/icons/location.png
  83. 4 0
      .template/lib/assets/placeholders/logo.svg
  84. 6 0
      .template/lib/assets/placeholders/signature.svg
  85. 3 0
      .template/lib/assets/placeholders/stamp.svg
  86. 24 0
      .template/lib/company.typ
  87. 334 0
      .template/lib/components.typ
  88. 77 0
      .template/lib/domain/attachments.typ
  89. 146 0
      .template/lib/domain/company.typ
  90. 115 0
      .template/lib/domain/document.typ
  91. 124 0
      .template/lib/domain/parties.typ
  92. 62 0
      .template/lib/domain/references.typ
  93. 73 0
      .template/lib/index.typ
  94. 109 0
      .template/lib/infrastructure/company-assets.typ
  95. 207 0
      .template/lib/infrastructure/employees.typ
  96. 105 0
      .template/lib/numbering.typ
  97. 383 0
      .template/lib/presentation/components.typ
  98. 119 0
      .template/lib/presentation/foundation.typ
  99. 256 0
      .template/lib/presentation/lists.typ
  100. 203 0
      .template/lib/presentation/profiles/commercial-offer.typ

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+# Приватные подписи, печати и их локальные настройки
+.private/
+
+# Результаты сборки из задач VS Code
+/document.pdf
+/example.pdf
+
+# Временные и тестовые файлы
+archives/
+.template/development/tests/.failures/
+.template/development/tests/.tmp/
+.template/development/tests/snapshots/
+__pycache__/
+*.pyc
+tmp/

+ 18 - 0
.template/companies/logo.svg

@@ -0,0 +1,18 @@
+<svg width="1174" height="734" viewBox="0 0 1174 734" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M751.5 592V208H870.5V592H751.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M601.5 734V208H720.5V734H601.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M451.5 663V208H570.5V663H451.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M301.5 521V208H420.5V521H301.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M151.5 389V208H270.5V389H151.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 302V208H121.5V302H1.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M900.5 419V208H1020.5V419H900.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1050.5 284V206H1170.5V284H1050.5Z" fill="black"/>
+<path d="M1137.56 184.124L1136.36 160.159V94.4931C1136.36 88.2621 1134.04 83.3891 1129.41 79.8741C1124.78 76.1994 1118.39 74.3621 1110.24 74.3621C1102.41 74.3621 1096.02 76.0397 1091.07 79.3948C1086.28 82.5902 1083.48 87.1437 1082.68 93.0552H1046.5C1047.3 83.6287 1050.49 75.5603 1056.08 68.85C1061.68 62.1397 1069.18 57.027 1078.61 53.5121C1088.19 49.8374 1099.3 48 1111.92 48C1124.38 48 1135.24 49.9172 1144.51 53.7517C1153.77 57.4264 1160.88 62.9385 1165.83 70.2879C1170.94 77.4776 1173.5 86.3448 1173.5 96.8897V184.124H1137.56ZM1094.9 187C1086.12 187 1078.37 185.322 1071.66 181.967C1064.95 178.452 1059.76 173.659 1056.08 167.588C1052.41 161.517 1050.57 154.647 1050.57 146.978C1050.57 133.237 1055.85 122.613 1066.39 115.103C1077.09 107.434 1091.79 103.6 1110.48 103.6H1139.95V129.483L1109.52 129.722C1102.65 129.722 1097.14 131.001 1092.99 133.557C1088.99 135.953 1087 139.628 1087 144.581C1087 148.096 1088.43 151.132 1091.31 153.688C1094.34 156.244 1098.58 157.522 1104.01 157.522C1111.52 157.522 1118.15 155.685 1123.9 152.01C1129.81 148.176 1134.68 142.983 1138.52 136.433L1142.59 161.597C1138.59 170.384 1132.44 176.855 1124.14 181.009C1115.99 185.003 1106.25 187 1094.9 187Z" fill="black"/>
+<path d="M942.663 185V52.4485H980.071V185H942.663ZM899.5 185V154.961H1024.5V185H899.5ZM916.286 82.4871V52.4485H961.367V82.4871H916.286ZM941.224 34.8067V0H981.031V34.8067H941.224Z" fill="black"/>
+<path d="M823.27 184C814.318 184 806.405 182.018 799.531 178.054C792.816 173.932 787.621 168.303 783.944 161.168C780.267 153.875 778.429 145.63 778.429 136.434V29.6481L815.837 13V136.434C815.837 140.556 816.396 143.965 817.515 146.661C818.794 149.197 821.032 151.1 824.23 152.369C827.587 153.478 832.223 154.033 838.138 154.033H874.5V184H823.27ZM749.5 81.733V51.7663H845.163V81.733H749.5Z" fill="black"/>
+<path d="M687.239 186V104.477C687.239 97.6637 685.488 92.1972 681.985 88.0775C678.482 83.9577 672.69 81.8979 666.957 81.8979C660.429 81.8979 654.378 84.1162 648.805 88.5528C643.391 92.831 637.658 100.278 631.608 110.894L626.353 79.5211C632.404 69.2218 639.728 61.9331 648.327 57.6549C656.926 53.2183 665.684 51 674.601 51C684.792 51 694.644 53.1391 701.809 57.4173C709.134 61.6954 714.707 67.6373 718.529 75.243C722.51 82.8486 724.5 91.8011 724.5 102.1V186H687.239ZM599.993 185.676L599.5 53.5285H635.566L634.713 77.382L636.761 185.676H599.993Z" fill="black"/>
+<path d="M511.243 187C496.971 187 484.681 184.204 474.374 178.612C464.225 173.02 456.375 165.032 450.825 154.647C445.275 144.261 442.5 131.879 442.5 117.5C442.5 102.961 445.275 90.4989 450.825 80.1138C456.375 69.7287 464.383 61.8201 474.849 56.3879C485.474 50.796 498.239 48 513.146 48C536.456 48 553.345 54.7103 563.811 68.131C574.435 81.392 578.875 99.6856 577.131 123.012H471.995L472.233 97.369H539.311C538.835 91.2977 536.139 86.4247 531.223 82.75C526.307 78.9155 520.202 76.9983 512.908 76.9983C501.49 76.9983 493.086 80.5931 487.694 87.7828C482.303 94.9724 479.607 105.437 479.607 119.178C479.607 126.048 480.796 132.359 483.175 138.11C485.553 143.862 489.042 148.495 493.641 152.01C498.398 155.366 504.265 157.043 511.243 157.043C518.696 157.043 524.96 155.206 530.034 151.531C535.267 147.856 538.438 143.223 539.549 137.631H576.655C575.07 152.809 568.409 164.872 556.675 173.819C545.099 182.606 529.955 187 511.243 187Z" fill="black"/>
+<path d="M343.318 185V52.4485H380.427V185H343.318ZM300.5 185V154.961H424.5V185H300.5ZM317.151 82.4871V52.4485H361.872V82.4871H317.151ZM341.891 34.8067V0H381.378V34.8067H341.891Z" fill="black"/>
+<path d="M212.691 187C198.713 187 186.641 184.204 176.475 178.612C166.468 173.02 158.764 165.032 153.363 154.647C148.121 144.261 145.5 131.879 145.5 117.5C145.5 102.801 148.28 90.2592 153.839 79.8741C159.558 69.4891 167.5 61.5805 177.666 56.1483C187.832 50.7161 199.825 48 213.644 48C225.717 48 236.359 50.1569 245.572 54.4707C254.944 58.7845 262.33 64.8558 267.731 72.6845C273.291 80.3534 276.309 89.5402 276.785 100.245H239.616C238.345 92.8954 235.486 87.4632 231.038 83.9483C226.749 80.2736 220.951 78.4362 213.644 78.4362C207.608 78.4362 202.208 79.954 197.442 82.9897C192.836 86.0253 189.182 90.419 186.482 96.1707C183.94 101.922 182.67 109.032 182.67 117.5C182.67 130.122 185.529 139.868 191.247 146.738C197.125 153.608 204.273 157.043 212.691 157.043C217.616 157.043 221.904 156.164 225.558 154.407C229.37 152.649 232.547 150.173 235.088 146.978C237.63 143.622 239.377 139.868 240.33 135.714H277.5C276.547 146.099 273.211 155.126 267.493 162.795C261.774 170.464 254.229 176.455 244.857 180.769C235.486 184.923 224.764 187 212.691 187Z" fill="black"/>
+<path d="M63.0286 187C50.6183 187 39.7196 185.003 30.3323 181.009C20.9451 176.855 13.6262 171.023 8.37574 163.514C3.28435 155.845 0.659105 146.818 0.5 136.433H35.1055C34.7873 143.782 37.0943 149.534 42.0266 153.688C47.118 157.842 54.0391 159.919 62.7899 159.919C69.1542 159.919 74.2456 158.88 78.0641 156.803C82.0417 154.567 84.0306 151.531 84.0306 147.697C84.0306 144.341 82.5191 141.705 79.4961 139.788C76.6321 137.711 72.8136 136.113 68.0404 134.995C63.2673 133.717 58.0168 132.518 52.2889 131.4C46.5611 130.122 40.7538 128.604 34.8669 126.847C29.1391 125.089 23.8886 122.772 19.1154 119.897C14.3422 116.861 10.4441 112.947 7.4211 108.153C4.5572 103.201 3.12525 97.0494 3.12525 89.7C3.12525 81.2322 5.51183 73.8828 10.285 67.6517C15.0582 61.4207 21.6611 56.6276 30.0937 53.2724C38.6854 49.7575 48.55 48 59.6874 48C76.7117 48 90.4744 51.9943 100.975 59.9828C111.635 67.8115 117.522 78.8356 118.636 93.0552H84.2692C83.951 87.1437 81.5644 82.6701 77.1095 79.6345C72.6545 76.5989 66.9267 75.081 59.926 75.081C54.0391 75.081 49.3455 76.1195 45.8452 78.1965C42.3448 80.2736 40.5947 83.3891 40.5947 87.5431C40.5947 90.8983 42.0266 93.5345 44.8905 95.4517C47.9135 97.2092 51.8116 98.6471 56.5848 99.7655C61.358 100.884 66.6085 101.922 72.3363 102.881C78.2232 103.84 84.0306 105.198 89.7584 106.955C95.4862 108.553 100.737 110.79 105.51 113.666C110.283 116.541 114.102 120.376 116.965 125.169C119.989 129.962 121.5 136.113 121.5 143.622C121.5 157.203 116.17 167.828 105.51 175.497C95.0089 183.166 80.8485 187 63.0286 187Z" fill="black"/>
+</svg>

+ 22 - 0
.template/companies/scientia/data.json

@@ -0,0 +1,22 @@
+{
+    "name": "ООО «Скиентия»",
+    "short-name": "scientia",
+    "brand-color": "#e39f49",
+    "email": "info@scientia.ru",
+    "website": "scientia.ru",
+    "phone": "+7 (922) 203-24-60",
+    "address": "620014, Свердловская область,\nг. Екатеринбург,\nул. Тверитина, 43",
+    "inn": "6686148633",
+    "kpp": "665801001",
+    "ogrn": "1236600002572",
+    "bank": "ООО «Банк Точка»",
+    "ks": "30101810745374525104",
+    "bik": "044525104",
+    "director-title": "Директор",
+    "director-name": "А.С. Мусихин",
+    "company_info": "ООО «Скиентия» ИНН 6686148633\nул. Шейнкмана, стр. 9, офис 65\nг. Екатеринбург, 620014, Россия\n+7 (922) 203-24-60 ☏\ninfo@scientia.ru 🖂",
+    "city": "Екатеринбург",
+    "logo_image": "companies/logo.svg",
+    "sign_image": null,
+    "stamp_image": null
+}

+ 22 - 0
.template/companies/technology/data.json

@@ -0,0 +1,22 @@
+{
+  "name": "ООО «Скиентия Технологии»",
+  "short-name": "scientia",
+  "brand-color": "#e39f49",
+  "email": "technology@scientia.ru",
+  "website": "scientia.ru",
+  "phone": "+7 (922) 203-24-60",
+  "address": "620014, Свердловская область,\nг. Екатеринбург,\nул. Тверитина, 41, 434",
+  "inn": "6685220911",
+  "kpp": "668501001",
+  "ogrn": "1246600037771",
+  "bank": "Филиал «Екатеринбургский» АО «АЛЬФА-БАНК»",
+  "ks": "30101810100000000964",
+  "bik": "046577964",
+  "director-title": "Директор",
+  "director-name": "И.А. Гузеев",
+  "company_info": "ООО «Скиентия Технологии» ИНН 6685220911\nул. Тверитина, д. 41\nг. Екатеринбург, 620026, Россия\n+7 (995) 541-90-83 ☏\ninfo@scientia.ru 🖂",
+  "city": "Екатеринбург",
+  "logo_image": "companies/logo.svg",
+  "sign_image": null,
+  "stamp_image": null
+}

+ 22 - 0
.template/companies/test-company/data.json

@@ -0,0 +1,22 @@
+{
+  "name": "ООО «Тестовая компания»",
+  "short-name": "Тест",
+  "brand-color": "#e39f49",
+  "email": "document@example.invalid",
+  "website": "example.invalid",
+  "phone": "+7 (000) 000-00-00",
+  "address": "000000, Тестовый регион, г. Пример, ул. Проверочная, 1",
+  "inn": "0000000000",
+  "kpp": "000000000",
+  "ogrn": "0000000000000",
+  "bank": "Тестовый банк",
+  "ks": "00000000000000000000",
+  "bik": "000000000",
+  "director-title": "Директор",
+  "director-name": "И.И. Тестов",
+  "company_info": "ООО «Тестовая компания» ИНН 0000000000\nг. Пример, ул. Проверочная, 1\n+7 (000) 000-00-00\ndocument@example.invalid",
+  "city": "Пример",
+  "logo_image": "companies/test-company/logo.svg",
+  "sign_image": "companies/test-company/sign.svg",
+  "stamp_image": "companies/test-company/stamp.svg"
+}

+ 5 - 0
.template/companies/test-company/logo.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220">
+  <rect width="600" height="220" rx="24" fill="#fbb20d"/>
+  <text x="300" y="112" text-anchor="middle" font-family="Arial, sans-serif" font-size="66" font-weight="700" fill="#111">TEST COMPANY</text>
+  <text x="300" y="166" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#111">synthetic fixture</text>
+</svg>

+ 4 - 0
.template/companies/test-company/sign.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 180">
+  <path d="M25 135 C90 25, 120 165, 185 55 S270 160, 330 60 S420 155, 565 35" fill="none" stroke="#2456c4" stroke-width="10" stroke-linecap="round"/>
+  <text x="300" y="174" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" fill="#2456c4">TEST SIGNATURE</text>
+</svg>

+ 7 - 0
.template/companies/test-company/stamp.svg

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420">
+  <circle cx="210" cy="210" r="185" fill="none" stroke="#3b76a8" stroke-width="12"/>
+  <circle cx="210" cy="210" r="142" fill="none" stroke="#3b76a8" stroke-width="5"/>
+  <text x="210" y="190" text-anchor="middle" font-family="Arial, sans-serif" font-size="46" font-weight="700" fill="#3b76a8">TEST</text>
+  <text x="210" y="245" text-anchor="middle" font-family="Arial, sans-serif" font-size="30" fill="#3b76a8">NOT VALID</text>
+  <text x="210" y="292" text-anchor="middle" font-family="Arial, sans-serif" font-size="22" fill="#3b76a8">FIXTURE ONLY</text>
+</svg>

+ 19 - 0
.template/companies/too/data.json

@@ -0,0 +1,19 @@
+{
+    "name": "ТОО «Скиентия»",
+    "short-name": "scientia",
+    "brand-color": "#e39f49",
+    "email": "info@weare.science",
+    "website": "weare.science",
+    "phone": "+7(777)635-63-10",
+    "address": "Республика Казахстан, 030007, Актюбинская область, город Актобе, проспект Абилкайыр-хана, дом 2, офис 72",
+    "address_en": "Republic of Kazakhstan, 030007, Aktobe Region, Aktobe city, Abilkhair Khan Avenue, building 2, office 72",
+    "bin": "231240017437",
+    "kbe": "17",
+    "director-title": "Директор",
+    "director-name": "Б.Т. Ильясов",
+    "company_info": "ТОО «Скиентия» БИН/ИИН 231240017437\nРеспублика Казахстан\nАктюбинская область г.Актобе, 030000\nпр-кт Абулхаир хана, 77\ninfo@weare.science 🖂",
+    "city": "Актобе",
+    "logo_image": "companies/logo.svg",
+    "sign_image": null,
+    "stamp_image": null
+}

+ 251 - 0
.template/development/docs/ARCHITECTURE.md

@@ -0,0 +1,251 @@
+# Архитектура шаблона документов Scientia
+
+## Системный контекст
+
+```text
+┌────────────────────┐  редактирует  ┌──────────────────────────────────┐
+│ Автор документа    │ ────────────▶ │ main.typ + chapters/ + assets/  │
+└─────────┬──────────┘               └────────────────┬─────────────────┘
+          │ читает                                    │ один import фасада
+          ▼                                           ▼
+┌────────────────────┐               ┌──────────────────────────────────┐
+│ docs/              │               │ .template/lib/                  │
+│ инструкции и       │               │ domain → application →          │
+│ примеры            │               │ infrastructure → presentation   │
+└────────────────────┘               └────────────────┬─────────────────┘
+                                                      │
+┌────────────────────┐  условный import               │ PDF
+│ .private/          │ ────────────▶ main.typ ─────────┤
+│ PNG + settings.typ │  один boolean                  ▼
+                                             ┌────────────────────┐
+                                             │ document.pdf       │
+                                             └────────────────────┘
+
+┌────────────────────┐  сопровождает  ┌─────────────────────────────────┐
+│ Разработчик        │ ─────────────▶ │ .template/development/          │
+│ шаблона            │                │ docs + modules + tests + tools  │
+└────────────────────┘                └─────────────────────────────────┘
+```
+
+Публичная и developer-документация физически разделены. Автору не требуется открывать `.template/`, а разработчик не использует `docs/` как описание внутренних контрактов.
+
+## Архитектурные принципы
+
+1. **Один файл ежедневной настройки**. Компания, режим, metadata и `#include` находятся в `main.typ`.
+2. **Режим — значение, а не entrypoint**. `final`, `draft` и `clean-copy` являются вариантами одной переменной.
+3. **Публичное видно**. Инструкции и примеры находятся в `docs/`, который VS Code не скрывает.
+4. **Разработка скрыта**. Библиотека, tests, ADR и tools находятся в `.template/`.
+5. **Пример является исполняемой документацией**. Один исходник одновременно обучает, компилируется в CI и устанавливается как тип документа.
+6. **Важные параметры явные**. Даже отключённые значения показаны как `none`, `false` или `()`.
+7. **Приватное не отслеживается**. Вся папка `.private/`, включая настройки offsets, исключена из Git; tracked placeholders никогда не заменяются реальными файлами.
+8. **Один переключатель**. При `false` условный import не читает `.private`; при `true` обычный preview и обычная build task используют её настройки.
+9. **Фасад скрывает реализацию**. Пользователь импортирует только `/.template/lib/index.typ`.
+10. **Domain не зависит от layout**. Presentation использует domain contracts, но обратной зависимости нет.
+
+## Целевая структура
+
+```text
+README.md                         # короткий маршрут автора
+main.typ                          # единственная точка входа и настройки
+chapters/                         # пользовательский текст
+assets/                           # изображения, данные и bibliography
+docs/                             # публичная документация
+├── README.md
+├── documents.md
+├── formatting.md
+├── vscode.md
+├── git.md
+├── private-assets.md
+├── writing-style.md
+├── troubleshooting.md
+└── examples/
+    ├── README.md
+    ├── private/
+    │   └── settings.typ
+    ├── documents/
+    │   ├── report/
+    │   ├── letter/
+    │   ├── commercial-offer/
+    │   └── contract/
+    └── formatting/
+.vscode/                          # tracked workspace configuration
+├── extensions.json
+├── settings.json
+└── tasks.json
+.private/                         # ignored settings, private media и backups
+├── settings.typ
+├── executors/
+├── scientia/
+├── technology/
+└── too/
+.template/                        # скрытая реализация
+├── lib/
+├── companies/
+└── development/
+    ├── docs/
+    ├── modules/
+    ├── tests/
+    ├── tools/
+    └── vscode/
+```
+
+В чистом fork каталог `.private/` не обязателен. Обычная компиляция использует placeholders из `.template/lib/assets/placeholders/`.
+
+## Компоненты
+
+| Компонент | Ответственность | Публичный интерфейс |
+|-----------|-----------------|---------------------|
+| Рабочее пространство автора | Один входной файл, главы и ресурсы | `main.typ`, `chapters/`, `assets/` |
+| Публичная документация | Обучение без знания реализации | `README.md`, `docs/*.md` |
+| Публичные примеры | Исполняемые примеры и источники выбора типа | `docs/examples/**/main.typ` |
+| VS Code workspace | Рекомендации, автосохранение и задачи | `.vscode/*.json` |
+| Публичный фасад | Единственный пользовательский Typst import | `.template/lib/index.typ` |
+| Document Domain | Профиль, context и render options | `document-profile()`, `render-options()` |
+| Company Domain | Реквизиты и firm resources | `company-profile()` |
+| Parties Domain | Адресаты, подписанты и стороны | `recipient()`, `signer()`, `party()` |
+| Attachments Domain | Порядок и идентичность приложений | `attachment()`, `attachment-set()` |
+| Render Application | Resolve, normalize, validate, render | `render-document()` |
+| Company Adapter | JSON profiles и resource overrides | `load-company()` |
+| Presentation | Foundation, components и четыре renderer | `profiles.report/letter/commercial_offer/contract` |
+| Employee/Private Adapter | Справочник сотрудников, private media и offsets | `report-executor()`, `private-company-media()` |
+| Test Harness | Static, compile, semantic и visual gates | `run-tests.py` |
+
+## Data flow
+
+### Обычная сборка
+
+1. Автор выбирает `company-id` и `document-mode` в `main.typ`.
+2. `main.typ` создаёт profile, company overrides, bibliography и attachments.
+3. `#show: document.with(...)` передаёт последующие `#include` как тело документа.
+4. Facade вызывает application use case.
+5. Application разрешает компанию, нормализует и валидирует profile metadata.
+6. Renderer применяет foundation, нумерацию, media policy и компонует страницы.
+7. При `none` для подписи или печати final renderer использует круг или крест.
+8. Typst создаёт `document.pdf`.
+
+### Выбор типа документа
+
+1. Задача VS Code получает `report`, `letter`, `commercial-offer` или `contract`.
+2. Tool сохраняет текущие `main.typ` и `chapters/` в `.private/starter-backups/<timestamp>/`.
+3. Tool копирует соответствующий публичный пример из `docs/examples/documents/`.
+4. Автор проверяет явно перечисленные параметры нового `main.typ`.
+
+### Приватная сборка
+
+1. Пользователь копирует готовую папку `.private` с `settings.typ` и PNG.
+2. В `main.typ` значение `use-private-assets` меняется с `false` на `true`.
+3. Условный import загружает `.private/settings.typ`.
+4. Adapter сопоставляет публичный идентификатор сотрудника с фиксированным именем PNG и применяет private offset.
+5. Отсутствующая или отключённая запись возвращает `none`; строка подписи остаётся пустой.
+6. Обычная build task и Tinymist preview используют один и тот же `main.typ`.
+
+## Ключевые интерфейсы
+
+```typst
+// Единственный пользовательский вход.
+#let company-id = "scientia" // scientia | technology | too
+#let document-mode = "final" // final | draft | clean-copy
+#let use-private-assets = false // true, если скопирована .private
+```
+
+```typst
+// Порядок и состав глав видны внизу main.typ.
+#include "chapters/00-introduction.typ"
+#pagebreak()
+#include "chapters/10-main.typ"
+```
+
+```typst
+// Отсутствующая .private не читается при false.
+#let private-settings = if use-private-assets {
+  import "/.private/settings.typ": settings
+  settings
+} else {
+  empty-private-settings
+}
+```
+
+```typst
+#show: document.with(
+  company: company,
+  profile: profiles.report(..),
+  options: (
+    mode: document-mode,
+    watermark: if document-mode == "draft" { "ЧЕРНОВИК" } else { none },
+    media-policy: if document-mode == "final" { "placeholder" } else { "reserve-space" },
+    diagnostics: true,
+  ),
+)
+```
+
+## Публичная и developer-документация
+
+| Слой | Расположение | Содержит | Не содержит |
+|------|--------------|----------|-------------|
+| Публичный | `README.md`, `docs/` | первый запуск, Git, типы, formatting, папку `.private`, troubleshooting | DDD, ADR, snapshots, migration internals |
+| Developer | `.template/development/docs/`, `modules/` | архитектуру, решения, границы и тестирование | обязательный маршрут обычного автора |
+
+Корневой README обязан ссылаться на каждую публичную тему. Developer README доступен одной отдельной ссылкой и не конкурирует с пользовательской навигацией.
+
+## Публичные примеры
+
+| Пример | Обязательное покрытие |
+|--------|----------------------|
+| Report | титул, stage/volume, executors, includes, рисунки, таблица, formula, references, bibliography, appendix |
+| Letter | recipient, исходящий номер, основной текст, attachment list, signer |
+| Commercial offer | recipient, subject, price, tax, сроки, payment, scope appendix |
+| Contract | parties, representatives, sections, requisites, signing, appendix |
+| Formatting | варианты изображений, grid, простые/сложные таблицы, CSV, формулы, labels, lists |
+
+Публичный пример не должен ссылаться на скрытый developer asset. Допустим только импорт фасада `/.template/lib/index.typ`.
+
+## Политика приватных ресурсов
+
+| Ресурс | Git | Поведение |
+|--------|-----|-----------|
+| Публичные реквизиты и логотипы | tracked | Загружаются из `.template/companies/` |
+| Векторные placeholders | tracked | Используются обычной сборкой по умолчанию |
+| `docs/examples/private/settings.typ` | tracked | Полный безопасный пример с `enabled: false` |
+| `.template/lib/infrastructure/employees.typ` | tracked | ФИО, обычные роли и фиксированные имена PNG |
+| `.private/` | ignored | Настройки доступности, offsets, реальные изображения и backups |
+
+Typst 0.15 не предоставляет проверки существования файла. Поэтому отсутствие подписи моделируется отсутствующей записью или `enabled: false`; только включённая запись создаёт private path.
+
+## Технологические решения
+
+| Решение | Выбор | Обоснование |
+|---------|-------|-------------|
+| Compiler | Typst 0.15.1+ | Проверенный baseline и path type |
+| Архитектура | Модульный монолит | Один процесс сборки без лишней инфраструктуры |
+| Root entry | Один `main.typ` | Минимум выбора и все параметры в одном месте |
+| Режимы | Переменная `document-mode` | Варианты видны комментариями, нет дублирования файлов |
+| Public docs | Видимый `docs/` | Автор находит примеры в Explorer |
+| Examples | Executable documentation | Код и объяснение не расходятся |
+| Private activation | Literal `use-private-assets` + conditional import | Один понятный параметр, clean fork не читает отсутствующий каталог |
+| Internal boundary | `.template/` | Реализация и developer docs не мешают автору |
+| Testing | Python + Typst + Poppler | Контракты, PDF semantic и визуальный layout |
+
+## Режимы отказа
+
+| Сбой | Влияние | Митигация |
+|------|---------|-----------|
+| Автор меняет режим не в `main.typ` | Ожидаемый вариант PDF не получается | README и comments показывают единственную переменную |
+| Пример использует скрытый developer asset | После очистки development сборка падает | Static path audit и compile всех public examples |
+| Выбор типа уничтожает текст | Потеря работы | Timestamp backup до удаления `chapters/` |
+| Подпись ещё не получена | Private compile падает при прямом path | Запись отсутствует или `enabled: false`, resolver возвращает `none` |
+| Включённая запись не имеет PNG | Ошибка Typst с точным path | Включать запись только после копирования PNG; troubleshooting |
+| Реальный файл попадает в Git | Утечка подписи | `.gitignore`, ignored `.private/`, no overwrite tracked placeholders |
+| Сложная таблица переполняет страницу | Нарушение layout | Formatting example, stress fixture, repeated header tests |
+| Публичная ссылка устарела | Автор теряет маршрут | Automated Markdown link audit |
+| Изменение Typst меняет layout | Тихая регрессия | Version gate и snapshot review |
+
+## Вне области видимости v1
+
+- Поддержка старых root-файлов `document.typ`, `draft.typ` и `clean-copy.typ`.
+- Автоматическое определение существования private files внутри Typst.
+- Хранение реальных подписей и печатей в Git или Git LFS.
+- Публикация внутренних VSIX в этом репозитории.
+- Юридическая экспертиза договора.
+- Научная верификация пользовательского содержания.
+- Генерация DOCX.
+- Публичная публикация в Typst Universe и open-source лицензирование.

+ 136 - 0
.template/development/docs/PLAN.md

@@ -0,0 +1,136 @@
+# План редизайна пользовательской поверхности Scientia
+
+## Обзор
+
+| Фаза | Результат | Статус |
+|------|-----------|--------|
+| 1 | Зафиксирован baseline до редизайна | [x] |
+| 2 | В корне оставлен один настраиваемый `main.typ` | [x] |
+| 3 | Публичные инструкции и компилируемые примеры перенесены в `docs/` | [x] |
+| 4 | Выбор типа выполняется задачей VS Code, private media — одним параметром | [x] |
+| 5 | Полная регрессия и визуальная приёмка | [x] |
+
+## План миграции
+
+**Текущее до редизайна**: пользовательская справка находилась в скрытой `.template/help/`, конфигурация была разделена между `document.typ`, `main.typ`, `draft.typ` и `clean-copy.typ`, а учебные примеры лежали среди developer fixtures.
+
+**Целевое состояние**: автор видит `main.typ`, `chapters/`, `assets/` и `docs/`. Режим задаётся одной переменной. Примеры четырёх типов документов и форматирования видимы, компилируемы и используются как заготовки. Приватная папка подключается одним параметром и содержит настройки индивидуальных подписей.
+
+**Стратегия**: атомарное переключение пользовательского контракта без compatibility-файлов. Обратная совместимость не требуется.
+
+| Фаза | Rollback |
+|------|----------|
+| 1 | Не требуется: только фиксация baseline |
+| 2 | Восстановить предыдущие четыре корневых файла одним change set |
+| 3 | Вернуть справку в `.template/help/`, не меняя библиотеку |
+| 4 | Отключить задачи и использовать обычную сборку с placeholders |
+| 5 | Откатить конкретную правку, повторить compile и visual suites |
+
+---
+
+## Фаза 1 — Зафиксировать baseline
+
+**Цель**: доказать работоспособность библиотеки до изменения пользовательского контракта.  
+**Результат**: unit, negative, company matrix, semantic и visual suites проходят.  
+**Трудоёмкость**: S  
+**Статус**: [x] Готово
+
+### Задачи
+
+- [x] Сохранить compile и visual baseline (→ [Модуль: Тестирование](../modules/testing.md))
+- [x] Зафиксировать Typst 0.15.1 (→ [ADR-0001](adr/0001-typst-015.md))
+
+### Тесты
+
+- [x] Unit: domain и numbering
+- [x] Интеграционный: четыре профиля и компании
+- [x] Визуальный: утверждённые snapshot pages
+
+---
+
+## Фаза 2 — Оставить один `main.typ`
+
+**Цель**: сделать все ежедневные настройки и `#include` видимыми в одном файле.  
+**Результат**: `document.typ`, `draft.typ` и `clean-copy.typ` отсутствуют; режим выбирается в `main.typ`.  
+**Трудоёмкость**: M  
+**Статус**: [x] Готово
+
+### Задачи
+
+- [x] Перенести компанию, режим, metadata и порядок глав в `main.typ` (→ [Рабочее пространство автора](../modules/author-workspace.md))
+- [x] Сохранить единый фасад импорта (→ [Публичный фасад](../modules/facade.md))
+- [x] Проверить profile/options contract (→ [Документ](../modules/domain-document.md))
+- [x] Сохранить границы организаций, сторон и приложений (→ [Организация](../modules/domain-company.md), [Стороны](../modules/domain-parties.md), [Приложения](../modules/domain-attachments.md))
+- [x] Проверить application flow и загрузку компаний (→ [Сборка](../modules/application-render.md), [Ресурсы компаний](../modules/infrastructure-assets.md))
+
+### Тесты
+
+- [x] Static: единственный root entrypoint
+- [x] Интеграционный: `final`, `draft`, `clean-copy` через profile fixtures
+- [x] Ручной: порядок глав меняется только списком `#include`
+
+---
+
+## Фаза 3 — Открыть документацию и примеры
+
+**Цель**: дать автору видимую справку и копируемые примеры без чтения реализации.  
+**Результат**: `docs/` содержит навигацию, четыре полных документа и каталог оформления.  
+**Трудоёмкость**: L  
+**Статус**: [x] Готово
+
+### Задачи
+
+- [x] Разделить публичную и developer-документацию (→ [Публичная документация](../modules/user-documentation.md))
+- [x] Сделать примеры источником выбора типа документа (→ [Публичные примеры](../modules/starter-packs.md))
+- [x] Показать таблицы, формулы, подписи и media blocks (→ [Компоненты](../modules/components.md), [Основа вёрстки](../modules/presentation-foundation.md))
+- [x] Показать ссылки, bibliography и numbering (→ [Ссылки](../modules/references.md), [Нумерация](../modules/numbering.md))
+- [x] Подготовить адекватные примеры профилей (→ [Отчёт](../modules/presentation-report.md), [Письмо](../modules/presentation-letter.md), [ТКП](../modules/presentation-commercial-offer.md), [Договор](../modules/presentation-contract.md))
+
+### Тесты
+
+- [x] Интеграционный: пять публичных примеров компилируются
+- [x] Semantic: ожидаемые подписи, ссылки, приложения и реквизиты присутствуют
+- [x] Ручной: весь `docs/` доступен из корневого README
+
+---
+
+## Фаза 4 — Упростить VS Code и приватные данные
+
+**Цель**: оставить одну build task и безопасно подключать папку `.private` одним параметром.  
+**Результат**: выбор типа создаёт backup, обычная сборка работает с private media и без неё, отсутствующая подпись не ломает документ.  
+**Трудоёмкость**: M  
+**Статус**: [x] Готово
+
+### Задачи
+
+- [x] Оставить в Typewriter единственный `main.typ` и обновить задачи (→ [Рабочая область VS Code](../modules/vscode-workspace.md))
+- [x] Добавить публичный справочник сотрудников, фиксированные PNG names и private offsets (→ [Приватные ресурсы](../modules/private-assets.md))
+- [x] Использовать conditional import при явном `use-private-assets` (→ [ADR-0010](adr/0010-private-folder-and-employees.md))
+
+### Тесты
+
+- [x] Static: `.vscode/` синхронизируется, `docs/` не скрыт
+- [x] Интеграционный: clean fork компилируется без `.private`
+- [x] Интеграционный: private compile отображает реальные подписи и offsets
+- [x] Интеграционный: `enabled: false` оставляет пустую строку без ошибки
+
+---
+
+## Фаза 5 — Hardening и выпуск
+
+**Цель**: подтвердить отсутствие мёртвых путей, утечек и визуальных дефектов.  
+**Результат**: полный harness проходит, Markdown-ссылки валидны, приватные ресурсы игнорируются.  
+**Трудоёмкость**: M  
+**Статус**: [x] Готово
+
+### Задачи
+
+- [x] Обновить static workspace contract (→ [Тестирование](../modules/testing.md))
+- [x] Удалить скрытые дубликаты примеров и notes (→ [Публичная документация](../modules/user-documentation.md))
+- [x] Проверить визуально все страницы публичных примеров (→ [ADR-0006](adr/0006-visual-regression.md))
+
+### Тесты
+
+- [x] Полный automated harness
+- [x] Аудит Markdown links и legacy paths
+- [x] Визуальная проверка contact sheets и проблемных страниц

+ 63 - 0
.template/development/docs/README.md

@@ -0,0 +1,63 @@
+# Разработка шаблона Scientia
+
+> Скрытая документация для сопровождающих Typst-библиотеки. Инструкции авторов находятся в публичном каталоге [`docs/`](../../../docs/README.md).
+
+## Текущее устройство
+
+Автор работает с единственным `main.typ`, каталогами `chapters/`, `assets/` и видимой документацией `docs/`. Внутренняя библиотека, профили организаций, тесты и архитектурные решения находятся в `.template/`.
+
+Режим `final`, `draft` или `clean-copy` выбирается переменной внутри `main.typ`. Компилируемые публичные примеры четырёх видов документов и элементов оформления одновременно являются источниками для задачи выбора типа документа.
+
+Обычная сборка использует векторные заглушки. Готовая папка `.private` подключается одним literal-переключателем в `main.typ`; отдельного ZIP installer и отдельной build task нет.
+
+## Команды сопровождающего
+
+```powershell
+# Пользовательский документ
+typst compile --root . main.typ document.pdf
+
+# Полная регрессия
+python .template/development/tests/run-tests.py
+```
+
+## Навигация
+
+| Документ | Назначение |
+|----------|------------|
+| [PLAN.md](PLAN.md) | Завершённые фазы редизайна и проверки |
+| [ARCHITECTURE.md](ARCHITECTURE.md) | Структура, компоненты, data flow и режимы отказа |
+| [ADR-0001](adr/0001-typst-015.md) | Typst 0.15.1 как baseline |
+| [ADR-0002](adr/0002-data-and-assets.md) | Границы данных и ресурсов |
+| [ADR-0003](adr/0003-rendering-model.md) | Детерминированная модель выполнения |
+| [ADR-0004](adr/0004-error-handling.md) | Ошибки и media fallback |
+| [ADR-0005](adr/0005-ddd-boundaries.md) | Направление зависимостей |
+| [ADR-0006](adr/0006-visual-regression.md) | Визуальная регрессия |
+| [ADR-0007, устарел](adr/0007-author-workspace.md) | Историческое решение о трёх entrypoints |
+| [ADR-0008](adr/0008-vscode-onboarding.md) | Версионируемая рабочая область VS Code |
+| [ADR-0009](adr/0009-single-main-and-public-docs.md) | Один `main.typ`, публичные примеры и приватная сборка |
+| [ADR-0010](adr/0010-private-folder-and-employees.md) | Папка `.private`, справочник сотрудников и частичные подписи |
+| [Рабочее пространство автора](../modules/author-workspace.md) | Минимальный корень и один входной файл |
+| [Публичные примеры](../modules/starter-packs.md) | Четыре вида документов и каталог оформления |
+| [Публичная документация](../modules/user-documentation.md) | Видимый `docs/` для авторов |
+| [Рабочая область VS Code](../modules/vscode-workspace.md) | Расширения, задачи и настройки |
+| [Приватные ресурсы](../modules/private-assets.md) | Справочник сотрудников, `.private/settings.typ`, offsets и placeholders |
+| [Публичный фасад](../modules/facade.md) | Стабильный Typst API |
+| [Документ](../modules/domain-document.md) | Профили и режимы выпуска |
+| [Организация](../modules/domain-company.md) | Реквизиты и ресурсы компании |
+| [Стороны](../modules/domain-parties.md) | Адресаты, подписанты и стороны |
+| [Приложения](../modules/domain-attachments.md) | Приложения разных видов документов |
+| [Сборка](../modules/application-render.md) | Application orchestration |
+| [Ресурсы компаний](../modules/infrastructure-assets.md) | Загрузка публичных профилей и overrides |
+| [Основа вёрстки](../modules/presentation-foundation.md) | Tokens и media policy |
+| [Отчёт](../modules/presentation-report.md) | Renderer отчёта |
+| [Письмо](../modules/presentation-letter.md) | Renderer письма |
+| [ТКП](../modules/presentation-commercial-offer.md) | Renderer предложения |
+| [Договор](../modules/presentation-contract.md) | Renderer договора |
+| [Компоненты](../modules/components.md) | Таблицы, формулы и подписи |
+| [Ссылки](../modules/references.md) | Перекрёстные ссылки и библиография |
+| [Нумерация](../modules/numbering.md) | Стратегии нумерации |
+| [Тестирование](../modules/testing.md) | Static, compile, semantic и visual suites |
+
+## Статус
+
+Редизайн принят. Корневой и публичные примеры компилируются на Typst 0.15.1; полная тестовая матрица является release gate.

+ 30 - 0
.template/development/docs/adr/0001-typst-015.md

@@ -0,0 +1,30 @@
+# ADR-0001: Typst 0.15.1 как минимальная платформа
+
+**Дата**: 2026-08-26
+**Статус**: Принято
+
+## Контекст
+
+Шаблон уже реализован на Typst и использует его counters, introspection, show rules и PDF-рендер. После обновления окружения доступен Typst 0.15.1, который добавляет несколько bibliographies, тип `path` для передачи project-relative ресурсов и более подробные diagnostics. Одновременно 0.15 меняет baseline некоторых layout-элементов, поэтому обновление должно сопровождаться визуальным аудитом.
+
+Официальные основания: [changelog Typst 0.15.0](https://typst.app/docs/changelog/0.15.0/), [bibliography](https://typst.app/docs/reference/model/bibliography/), [path](https://typst.app/docs/reference/foundations/path/).
+
+## Рассматриваемые варианты
+
+1. **Остаться на Typst 0.14.2** — меньше миграционных рисков сейчас, но нет нативных нескольких bibliographies и нового `path`.
+2. **Принять Typst 0.15.1+** — доступны нужные возможности, но требуется новый baseline и контроль будущих обновлений.
+3. **Перейти на LaTeX или Word** — большая экосистема, но фактически требует переписать проверенную вёрстку и усложняет программируемые компоненты.
+
+## Решение
+
+Выбрали **Typst 0.15.1 как минимальную поддерживаемую версию**, потому что она уже установлена, поддерживает несколько bibliographies и даёт корректную модель передачи путей между пользовательским проектом и библиотекой.
+
+Каждое обновление Typst выполняется отдельным изменением: сначала полная компиляционная и визуальная матрица, затем принятие новых snapshots.
+
+## Последствия
+
+**Становится проще**: тематические и поглавные библиографии, автономное подключение шаблона, диагностика layout convergence.
+
+**Становится сложнее**: необходимо контролировать версию CLI и визуальные изменения baseline.
+
+**Закрывает дверь на**: поддержку Typst 0.14 и более ранних версий без отдельной compatibility-ветки.

+ 37 - 0
.template/development/docs/adr/0002-data-and-assets.md

@@ -0,0 +1,37 @@
+# ADR-0002: Публичные, пользовательские и приватные ресурсы
+
+**Дата**: 2026-08-31
+**Статус**: Принято
+
+> Способ доставки через ZIP заменён папкой `.private` в [ADR-0010](0010-private-folder-and-employees.md). Граница публичных и приватных данных остаётся действующей.
+
+## Контекст
+
+Внутренний шаблон Scientia содержит фирменные реквизиты, логотипы, имена, подписи, печати и материалы конкретного документа. Логотипы, адреса, имена и реквизиты разрешено распространять внутри компании. Реальные подписи и печати нельзя хранить в Git вместе с шаблоном.
+
+Typst не читает ZIP напрямую и не умеет проверить наличие изображения без попытки его загрузить. Поэтому приватный архив должен быть внешним каналом доставки, а отсутствие ресурса должно моделироваться значением `none`.
+
+## Рассматриваемые варианты
+
+1. **Оставить всё в одном tracked-каталоге** — максимально просто, но подписи и печати неизбежно распространяются с каждым форком.
+2. **Хранить приватные изображения в Git LFS** — уменьшает основной репозиторий, но не устраняет доступ и историю распространения.
+3. **Хранить подписи и печати в отдельном ZIP** — требует извлечения, зато отделяет приватный канал от шаблона.
+4. **Не поддерживать реальные изображения вообще** — безопасно, но не покрывает подготовку финальных документов.
+
+## Решение
+
+Выбрали **три класса ресурсов**:
+
+- публичные фирменные данные и логотипы хранятся в `.template/companies/`;
+- материалы конкретного документа хранятся в `assets/`;
+- реальные подписи и печати поставляются отдельным `private-assets.zip`; проверенная задача извлекает их в `.private/` и включает через `sys.inputs` только на время приватной сборки `main.typ`.
+
+`.private/` и `private-assets*.zip` исключаются через `.gitignore`. В репозитории остаются только нейтральные placeholders: векторный круг для печати и крест для подписи. При значении ресурса `none` renderer использует placeholder; указанный путь обязан существовать.
+
+## Последствия
+
+**Становится проще**: безопасно форкать шаблон, централизованно обновлять публичные реквизиты и собирать документ без приватного архива.
+
+**Становится сложнее**: для финального подписанного PDF нужно получить ZIP, извлечь его и явно указать пути.
+
+**Закрывает дверь на**: хранение настоящих подписей и печатей в обычном Git, Git LFS или visual snapshots.

+ 28 - 0
.template/development/docs/adr/0003-rendering-model.md

@@ -0,0 +1,28 @@
+# ADR-0003: Синхронная детерминированная модель сборки
+
+**Дата**: 2026-08-26
+**Статус**: Принято
+
+## Контекст
+
+Сборка документа выполняется локальным Typst compiler: данные читаются из файлов проекта, затем происходит несколько внутренних итераций layout и создаётся PDF. Внешних сетевых сервисов, конкурентной записи или длительных независимых операций в v1 нет.
+
+Добавление собственной async-модели не ускорит Typst-layout, но усложнит диагностику, воспроизводимость и тестирование.
+
+## Рассматриваемые варианты
+
+1. **Синхронная сборка одного документа** — простая, воспроизводимая и соответствует модели Typst.
+2. **Параллельные renderer-профили внутри Typst** — не поддерживаются как управляемая модель и не дают изоляции layout-state.
+3. **Внешний асинхронный build-сервис** — полезен для массовой генерации, но избыточен для локального шаблона.
+
+## Решение
+
+Выбрали **синхронную детерминированную сборку одного документа**. Параллельный запуск нескольких независимых fixtures допускается только во внешнем test harness, где каждый процесс получает собственный entry point и output.
+
+## Последствия
+
+**Становится проще**: воспроизводимость, порядок diagnostics, изоляция `state` и расследование visual regressions.
+
+**Становится сложнее**: массовая генерация большого набора документов должна оркестрироваться внешним скриптом.
+
+**Закрывает дверь на**: сетевые и фоновые операции непосредственно внутри шаблона v1.

+ 36 - 0
.template/development/docs/adr/0004-error-handling.md

@@ -0,0 +1,36 @@
+# ADR-0004: Ранняя валидация и явные fallback-политики
+
+**Дата**: 2026-08-26
+**Статус**: Принято
+
+## Контекст
+
+Ошибки Typst часто проявляются во время layout далеко от места, где пользователь передал неверное значение. Для бизнес-документа особенно опасны тихие fallback: неверная компания, отсутствующая сторона, незаметно пропавшая подпись или citation, не попавшая в список источников.
+
+При этом распространяемый шаблон должен компилироваться без реальных подписей и печатей. Они поставляются отдельным ZIP; PowerShell task проверяет и извлекает архив, после чего запускает `main.typ` с явным `sys.inputs`. Typst 0.15 не предоставляет проверки существования файла без попытки загрузки.
+
+## Рассматриваемые варианты
+
+1. **Полагаться только на diagnostics Typst** — мало кода, но сообщения не отражают доменный путь поля.
+2. **Всегда аварийно завершаться при любом отсутствующем ресурсе** — строго, но шаблон нельзя удобно распространять без подписей.
+3. **Валидировать domain до layout и явно моделировать необязательные ресурсы** — больше контрактов, зато ошибки предсказуемы.
+
+## Решение
+
+Выбрали **раннюю profile-specific валидацию**. Каждая ошибка называет профиль, путь поля, фактическое значение и ожидаемое ограничение.
+
+Для подписи, печати и необязательных изображений поддерживаются политики:
+
+- `hide` — не показывать ресурс и не резервировать место;
+- `placeholder` — показать безопасную графическую заглушку: круг для печати или крест для подписи;
+- `reserve-space` — оставить место для ручной подписи или печати.
+
+Политика применяется только если поле равно `none`. Если поле содержит путь, но файл отсутствует, сборка завершается ошибкой.
+
+## Последствия
+
+**Становится проще**: распространение шаблона, поиск причины ошибки и тестирование негативных сценариев.
+
+**Становится сложнее**: каждый профиль обязан определить required/optional поля и defaults.
+
+**Закрывает дверь на**: молчаливое игнорирование неверно указанного пути к производственному ресурсу.

+ 37 - 0
.template/development/docs/adr/0005-ddd-boundaries.md

@@ -0,0 +1,37 @@
+# ADR-0005: DDD-границы внутри модульного Typst-монолита
+
+**Дата**: 2026-08-26
+**Статус**: Принято
+
+## Контекст
+
+Шаблон должен расширяться новыми видами документов, но обычное использование должно оставаться простым. Один монолитный `show` с ветвлением по типу документа быстро свяжет корпоративные данные, file paths, domain-правила и пагинацию. Полноценные микросервисы или отдельные пакеты для каждого bounded context, напротив, избыточны для локальной Typst-библиотеки.
+
+## Рассматриваемые варианты
+
+1. **Одна функция с `if kind == ...`** — минимальный старт, но любое расширение меняет общее ядро и повышает риск регрессии.
+2. **Модульный монолит с DDD-границами и profile contract** — изоляция без инфраструктурной сложности.
+3. **Отдельный Typst package для каждого вида документа** — сильная физическая изоляция, но дублирование foundation и сложное совместное версионирование.
+
+## Решение
+
+Выбрали **модульный монолит** со слоями Domain → Application и адаптерами Infrastructure/Presentation. Domain не импортирует presentation или infrastructure. Новый вид документа добавляется новым `DocumentProfile`, а не новой веткой в `document()`.
+
+Разрешённое направление зависимостей:
+
+```text
+Facade → Application → Domain
+            │            ▲
+            ├→ Infrastructure
+            └→ Presentation → Shared Components
+```
+
+Presentation и Infrastructure могут создавать domain-значения или читать их, но не изменяют domain-инварианты.
+
+## Последствия
+
+**Становится проще**: добавление договора или другого профиля, независимые fixtures и локализация `show/state`.
+
+**Становится сложнее**: необходимо поддерживать явные contracts и проверять import graph.
+
+**Закрывает дверь на**: доступ domain-модулей к JSON, `image`, `page`, `context` и глобальным renderer-state.

+ 32 - 0
.template/development/docs/adr/0006-visual-regression.md

@@ -0,0 +1,32 @@
+# ADR-0006: Трёхуровневая стратегия регрессионного тестирования
+
+**Дата**: 2026-08-26
+**Статус**: Принято
+
+## Контекст
+
+Шаблон содержит хрупкую пагинацию, сложные таблицы, подписи, формулы, кириллическую нумерацию и show rules. Успешная компиляция не обнаруживает тихий перенос строки, наложение печати или изменение количества страниц. Чистый pixel-perfect diff, в свою очередь, слишком чувствителен к версии renderer и системным шрифтам.
+
+## Рассматриваемые варианты
+
+1. **Проверять только exit code компилятора** — быстро, но не защищает макет.
+2. **Использовать только pixel-perfect snapshots** — ловит всё, но создаёт шум при допустимых изменениях окружения.
+3. **Совместить unit, semantic и visual проверки** — больше инфраструктуры, зато дефекты классифицируются точнее.
+
+## Решение
+
+Выбрали **три уровня тестов**:
+
+1. Domain unit tests через `typst eval` и `assert`.
+2. Compile/semantic tests: exit code, diagnostics, A4, количество страниц, наличие обязательных текстовых маркеров и PDF metadata.
+3. Visual regression: rasterize через Poppler, сравнивать контрольные области и полный perceptual diff с документированным порогом.
+
+Snapshots создаются только из синтетического `test-company`; реальные подписи и печати не включаются в публичные тестовые изображения. Новая версия Typst всегда проверяется отдельным прогоном до обновления snapshots.
+
+## Последствия
+
+**Становится проще**: находить как логические, так и визуальные регрессии и безопасно менять отдельные profiles.
+
+**Становится сложнее**: требуется Python/Poppler test runtime и процедура осознанного обновления эталонов.
+
+**Закрывает дверь на**: автоматическое принятие новых snapshots при обычном тестовом запуске.

+ 39 - 0
.template/development/docs/adr/0007-author-workspace.md

@@ -0,0 +1,39 @@
+# ADR-0007: Минимальный корень и три режима одного документа
+
+**Дата**: 2026-08-31
+**Статус**: Устарело — заменено [ADR-0009](0009-single-main-and-public-docs.md)
+
+## Контекст
+
+> Этот ADR сохраняется как история промежуточного решения. Три entrypoint-файла были удалены после проверки на реальных отчётах: пользователю удобнее выбирать режим в одном `main.typ`.
+
+В текущем корне служебные каталоги конкурируют с `chapters/` и `assets/`, а `main.typ` смешивает демонстрационные данные, настройку и сборку. Автору после форка нужен короткий маршрут без изучения DDD-слоёв, тестов и примеров.
+
+Одновременно должны поддерживаться три выпуска одного содержания и четыре вида документов. Обратная совместимость со старыми путями не требуется.
+
+## Рассматриваемые варианты
+
+1. **Оставить служебные каталоги в корне** — удобно разработчику, но перегружает основной сценарий автора.
+2. **Удалить тесты и документацию** — очищает корень, но делает шаблон хрупким и плохо сопровождаемым.
+3. **Перенести внутреннее устройство в `.template/`** — сохраняет разработку и визуально отделяет её от пользовательских файлов.
+4. **Создать отдельный entrypoint для каждого типа и режима** — явно, но приводит минимум к двенадцати корневым файлам и дублированию конфигурации.
+
+## Решение
+
+Выбрали **один скрытый каталог `.template/`**, общий `document.typ` и три корневые точки входа:
+
+- `main.typ` передаёт `mode: "final"`;
+- `draft.typ` передаёт `mode: "draft"`;
+- `clean-copy.typ` передаёт `mode: "clean-copy"`.
+
+Все entrypoints импортируют `render(mode:)` из `document.typ`. Report, letter, commercial-offer и contract starters реализуют одинаковый контракт, поэтому выбор типа документа не меняет entrypoints.
+
+В starter явно записываются все семантически важные параметры, включая осознанные `none`, `false` и `()`. Низкоуровневые параметры layout остаются внутри библиотеки.
+
+## Последствия
+
+**Становится проще**: первый fork, переключение режима, выбор starter и обновление внутренней реализации.
+
+**Становится сложнее**: `document.typ` является обязательным стабильным контрактом, а каждый starter должен проходить contract-tests.
+
+**Закрывает дверь на**: compatibility-файлы в корне и отдельные копии полной конфигурации для каждого режима.

+ 35 - 0
.template/development/docs/adr/0008-vscode-onboarding.md

@@ -0,0 +1,35 @@
+# ADR-0008: Версионируемая рабочая область VS Code и обучение автора
+
+**Дата**: 2026-08-31  
+**Статус**: Принято
+
+## Контекст
+
+Основные пользователи шаблона пишут документы, но могут никогда не работать с кодом, Git и Typst. Устные инструкции и личные настройки редактора не воспроизводятся в новом форке. Одновременно каталог `.vscode/` не должен отвлекать автора от текста.
+
+Часть расширений доступна в публичном Marketplace, а Typst Typewriter и Zotst распространяются внутри компании как локальные VSIX. Запись идентификатора расширения в рекомендациях VS Code не распространяет сам установочный пакет.
+
+## Рассматриваемые варианты
+
+1. **Не хранить настройки редактора** — корень формально проще, но каждый сотрудник вручную повторяет настройку и получает различное поведение.
+2. **Настроить всё глобально на рабочих станциях** — удобно на одном компьютере, но не переносится вместе с форком и требует администрирования.
+3. **Версионировать `.vscode/` и скрыть его в проводнике** — настройки синхронизируются через Git, оставаясь вне повседневной области автора.
+4. **Положить локальные VSIX в шаблон** — обеспечивает автономную установку, но смешивает бинарные пакеты с исходниками и затрудняет централизованное обновление.
+
+## Решение
+
+Выбран вариант **версионировать `.vscode/`, но скрывать его из Explorer**:
+
+- `extensions.json` содержит десять согласованных идентификаторов, включая два внутренних;
+- `settings.json` включает автосохранение, языки проверки орфографии, TODO-маркеры и защитные настройки Git;
+- `tasks.json` предоставляет обычную и приватную сборку, выбор одного из четырёх публичных примеров и компиляцию учебного каталога;
+- внутренние VSIX хранятся в корпоративном хранилище, а README объясняет их установку;
+- README и видимый `docs/` обучают Git в терминах истории документа, контрольных точек и параллельных версий.
+
+## Последствия
+
+**Становится проще**: первый запуск, одинаковая среда во всех форках, живой предпросмотр, проверка русского текста и совместная работа через Git.
+
+**Становится сложнее**: изменения `.vscode/` требуют такого же review, как изменения шаблона; сопровождающий должен отдельно публиковать совместимые VSIX.
+
+**Закрывает дверь на**: неявные обязательные глобальные настройки и распространение внутренних бинарных расширений внутри Git-шаблона.

+ 35 - 0
.template/development/docs/adr/0009-single-main-and-public-docs.md

@@ -0,0 +1,35 @@
+# ADR-0009: Один main.typ, видимая документация и явная приватная сборка
+
+**Дата**: 2026-08-31  
+**Статус**: Принято
+
+> Часть решения об отдельной private build task заменена условным import из [ADR-0010](0010-private-folder-and-employees.md). Один `main.typ` и видимая публичная документация остаются действующими.
+
+## Контекст
+
+Проверка шаблона на реальных отчётах показала, что авторы ожидают менять компанию, стадию, режим и список глав в одном файле. Три корневых entrypoint-файла и скрытая пользовательская справка создавали лишний выбор. Одновременно Typst 0.15 не умеет безопасно проверять существование private path.
+
+## Рассматриваемые варианты
+
+1. **Сохранить `document.typ` и три entrypoints** — технически чисто, но пользователь должен понимать разделение ролей четырёх файлов.
+2. **Один `main.typ` и tracked private placeholders с заменой** — просто, но реальная подпись становится изменением уже отслеживаемого файла.
+3. **Один `main.typ`, public `docs/`, internal placeholders и private task** — минимальная поверхность без риска добавить реальный media в Git.
+
+## Решение
+
+Выбран третий вариант:
+
+- `main.typ` содержит `company-id`, `document-mode`, metadata и `#include`;
+- `docs/` видим и содержит executable examples;
+- examples одновременно являются источниками задачи выбора типа;
+- обычная сборка использует internal placeholders;
+- задача приватной сборки проверяет `private-assets.zip`, копирует PNG в `.private/` и передаёт `--input private-assets=true`;
+- прежний ADR-0007 считается устаревшим.
+
+## Последствия
+
+**Становится проще**: первый запуск, переключение режима, поиск примеров, выбор типа и выпуск с приватными изображениями.
+
+**Становится сложнее**: main-файлы четырёх примеров частично повторяют setup-код; PowerShell tool становится security boundary для ZIP.
+
+**Закрывает дверь на**: отдельные root entrypoints для режимов, скрытую public-документацию и замену tracked placeholders реальными файлами.

+ 26 - 0
.template/development/docs/adr/0010-private-folder-and-employees.md

@@ -0,0 +1,26 @@
+# ADR-0010: Папка `.private`, справочник сотрудников и частичные подписи
+
+**Дата**: 2026-08-31  
+**Статус**: Принято
+
+## Контекст
+
+ZIP installer и отдельная build task скрывали второй переключатель private media от автора и не поддерживали реальные фамильные имена PNG. В проектах подписи поступают постепенно, а для каждого изображения уже подобрано индивидуальное вертикальное смещение. Чистый fork при этом обязан собираться без приватного каталога.
+
+## Рассматриваемые варианты
+
+1. **Оставить ZIP и `sys.inputs`** — безопасно для clean fork, но preview и обычная task не совпадают, а схема имён жёстко привязана к ролям.
+2. **Автоматически сканировать `.private`** — желаемый UX, но Typst 0.15 не предоставляет file-exists и падает при попытке открыть отсутствующий PNG.
+3. **Условный import и явная карта доступности** — один параметр в `main.typ`, отсутствие записи означает пустое место, offsets живут рядом с приватными PNG.
+
+## Решение
+
+Выбран вариант 3. Публичный tracked-справочник хранит идентификатор, ФИО, обычную должность и фиксированное имя PNG. Игнорируемый `.private/settings.typ` хранит доступность и offset. `main.typ` импортирует его только при literal `use-private-assets = true`. Все build/preview пути используют один entrypoint.
+
+## Последствия
+
+**Становится проще**: копировать `.private` целиком, менять роль сотрудника в одной строке, видеть реальные подписи в preview, работать при частично полученных PNG и публиковать clean fork с `false`.
+
+**Становится сложнее**: при получении нового PNG нужно вручную включить запись; включённый, но отсутствующий файл по-прежнему вызывает точную ошибку Typst.
+
+**Закрывает дверь на**: автоматическое определение файлов, role-based имена `responsible.png`, отдельную private build task и ZIP installer.

+ 42 - 0
.template/development/modules/application-render.md

@@ -0,0 +1,42 @@
+# Модуль: Сборка документа
+
+**Ответственность**: выполняет use case «собрать документ» в фиксированном порядке от пользовательских параметров до вызова renderer.
+**Расположение**: `.template/lib/application/render-document.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `render-document()` | функция | Оркестрирует resolve company → normalize → validate → create context → render |
+| `resolve-profile()` | функция | Проверяет и нормализует встроенный или пользовательский `DocumentProfile` |
+| `build-context()` | функция | Создаёт итоговый `DocumentContext` из валидированных частей |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/document.typ` | Контракт профиля, context и render options |
+| `.template/lib/domain/company.typ` | Валидацию CompanyProfile |
+| `.template/lib/domain/parties.typ` | Валидацию сторон и подписантов |
+| `.template/lib/domain/attachments.typ` | Валидацию приложений |
+| `.template/lib/infrastructure/company-assets.typ` | Загрузку компании только если передан строковый id |
+
+## Инварианты
+
+- Renderer никогда не вызывается до завершения всех validators.
+- Строковый `company` разрешается infrastructure-адаптером; готовый `CompanyProfile` повторно не загружается.
+- Порядок normalize и validate одинаков для всех профилей.
+- Application не содержит `if profile.id == "report"` или другой profile-specific логики.
+- `body` передаётся renderer без изменения пользовательского content.
+- Ошибка содержит stage: `resolve-company`, `normalize-profile`, `validate-domain` или `render`.
+
+## Намеренно НЕ обрабатывает
+
+- Page layout и show rules.
+- Чтение пользовательского `assets/`.
+- Юридическую проверку содержимого.
+- Запуск Typst compiler или запись PDF на диск.
+
+## Заметки для агента
+
+> Этот модуль должен оставаться коротким и скучным: его ценность — стабильный pipeline. Если новая возможность требует ветвления по профилю, добавьте её в profile contract или domain service. Infrastructure подключается как адаптер только для удобного строкового company id.

+ 42 - 0
.template/development/modules/author-workspace.md

@@ -0,0 +1,42 @@
+# Модуль: Рабочее пространство автора
+
+**Ответственность**: предоставляет один понятный входной файл и отделяет пользовательский текст от реализации.  
+**Расположение**: `main.typ`, `chapters/`, `assets/`, `docs/`
+
+## Публичный интерфейс
+
+| Элемент | Тип | Описание |
+|---------|-----|----------|
+| `main.typ` | entrypoint | Компания, режим, metadata, ресурсы и порядок `#include` |
+| `document-mode` | строка | `final`, `draft` или `clean-copy` |
+| `chapters/` | каталог | Текстовые разделы документа |
+| `assets/` | каталог | Рисунки, CSV/TSV, bibliography и другие материалы |
+| `docs/` | каталог | Публичная справка и компилируемые примеры |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/index.typ` | Единственный Typst facade import |
+| `chapters/*.typ` | Явно перечисленное содержимое |
+| `assets/*` | Пользовательские paths |
+| `.private/settings.typ` | Только при явном `use-private-assets = true` |
+
+## Инварианты
+
+- В корне существует только один Typst entrypoint `main.typ`.
+- Все ежедневные параметры и порядок глав видны в одном файле.
+- Режимы не представлены отдельными файлами.
+- `docs/` не скрыт настройками VS Code.
+- Автор не обязан открывать `.template/` для первого PDF.
+- Generated PDF и `.private/` не попадают в Git.
+
+## Намеренно НЕ обрабатывает
+
+- Layout renderer и profile validation.
+- Разработку нового вида документа.
+- Хранение настоящих подписей.
+
+## Заметки для агента
+
+> Корень является пользовательским интерфейсом. Новый обязательный параметр добавляйте в `main.typ` и public examples с вариантом в комментарии. Не создавайте дополнительный entrypoint ради режима сборки.

+ 53 - 0
.template/development/modules/components.md

@@ -0,0 +1,53 @@
+# Модуль: Компоненты
+
+**Ответственность**: содержит проверенные переиспользуемые визуальные примитивы, которые не принадлежат одному профилю документа.
+**Расположение**: `.template/lib/presentation/components.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `corp-table()` | функция | Корпоративная таблица со сложной шапкой, spans и продолжениями |
+| `formula()` | функция | Блочная формула, совместимая с профильной нумерацией |
+| `signature-block()` | функция | Один подписант с media policy |
+| `multi-party-signing()` | функция | Подписание несколькими сторонами |
+| `approval-block()` | функция | Блок утверждения отчёта |
+| `company-footer()` | функция | Контакты и реквизиты организации в footer |
+| `letter-header()` | функция | Общая геометрия исходящего письма и ТКП |
+| `attachment-list()` | функция | Перечень приложений к письму или ТКП |
+| `requisites-table()` | функция | Реквизиты одной или нескольких сторон |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/company.typ` | Нормализованные реквизиты и brand |
+| `.template/lib/domain/parties.typ` | Signer, Approval и Party |
+| `.template/lib/domain/attachments.typ` | AttachmentSet для перечней |
+| `.template/lib/presentation/foundation.typ` | Design tokens и `render-media-slot()` |
+| `.template/lib/shared/numbering.typ` | Общие numbering functions |
+
+## Инварианты
+
+- Компонент не читает JSON и не конструирует пользовательский path.
+- Компонент не определяет global page settings.
+- `corp-table` сохраняет поддержку auto/multirow headers, rowspan/colspan, repeat header и continuation label.
+- `row_breakable: true` действительно передаётся в `table.cell(breakable:)`; режим проверяется fixture с одной строкой, которая продолжается на трёх страницах.
+- Центрирование блока `corp-table` не наследуется текстом ячеек: шапка по умолчанию центрирована, тело выровнено влево; явный `align` имеет приоритет.
+- Блок подписи сохраняет эталонную геометрию для обычных реквизитов и переключается на ограниченные равные боковые колонки при длинных наименованиях.
+- Письмо и ТКП используют один эталонный footer; на страницах приложений footer скрывается.
+- Обязательный positional `body` идёт первым в функциях, используемых через `.with`, если это требуется Typst.
+- Компонент принимает domain-значение и layout options отдельно.
+- Любая работа с изображением проходит через media slot или проверку `resource != none`.
+- Компоненты не импортируют profile renderers.
+
+## Намеренно НЕ обрабатывает
+
+- Полный жизненный цикл документа.
+- Profile-specific обязательность полей.
+- Выбор компании по id.
+- Сброс глобальных counters между главами.
+
+## Заметки для агента
+
+> `corp_table` считается особо хрупким: не переписывайте рекурсивное определение header rows без unit и visual fixtures на rowspan/colspan. Общность компонента доказывается использованием хотя бы в двух профилях; иначе оставьте его внутри конкретного renderer. Layout offsets изображений должны быть параметрами компонента или design tokens, но не данными Signer.

+ 39 - 0
.template/development/modules/domain-attachments.md

@@ -0,0 +1,39 @@
+# Модуль: Приложения
+
+**Ответственность**: задаёт идентичность, порядок, заголовки и нумерацию приложений независимо от профиля и конкретной вёрстки.
+**Расположение**: `.template/lib/domain/attachments.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `attachment()` | конструктор dictionary | Создаёт приложение с `id`, `title`, `subtitle`, `body`, `numbering` |
+| `attachment-set()` | конструктор dictionary | Нормализует массив приложений и общую политику нумерации |
+| `validate-attachments()` | функция | Проверяет уникальность ids, номеров и допустимость body |
+| `attachment-label()` | чистая функция | Формирует семантическое обозначение без layout |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/shared/numbering.typ` | Стратегии арабской и кириллической нумерации как чистые функции |
+
+## Инварианты
+
+- `id` приложения уникален и пригоден для label.
+- Порядок массива является порядком документа, если пользователь явно не задал sort key.
+- Номер не хранится одновременно как вычисляемый и вручную заданный без явной override-policy.
+- `body` является content или функцией, которую renderer вызывает в локальном контексте.
+- Заголовок обязателен; subtitle необязателен.
+- Приложение не меняет global counter вне вызова своего renderer.
+
+## Намеренно НЕ обрабатывает
+
+- Размещение pagebreak и заголовка приложения.
+- Физическое объединение внешних PDF.
+- Подсчёт страниц вложения до компиляции.
+- Profile-specific текст «Приложение к договору».
+
+## Заметки для агента
+
+> Старые `make_appendices` и `appendix-header` решают presentation-задачи и не переносятся в domain. Domain должен одинаково поддерживать кириллические приложения отчёта, цифровые приложения ТКП и именованные приложения договора. Renderer выбирает display policy на основе profile metadata.

+ 39 - 0
.template/development/modules/domain-company.md

@@ -0,0 +1,39 @@
+# Модуль: Организация
+
+**Ответственность**: описывает единый нормализованный профиль юридического лица и его фирменных ресурсов.
+**Расположение**: `.template/lib/domain/company.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `company-profile()` | конструктор dictionary | Создаёт профиль из секций `legal`, `contacts`, `banking`, `brand`, `director`, `resources` |
+| `validate-company()` | функция | Проверяет обязательные реквизиты и типы необязательных полей |
+| `company-display-name()` | чистая функция | Возвращает полное или краткое наименование по политике профиля |
+| `company-resource()` | чистая функция | Возвращает нормализованное значение ресурса из уже построенного profile |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| Нет | Domain не читает JSON и не строит пути |
+
+## Инварианты
+
+- `id`, полное наименование и юрисдикция заданы.
+- ИНН/КПП/ОГРН и БИН/КБЕ хранятся как строки, чтобы не терять ведущие нули и формат.
+- Контактные и банковские поля имеют единые ключи для всех компаний; неприменимое поле равно `none`.
+- Brand color нормализован до color до передачи renderer.
+- Логотип, подпись и печать имеют значение `none`, `path` или готовый content; произвольная строка после infrastructure-нормализации не допускается.
+- Director содержит должность и ФИО; ресурсы подписи и печати хранятся отдельно и не задают координаты на странице.
+
+## Намеренно НЕ обрабатывает
+
+- Поиск компании по содержимому документа.
+- Чтение `.template/companies/` и `.private/`.
+- Отрисовку логотипа, подписи, печати и реквизитов.
+- Валидацию законодательства конкретной юрисдикции.
+
+## Заметки для агента
+
+> Существующие JSON-файлы отличаются по набору полей. Нормализуйте их через infrastructure, не добавляйте условные ключи в renderer. Для РФ и Казахстана используйте общую структуру с `none` для неприменимых идентификаторов. Не переносите пользовательские изображения в CompanyProfile.

+ 40 - 0
.template/development/modules/domain-document.md

@@ -0,0 +1,40 @@
+# Модуль: Документ
+
+**Ответственность**: владеет общей моделью документа, контрактом расширяемого профиля и режимами выпуска независимо от Typst-вёрстки.
+**Расположение**: `.template/lib/domain/document.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `document-context()` | конструктор dictionary | Создаёт полный неизменяемый контекст после нормализации и валидации |
+| `document-profile()` | конструктор dictionary | Создаёт контракт профиля с `id`, `metadata`, `normalize`, `validate`, `render` |
+| `render-options()` | конструктор dictionary | Нормализует режим выпуска, watermark и media policy |
+| `validate-profile-contract()` | функция | Проверяет, что расширение содержит все обязательные функции и поля |
+| `merge-known()` | чистая функция | Объединяет defaults с пользовательскими значениями по явным правилам |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| Нет | Модуль не импортирует infrastructure или presentation |
+
+## Инварианты
+
+- `DocumentProfile.id` — непустая стабильная строка.
+- `normalize`, `validate` и `render` являются functions.
+- Режим выпуска принадлежит конечному набору `final`, `draft`, `clean-copy`.
+- `DocumentContext` создаётся только после успешной валидации company, profile metadata, parties и attachments.
+- Domain-значения не содержат вызовов `page`, `image`, `place`, `context`, `query` или глобального `state`.
+- Merge не принимает неизвестные ключи молча: профиль либо объявляет extension bucket, либо возвращает ошибку.
+
+## Намеренно НЕ обрабатывает
+
+- Загрузку компании из JSON.
+- Пагинацию и выбор шрифта.
+- Физическое наличие файлов.
+- Состав обязательных полей конкретного отчёта или договора.
+
+## Заметки для агента
+
+> `DocumentProfile` — основной extension contract. Не превращайте его в строковый enum со switch в application. Renderer-function является портом presentation, переданным профилем. Для простого пользователя profile constructors скрывают этот контракт; вручную он нужен только разработчику нового вида документа.

+ 40 - 0
.template/development/modules/domain-parties.md

@@ -0,0 +1,40 @@
+# Модуль: Стороны
+
+**Ответственность**: моделирует адресатов, стороны договора, представителей, подписантов и блоки утверждения независимо от их размещения.
+**Расположение**: `.template/lib/domain/parties.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `party()` | конструктор dictionary | Создаёт организацию или физическое лицо в роли стороны документа |
+| `recipient()` | конструктор dictionary | Создаёт адресата делового письма или ТКП |
+| `signer()` | конструктор dictionary | Создаёт подписанта с должностью, ФИО, основанием и ресурсом подписи |
+| `approval()` | конструктор dictionary | Создаёт данные блока утверждения отчёта |
+| `validate-parties()` | функция | Проверяет уникальность ролей и профильные обязательные поля |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/company.typ` | Может принять нормализованный `CompanyProfile` как сторону, не загружая его |
+
+## Инварианты
+
+- Каждая сторона имеет уникальный `id` внутри документа и непустую `role`.
+- Recipient допускает отдельно организацию, должность и имя; пустые строки нормализуются в `none`.
+- Signer хранит семантические данные и ресурс, но не layout offsets.
+- Contract-party содержит реквизиты либо ссылку на `CompanyProfile`, но не оба источника с конфликтующими значениями.
+- Approval date и document date являются разными полями и не подменяют друг друга.
+- Обязательность подписи определяется профилем и режимом выпуска, а не самим `Signer`.
+
+## Намеренно НЕ обрабатывает
+
+- Координаты и размеры изображений подписи.
+- Текст преамбулы договора.
+- Склонение ФИО и должностей.
+- Загрузку реквизитов из файлов.
+
+## Заметки для агента
+
+> Письмо, ТКП и договор должны использовать одинаковые базовые Party/Signer, но разные validators. Не добавляйте коммерческие поля в recipient и report-specific approval в party. Если понадобится склонение, создайте отдельный domain service, а не набор условий внутри renderer.

+ 48 - 0
.template/development/modules/facade.md

@@ -0,0 +1,48 @@
+# Модуль: Публичный фасад
+
+**Ответственность**: предоставляет `main.typ` и публичным примерам стабильный API, скрывая внутренние DDD-слои и файловую структуру.
+**Расположение**: `.template/lib/index.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `document()` | show-функция | Собирает документ из явно переданных `company`, `profile`, `options` и `body` |
+| `profiles` | module namespace | Конструкторы `report`, `letter`, `commercial_offer`, `contract` |
+| `components` | module namespace | Поддерживаемые визуальные компоненты для пользовательского content |
+| `references` | module namespace | `vref`, `vrefs`, `eqref` и bibliography helpers |
+| `load-company()` | функция | Загружает публичный профиль и применяет явные resource overrides |
+| `report-executor()` | функция | Разрешает сотрудника, роль, private PNG и offset |
+| `private-company-media()` | функция | Разрешает private подпись и печать организации |
+| `document-profile()` | конструктор dictionary | Extension contract для нового вида документа |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/application/render-document.typ` | Единственный application use case |
+| `.template/lib/presentation/profiles/*.typ` | Публичные profile constructors |
+| `.template/lib/presentation/components.typ` | Разрешённый пользовательский набор компонентов |
+| `.template/lib/presentation/references.typ` | Публичные helpers ссылок |
+| `.template/lib/infrastructure/company-assets.typ` | Загрузка профилей компаний |
+| `.template/lib/infrastructure/employees.typ` | Справочник сотрудников и private settings adapter |
+
+## Инварианты
+
+- `main.typ` и public examples используют один import фасада и не знают внутренних путей.
+- В фасаде нет чтения пользовательских глав, page layout и profile-specific ветвлений.
+- Добавление нового профиля не меняет сигнатуру `document()`.
+- Resource override принимает явные `none`, `path` или content и не сканирует `.private/`.
+- В namespaces экспортируются только документированные символы.
+- В diagnostics используется бренд Scientia.
+
+## Намеренно НЕ обрабатывает
+
+- Включение пользовательских файлов из `chapters/`.
+- Создание пользовательских paths к `assets/`.
+- Выбор типа документа и режима выпуска.
+- Юридическую или смысловую проверку текста.
+
+## Заметки для агента
+
+> Фасад является границей между пользовательским `main.typ` и библиотекой. Если public example импортирует domain, infrastructure или конкретный renderer напрямую, фасад недостаточен. Обратная совместимость со старым корневым `template.typ` не требуется; не создавайте alias в корне.

+ 41 - 0
.template/development/modules/infrastructure-assets.md

@@ -0,0 +1,41 @@
+# Модуль: Ресурсы компаний
+
+**Ответственность**: читает публичные данные `.template/companies/`, нормализует JSON, создаёт устойчивые Typst paths и применяет явные overrides подписи и печати.
+**Расположение**: `.template/lib/infrastructure/company-assets.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `load-company()` | функция | Загружает профиль по разрешённому id и принимает `logo`, `signature`, `stamp` overrides |
+| `available-companies()` | функция | Возвращает детерминированный список встроенных ids |
+| `merge-resources()` | чистая функция | Применяет только явно переданные resource overrides |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/company.typ` | `company-profile()` и `validate-company()` |
+| `.template/companies/*/data.json` | Публичные реквизиты организаций |
+| `.template/companies/*/logo.*` | Публичные логотипы |
+| `.template/lib/assets/placeholders/` | Безопасные круг и крест |
+
+## Инварианты
+
+- Адаптер не сканирует `assets/` или `.private/`.
+- Относительные внутренние paths создаются в infrastructure-файле.
+- Private paths организаций создаются адаптером сотрудников из явной настройки и передаются как готовые `path`.
+- Идентификатор компании выбирается из явного registry.
+- Публичные JSON не содержат путей к реальным подписям и печатям.
+- `none` сохраняется как отсутствие ресурса и обрабатывается media policy.
+- Указанный override не подменяется placeholder при ошибке загрузки.
+
+## Намеренно НЕ обрабатывает
+
+- Layout логотипа, подписи или печати.
+- Поиск файлов по имени.
+- Сетевую загрузку реквизитов.
+
+## Заметки для агента
+
+> Typst `path` сохраняет контекст файла, в котором создан. Public company paths создавайте здесь, а absolute `.private/` paths — только в private/employee adapter. Значения `auto` и `none` должны различаться: `auto` означает взять публичное значение профиля, `none` — осознанно применить media policy.

+ 42 - 0
.template/development/modules/lists.md

@@ -0,0 +1,42 @@
+# Модуль: Настраиваемые списки
+
+**Ответственность**: формирует многоуровневые номера и маркеры, сохраняя нативную вёрстку `enum`.
+**Расположение**: `.template/lib/presentation/lists.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `numbered-list()` | function | Локально применяет схему и геометрию к стандартному нумерованному списку |
+| `list-scheme()` | constructor | Создаёт проверенную конфигурацию уровней, разделителей и окончаний |
+| `list-level()` | constructor | Описывает нестандартный уровень, включая prefix, suffix и width |
+| `list-numbering()` | function factory | Возвращает функцию нумерации для прямого использования в `enum` |
+| `list-schemes` | dictionary | Хранит публичные готовые схемы |
+
+## Инварианты
+
+- Модуль не заменяет `enum`: переносы страниц, вложенность и многоабзацные элементы остаются ответственностью Typst.
+- Внутренне `enum.full` всегда включён, чтобы форматтер знал глубину; показ родительских уровней определяет `scheme.full`.
+- Если массив levels, separators или suffixes короче глубины, повторяется его последнее значение.
+- Неизвестная именованная схема вызывает понятную ошибку и не подменяется схемой по умолчанию.
+- Локальные параметры `numbered-list` не должны менять списки за пределами переданного body.
+
+## Поддерживаемые обозначения
+
+- арабские числа: `1`;
+- арабские числа с ведущим нулём: `01` или `list-level("1", width: N)`;
+- римские числа: `I`, `i`;
+- латинские буквы: `A`, `a`;
+- кириллица по ГОСТ: `А`, `а`;
+- любой строковый или content-маркер;
+- пользовательская функция `value => content`.
+
+## Намеренно НЕ обрабатывает
+
+- собственную раскладку строк и переносы страниц;
+- скрытое глобальное продолжение счётчика между несвязанными списками;
+- автоматический выбор схемы по содержимому текста.
+
+## Заметки для агента
+
+> Не заменяйте нативный `enum` ручной сеткой или таблицей. Это ухудшит переносы, семантику документа и поддержку многоабзацных пунктов. Новые возможности добавляйте через форматирование массива родительских номеров и локальные set/show rules.

+ 40 - 0
.template/development/modules/numbering.md

@@ -0,0 +1,40 @@
+# Модуль: Нумерация
+
+**Ответственность**: предоставляет чистые стратегии арабской, многоуровневой и кириллической нумерации без управления counters конкретного профиля.
+**Расположение**: `.template/lib/shared/numbering.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `cyrillic-numbering()` | чистая функция | Преобразует положительный номер в допустимую заглавную кириллическую букву |
+| `cyrillic-lower-numbering()` | чистая функция | Формирует строчную кириллическую часть списка |
+| `num-11()` | чистая функция | Формирует многоуровневый формат `1.1.` |
+| `num-1a()` | чистая функция | Чередует цифровые и кириллические уровни с наследованием |
+| `num-1-a()` | чистая функция | Чередует уровни без полного наследования родителей |
+| `attachment-numbering()` | чистая функция | Выбирает арабское или кириллическое обозначение из policy |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| Нет | Модуль состоит из чистых функций и неизменяемых массивов символов |
+
+## Инварианты
+
+- Исключённые ГОСТ-буквы не используются в кириллической последовательности.
+- Ноль и отрицательные значения не маскируются неявным fallback, несовместимым с будущими версиями Typst.
+- Функции не читают counters самостоятельно и форматируют только переданные числа.
+- Один и тот же вход всегда возвращает одинаковую строку.
+- Profile renderer управляет reset и scope counters, а не shared-модуль.
+
+## Намеренно НЕ обрабатывает
+
+- Сброс counters при новой главе.
+- Выбор numbering policy конкретного документа.
+- Формат заголовка приложения и supplement.
+- Локализацию на другие языки.
+
+## Заметки для агента
+
+> Typst 0.15 предупреждает о fallback для numbering systems, которые не умеют отображать ноль. Добавьте явные негативные unit tests. Сохраните существующие варианты `num_11`, `num_1a`, `num_1_a` семантически, но публичные имена можно унифицировать, поскольку обратная совместимость не требуется.

+ 43 - 0
.template/development/modules/presentation-commercial-offer.md

@@ -0,0 +1,43 @@
+# Модуль: Технико-коммерческое предложение
+
+**Ответственность**: формирует ТКП как самостоятельный профиль с предметом, ценой, сроками, коммерческими условиями и приложениями.
+**Расположение**: `.template/lib/presentation/profiles/commercial-offer.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `commercial-offer-profile()` | конструктор `DocumentProfile` | Принимает адресата, предмет, стоимость, валюту, сроки и validity |
+| `render-commercial-offer()` | renderer-функция | Собирает шапку, резюме предложения, body, условия, подпись и приложения |
+| `commercial-terms()` | domain-normalizer | Нормализует цену, НДС, валюту, срок и порядок оплаты |
+| `commercial-offer-validators()` | массив functions | Проверяет предмет и обязательные коммерческие поля |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/document.typ` | `DocumentProfile` и context |
+| `.template/lib/domain/parties.typ` | Recipient и Signer |
+| `.template/lib/domain/attachments.typ` | Календарный план, ТЗ и другие приложения |
+| `.template/lib/presentation/foundation.typ` | Typography, page и media policy |
+| `.template/lib/presentation/components.typ` | Letter shell, money/date blocks, company footer, signature block |
+
+## Инварианты
+
+- ТКП имеет собственный `profile.id` и renderer, а не boolean-режим письма.
+- Стоимость хранится структурированно: amount, currency, tax note; renderer не разбирает свободную строку.
+- Срок выполнения и срок действия предложения являются разными полями.
+- Приложения используют общий AttachmentSet и могут иметь цифровую нумерацию.
+- Коммерческие defaults принадлежат constructor, но пользователь может заменить текстовые формулировки через content slots.
+- Renderer не импортирует внешний `G:/TYPST/TKP` и не читает его config.
+
+## Намеренно НЕ обрабатывает
+
+- Расчёт стоимости из сметы.
+- Юридическую проверку налоговой формулировки.
+- Автоматическое превращение ТКП в договор.
+- Специфические главы научного отчёта.
+
+## Заметки для агента
+
+> Используйте TKP только как контекст сценариев и визуальную подсказку. Не копируйте монолитный facade. Если уникальная иконка действительно нужна, перенесите её в `.template/lib/assets/icons/` и дайте ей семантическое имя. Таблицы календарного плана должны использовать общий `corp-table`, а не локальную реализацию.

+ 44 - 0
.template/development/modules/presentation-contract.md

@@ -0,0 +1,44 @@
+# Модуль: Договор
+
+**Ответственность**: предоставляет расширяемый каркас договора с преамбулой, произвольными разделами, сторонами, реквизитами, подписанием и приложениями.
+**Расположение**: `.template/lib/presentation/profiles/contract.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `contract-profile()` | конструктор `DocumentProfile` | Принимает номер, дату, место, название, стороны и section options |
+| `render-contract()` | renderer-функция | Собирает header, preamble slot, body sections, requisites, signing и attachments |
+| `contract-section()` | конструктор dictionary | Описывает нумерованный или именованный раздел с body |
+| `contract-validators()` | массив functions | Проверяет стороны, номера, даты и уникальность sections |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/document.typ` | `DocumentProfile` и context |
+| `.template/lib/domain/parties.typ` | Contract parties, representatives и signers |
+| `.template/lib/domain/attachments.typ` | Приложения и спецификации |
+| `.template/lib/presentation/foundation.typ` | Base typography и signing-copy mode |
+| `.template/lib/presentation/components.typ` | Requisites table, multi-party signing, tables и numbering primitives |
+
+## Инварианты
+
+- В v1 поддерживается не менее двух сторон; модель не зашита строго на роли «Заказчик/Исполнитель».
+- Section id и display number уникальны.
+- Юридический текст sections остаётся пользовательским content.
+- Preamble можно передать content или собрать из сторон через явный helper; автоматический текст не является юридической гарантией.
+- Requisites берутся из Party/CompanyProfile, а не дублируются внутри renderer.
+- Signing-copy может резервировать место без реальных изображений подписи и печати.
+- Приложения используют тот же AttachmentSet, что отчёт и ТКП.
+
+## Намеренно НЕ обрабатывает
+
+- Юридическую достаточность и актуальность условий.
+- Электронную подпись и криптографию.
+- Согласование версий договора и tracked changes.
+- Автоматическую генерацию актов, счетов или УПД.
+
+## Заметки для агента
+
+> Не превращайте contract-profile в библиотеку юридических формулировок. Профиль отвечает за типографику и композицию. Чтобы будущие лицензионные, сервисные или смешанные договоры не требовали изменения renderer, sections должны быть открытым массивом с устойчивой numbering policy.

+ 41 - 0
.template/development/modules/presentation-foundation.md

@@ -0,0 +1,41 @@
+# Модуль: Основа вёрстки
+
+**Ответственность**: предоставляет общие design tokens, режимы выпуска и безопасные presentation-примитивы без правил конкретного вида документа.
+**Расположение**: `.template/lib/presentation/foundation.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `design-tokens()` | конструктор dictionary | Шрифты, размеры, цвета, интервалы и стандартные поля страницы |
+| `apply-foundation()` | show-функция | Применяет локальные общие text/par/page defaults к body renderer |
+| `render-media-slot()` | функция | Отображает ресурс по policy `hide`, `placeholder`, `reserve-space` |
+| `render-mode()` | чистая функция | Нормализует `final`, `draft`, `clean-copy` |
+| `watermark-layer()` | функция | Создаёт слой watermark без изменения domain данных |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/document.typ` | `RenderOptions` и режим выпуска |
+| `.template/lib/domain/company.typ` | Brand color и нормализованные ресурсы |
+
+## Инварианты
+
+- Foundation не знает, является документ отчётом, письмом, ТКП или договором.
+- Общие defaults локальны body, не протекают в следующий независимый renderer.
+- `render-media-slot()` никогда не вызывает `image(none)`.
+- Размер placeholder и reserve-space задаётся вызывающим компонентом, чтобы не ломать профильную геометрию.
+- Watermark не влияет на layout flow и счётчики.
+- Внутри module scope не создаётся state, общий для нескольких документов.
+
+## Намеренно НЕ обрабатывает
+
+- Титульный лист отчёта.
+- Шапку письма и реквизиты договора.
+- Нумерацию приложений конкретного профиля.
+- Загрузку файлов и JSON.
+
+## Заметки для агента
+
+> Typst 0.15 изменил baseline у `box` и `block`; любые правки foundation требуют visual regression всех профилей. Не переносите сюда profile-specific отступ только потому, что он встречается в двух документах: сначала проверьте, является ли это действительно общим design token.

+ 43 - 0
.template/development/modules/presentation-letter.md

@@ -0,0 +1,43 @@
+# Модуль: Деловое письмо
+
+**Ответственность**: формирует исходящее деловое письмо с адресатом, регистрационными данными, темой, основным текстом и подписанием.
+**Расположение**: `.template/lib/presentation/profiles/letter.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `letter-profile()` | конструктор `DocumentProfile` | Принимает дату, исходящий номер, адресата, тему и signing options |
+| `render-letter()` | renderer-функция | Собирает фирменную шапку, body, подпись и footer |
+| `letter-metadata()` | чистая функция | Нормализует регистрационные поля и тему |
+| `letter-validators()` | массив functions | Проверяет адресата, дату и подписанта |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/document.typ` | `DocumentProfile` и render options |
+| `.template/lib/domain/parties.typ` | Recipient и Signer |
+| `.template/lib/domain/attachments.typ` | Перечень приложений к письму |
+| `.template/lib/presentation/foundation.typ` | Общие tokens и media policy |
+| `.template/lib/presentation/components.typ` | Letter header, company footer, attachment list, signature block |
+
+## Инварианты
+
+- Letter profile не содержит коммерческой стоимости, графика работ или offer validity.
+- Recipient может быть частично заполнен, но validator требует хотя бы организацию или ФИО.
+- Исходящий номер и дата отображаются единым регистрационным блоком.
+- Footer включается profile option и не исчезает из-за состояния приложения другого документа.
+- Подпись, печать и место для ручного подписания obey media policy.
+- Renderer не импортирует report или commercial-offer profile.
+
+## Намеренно НЕ обрабатывает
+
+- Коммерческие условия ТКП.
+- Титул и содержание отчёта.
+- Разделы договора.
+- Регистрацию письма во внешней системе.
+
+## Заметки для агента
+
+> Внешний проект TKP содержит полезный визуальный референс шапки и footer, но его global `comp-data` и `in-appendix` не переносятся. Состояние footer, если оно понадобится, должно принадлежать только текущему renderer и сбрасываться внутри него. Общая геометрия письма выносится в components, чтобы ТКП переиспользовал её без импорта `render-letter()`.

+ 46 - 0
.template/development/modules/presentation-report.md

@@ -0,0 +1,46 @@
+# Модуль: Отчёт
+
+**Ответственность**: реализует профиль научно-технического отчёта как стартовую заготовку, не влияя на деловые документы.
+**Расположение**: `.template/lib/presentation/profiles/report.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `report-profile()` | конструктор `DocumentProfile` | Принимает метаданные отчёта и безопасные defaults |
+| `render-report()` | renderer-функция | Собирает титул, служебные страницы, body, библиографии и приложения |
+| `report-metadata()` | чистая функция | Нормализует название, тему, договор, этап, том, год и режим исследования |
+| `report-validators()` | массив functions | Проверяет обязательные поля и совместимость опций |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `.template/lib/domain/document.typ` | `DocumentProfile`, `DocumentContext` |
+| `.template/lib/domain/parties.typ` | Approval и список исполнителей |
+| `.template/lib/domain/attachments.typ` | Приложения отчёта |
+| `.template/lib/presentation/foundation.typ` | Общую страницу, typography и render mode |
+| `.template/lib/presentation/components.typ` | Title primitives, signature rows, tables, formula |
+| `.template/lib/presentation/references.typ` | Cross-references и bibliography sections |
+| `.template/lib/shared/numbering.typ` | Нумерацию глав, фигур, формул и приложений |
+
+## Инварианты
+
+- Все report-specific `set/show/state` локальны `render-report()`.
+- Титульный лист, список исполнителей и содержание включаются profile options, а не наличием случайного поля.
+- `paper/clean-copy` не загружает изображения подписей и печатей при соответствующей media policy.
+- Counters фигур, таблиц и формул сбрасываются только в границах отчётной главы.
+- Приложения используют собственную стратегию numbering без изменения других profiles.
+- Bibliographies представлены массивом секций; renderer не ограничивает их количество одним `refs.bib`.
+- Пользовательский body не изменяется и размещается после служебных страниц.
+
+## Намеренно НЕ обрабатывает
+
+- Адресата исходящего письма.
+- Стоимость и срок действия ТКП.
+- Стороны и реквизиты договора.
+- Загрузку компании и пользовательских файлов.
+
+## Заметки для агента
+
+> Legacy renderer в `.template/lib/report.typ` является функциональной спецификацией, но его API можно менять. Переносите секции по одной и после каждой сравнивайте snapshots. Сохраните проверенные решения вокруг heading gaps, paragraph indent, continuation tables и executor signatures, пока тест не докажет, что упрощение безопасно.

+ 33 - 0
.template/development/modules/private-assets.md

@@ -0,0 +1,33 @@
+# Модуль: Приватные ресурсы и сотрудники
+
+**Ответственность**: подключает игнорируемые Git подписи и печати через один переключатель, хранит публичный справочник сотрудников и оставляет пустое место при недоступной подписи.  
+**Расположение**: `.template/lib/infrastructure/employees.typ`, `.template/lib/assets/placeholders/`, `.private/`, `docs/examples/private/settings.typ`
+
+## Публичный интерфейс
+
+| Элемент | Назначение |
+|---------|------------|
+| `employee-directory` | ФИО, обычные должности и имена PNG |
+| `empty-private-settings` | Безопасная конфигурация без private paths |
+| `private-company-media()` | Возвращает подпись и печать выбранной организации либо `none` |
+| `report-executor()` | Строит legacy-compatible tuple исполнителя с необязательной подписью |
+| `.private/settings.typ` | Локальная доступность изображений и индивидуальные offsets |
+
+## Инварианты
+
+- При `use-private-assets = false` условный import не читает `.private/settings.typ`.
+- Включённая запись ссылается только на фиксированное имя из публичного справочника.
+- Отсутствующая или отключённая запись возвращает `none`, поэтому строка подписи остаётся пустой.
+- Реальные файлы не заменяют tracked placeholders.
+- Каталог `.private/` целиком игнорируется Git.
+- Обычная сборка и private-сборка используют одну VS Code task.
+
+## Намеренно НЕ обрабатывает
+
+- Доставку и шифрование папки `.private`.
+- Проверку подлинности подписи.
+- Автоматическую проверку наличия файла внутри Typst 0.15.
+
+## Заметки для агента
+
+> Typst не предоставляет безопасный file-exists. Поэтому при ещё не полученной подписи запись должна отсутствовать или иметь `enabled: false`. При добавлении сотрудника синхронно обновите directory, безопасный пример и публичную таблицу.

+ 48 - 0
.template/development/modules/references.md

@@ -0,0 +1,48 @@
+# Модуль: Ссылки и библиографии
+
+**Ответственность**: предоставляет русскоязычные перекрёстные ссылки и модель одной или нескольких библиографических секций Typst 0.15.
+**Расположение**: `.template/lib/domain/references.typ`, `.template/lib/presentation/references.typ`
+
+## Публичный интерфейс
+
+| Символ | Тип | Описание |
+|--------|-----|----------|
+| `vref()` | contextual function | Форматирует одну ссылку с падежом для рисунка, таблицы, формулы или раздела |
+| `vrefs()` | contextual function | Форматирует массив однородных labels с корректным соединением |
+| `eqref()` | contextual function | Создаёт ссылку на формулу без лишнего supplement |
+| `bibliography-section()` | domain constructor | Описывает sources, title, style, target, group и политику переноса |
+| `render-bibliographies()` | function | Размещает произвольное число секций через нативный `bibliography()` |
+| `validate-bibliographies()` | function | Проверяет ids, sources, target/group и наличие default coverage policy |
+
+`vref()` и `vrefs()` по умолчанию используют предложный падеж и прописную первую букву названия объекта. Короткие коды — `"и"`, `"р"`, `"д"`, `"в"`, `"т"`, `"п"`; прежние сокращения и полные русские названия нормализуются во внутренние ключи `"имен"`, `"род"`, `"дат"`, `"вин"`, `"тв"`, `"предл"`. Строчная форма включается явно через `capitalized: false`.
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| Typst `query`, `selector`, `ref`, `cite` | Разрешение labels и citations |
+| Typst 0.15 `bibliography(target:, group:)` | Несколько списков источников и управление нумерацией |
+| `.template/lib/domain/document.typ` | Profile metadata и render options |
+
+## Инварианты
+
+- Cross-reference label и bibliography citation являются разными типами использования и не смешиваются в одной функции.
+- `vref` всегда обрабатывает состояние `element == none`, потому что Typst может ещё не обнаружить элемент.
+- `vref` поддерживает `имен`, `род`, `дат`, `вин`, `тв`, `предл`; неизвестный падеж является ошибкой, а не тихим fallback.
+- `vrefs` объединяет однородные labels под одной формой множественного числа (`рисунках 1 и 2`), смешанные типы форматирует поэлементно.
+- Каждая bibliography section имеет стабильный id и хотя бы один source.
+- Последующие секции по умолчанию начинаются с новой страницы (`page_break: true`), поэтому их заголовки и записи не могут наложиться; компактный режим включается явно.
+- `target` передаётся как `auto`, label, selector, location или function без преобразования в строку.
+- `group: none` сбрасывает нумерацию секции; одинаковая строковая группа продолжает нумерацию.
+- Если используется несколько sections, fixture обязан доказать, что каждая citation попала ровно в одну bibliography.
+
+## Намеренно НЕ обрабатывает
+
+- Редактирование `.bib` и Hayagriva-файлов.
+- Поиск научных источников.
+- Ссылки на внешние URL без label.
+- Автоматическое определение падежа по окружающему тексту.
+
+## Заметки для агента
+
+> Typst 0.15 нативно распределяет citations между несколькими bibliographies. Не воспроизводите этот алгоритм вручную. Domain хранит настройки, presentation вызывает built-in и смещает уровень заголовка библиографии по официальной схеме. Добавьте fixtures для поглавных, тематических, общей и раздельной нумерации.

+ 42 - 0
.template/development/modules/starter-packs.md

@@ -0,0 +1,42 @@
+# Модуль: Публичные примеры документов
+
+**Ответственность**: хранит исполняемую документацию четырёх профилей и источник безопасной установки выбранного типа.  
+**Расположение**: `docs/examples/`
+
+## Публичный интерфейс
+
+| Каталог | Назначение |
+|---------|------------|
+| `documents/report/` | Полный отчёт с главами, bibliography и appendix |
+| `documents/letter/` | Деловое письмо |
+| `documents/commercial-offer/` | Технико-коммерческое предложение |
+| `documents/contract/` | Договор |
+| `formatting/` | Каталог рисунков, таблиц, формул, ссылок и списков |
+| `private/settings.typ` | Безопасный полный пример private settings без PNG |
+
+## Зависимости
+
+| Модуль | Что использует |
+|--------|----------------|
+| `/.template/lib/index.typ` | Только публичный фасад |
+| `use-starter.ps1` | Backup и копирование примера в корень |
+| Presentation profiles | Реальный layout каждого вида документа |
+
+## Инварианты
+
+- Каждый `main.typ` компилируется непосредственно из своего каталога.
+- Тот же каталог компилируется после копирования в корень.
+- Каждый пример явно показывает `company-id`, `document-mode`, `use-private-assets`, profile metadata и media policy.
+- Public example не зависит от `.template/development/`.
+- Formatting guide содержит пояснения рядом с копируемыми блоками.
+- Выбор типа создаёт backup до замены пользовательских файлов.
+
+## Намеренно НЕ обрабатывает
+
+- Обратную совместимость со старыми starters.
+- Синхронизацию уже изменённого примера с пользовательским fork.
+- Юридическую корректность демонстрационного договора.
+
+## Заметки для агента
+
+> Не создавайте отдельную showcase-копию: публичный пример должен одновременно быть тестовым fixture и источником установки. Изменение API считается завершённым только после обновления всех четырёх `main.typ` и formatting guide.

+ 44 - 0
.template/development/modules/testing.md

@@ -0,0 +1,44 @@
+# Модуль: Тестирование
+
+**Ответственность**: защищает слои библиотеки, единственный root entrypoint, public examples, PDF semantics и визуальную стабильность.  
+**Расположение**: `.template/development/tests/`
+
+## Публичный интерфейс
+
+| Символ или файл | Тип | Описание |
+|-----------------|-----|----------|
+| `run-tests.py` | CLI | Полный release gate |
+| `manifest.json` | fixture manifest | Compile, semantic, negative и visual expectations |
+| `check_architecture()` | static check | Направление Typst imports |
+| `check_workspace()` | static check | Root surface, docs, VS Code, private settings и public examples |
+| `snapshots/` | visual baseline | Утверждённые страницы стабильных renderer fixtures |
+
+## Зависимости
+
+| Компонент | Что использует |
+|-----------|----------------|
+| Typst 0.15.1+ | Компиляция и PDF generation |
+| Poppler | Text extraction, links и PNG rendering |
+| Pillow | Pixel diff snapshots |
+| `docs/examples/` | Public executable documentation |
+
+## Инварианты
+
+- Корень не содержит `document.typ`, `draft.typ` и `clean-copy.typ`.
+- `docs/` видим и содержит обязательную public navigation.
+- Все четыре документа и formatting guide компилируются.
+- Generic profile fixtures проверяют `final`, `draft` и `clean-copy` без root entrypoint duplication.
+- Безопасный private settings example содержит всех сотрудников с отключёнными PNG.
+- Unit tests проверяют public directory, private offsets и отсутствие подписи.
+- Public company profiles не содержат настоящих sign/stamp.
+- Snapshot изменяется только после ручного visual review.
+
+## Намеренно НЕ обрабатывает
+
+- Юридическую корректность договоров.
+- Инженерную достоверность example values.
+- Установку сторонних extensions.
+
+## Заметки для агента
+
+> Public example является production-facing documentation: его compile failure блокирует выпуск так же, как regression renderer. Для layout changes сначала просмотрите rendered pages, затем осознанно обновите snapshots.

+ 38 - 0
.template/development/modules/user-documentation.md

@@ -0,0 +1,38 @@
+# Модуль: Публичная документация
+
+**Ответственность**: проводит автора без опыта программирования от открытия fork до проверенного PDF.  
+**Расположение**: `README.md`, `docs/`
+
+## Публичный интерфейс
+
+| Документ | Назначение |
+|----------|------------|
+| `README.md` | Краткий обязательный маршрут и все важные ссылки |
+| `docs/README.md` | Полная пользовательская навигация |
+| `docs/documents.md` | Один main, режимы, профили, главы и assets |
+| `docs/formatting.md` | Таблицы, рисунки, формулы и ссылки |
+| `docs/examples/` | Компилируемые исходники |
+| `docs/vscode.md` | Расширения, preview и tasks |
+| `docs/git.md` | Совместная работа простыми словами |
+| `docs/private-assets.md` | Копирование `.private`, один переключатель, роли и offsets |
+| `docs/writing-style.md` | Заготовка для редактирования технического текста |
+| `docs/troubleshooting.md` | Диагностика типовых проблем |
+
+## Инварианты
+
+- `docs/` виден в Explorer и находится в корне.
+- Все обязательные сценарии доступны из root README.
+- Каждый Typst-фрагмент с нетривиальным API имеет компилируемую версию.
+- Термины Git объясняются бытовыми аналогиями.
+- Developer details остаются в `.template/development/`.
+- Документация не обещает автоматическую установку внутренних VSIX.
+
+## Намеренно НЕ обрабатывает
+
+- DDD architecture и migration internals.
+- Обучение программированию.
+- Администрирование Git-сервера.
+
+## Заметки для агента
+
+> Любое новое пользовательское действие сначала появляется в root README, затем раскрывается в `docs/`. Проверяйте относительные Markdown links и компиляцию public examples автоматически.

+ 34 - 0
.template/development/modules/vscode-workspace.md

@@ -0,0 +1,34 @@
+# Модуль: Рабочая область VS Code
+
+**Ответственность**: синхронизирует воспроизводимую среду автора и предоставляет три понятные задачи.  
+**Расположение**: `.vscode/`, `.template/development/tools/`
+
+## Публичный интерфейс
+
+| Файл или задача | Назначение |
+|-----------------|------------|
+| `extensions.json` | Десять согласованных recommendations |
+| `settings.json` | Auto Save, spellcheck, TODO, скрытие служебных каталогов |
+| `Scientia: собрать PDF` | Компиляция текущего `main.typ` |
+| `Scientia: выбрать тип документа` | Backup и установка public example |
+| `Scientia: собрать учебный пример` | Компиляция выбранного исходника из `docs/examples/` |
+
+## Инварианты
+
+- `.vscode/` включён в Git, но скрыт в Explorer.
+- `docs/` никогда не скрыт.
+- Typewriter navigator содержит только `main.typ`.
+- Автосохранение не создаёт commit и не выполняет push.
+- Tasks не хранят приватные значения в tracked settings.
+- Одна build task собирает и public-, и private-режим согласно `main.typ`.
+- Внутренние VSIX не входят в репозиторий.
+
+## Намеренно НЕ обрабатывает
+
+- Установку VS Code, Git и Typst.
+- Авторизацию на Git-сервере.
+- Публикацию внутренних VSIX.
+
+## Заметки для агента
+
+> Не добавляйте отдельную build task для каждого режима: пользователь меняет `document-mode` в `main.typ`. Новая задача должна либо быть частой, либо существенно снижать риск потери данных.

+ 67 - 0
.template/development/tests/domain/core.typ

@@ -0,0 +1,67 @@
+#import "/.template/lib/domain/document.typ": render-options, document-profile, validate-profile-contract
+#import "/.template/lib/domain/attachments.typ": attachment, attachment-set
+#import "/.template/lib/domain/references.typ": bibliography-section, validate-bibliographies
+#import "/.template/lib/infrastructure/company-assets.typ": load-company, available-companies
+#import "/.template/lib/infrastructure/employees.typ": employee-directory, empty-private-settings, private-company-media, report-executor
+
+#let passthrough(body, ctx) = body
+
+#let run() = {
+  let options = render-options()
+  assert.eq(options.mode, "final")
+  assert.eq(options.at("media-policy"), "placeholder")
+
+  let profile = document-profile("unit", passthrough)
+  assert.eq(validate-profile-contract(profile).id, "unit")
+
+  assert.eq(available-companies().len(), 4)
+  for id in available-companies() {
+    let loaded = load-company(id)
+    assert.eq(loaded.kind, "company-profile")
+    assert(type(loaded.legal.name) == str)
+  }
+  let company = load-company("test-company")
+  assert.eq(company.legal.name, "ООО «Тестовая компания»")
+  assert.eq(type(company.resources.logo), path)
+
+  assert.eq(employee-directory.len(), 9)
+  let unsigned = report-executor("fedorov")
+  assert.eq(unsigned.at(1), "Федоров Д.А.")
+  assert.eq(unsigned.at(2), none)
+
+  let private-settings = (
+    companies: (scientia: (signature: true, stamp: false)),
+    signatures: (
+      musikhin: (enabled: true, offset: 1.25cm),
+      fedorov: (enabled: false, offset: 0.7cm),
+    ),
+  )
+  let signed = report-executor(
+    "musikhin",
+    role: "Управляющий директор",
+    private-settings: private-settings,
+  )
+  assert.eq(signed.at(0), "Управляющий директор")
+  assert.eq(type(signed.at(2)), path)
+  assert.eq(signed.at(3), 1.25cm)
+  assert.eq(report-executor("fedorov", private-settings: private-settings).at(2), none)
+  let media = private-company-media(private-settings, "scientia")
+  assert.eq(type(media.signature), path)
+  assert.eq(media.stamp, none)
+  assert.eq(private-company-media(empty-private-settings, "scientia").signature, none)
+
+  let item = attachment("a", "Приложение", [Текст])
+  let items = attachment-set(items: (item,), numbering: "cyrillic")
+  assert.eq(items.items.len(), 1)
+
+  let bibliography = bibliography-section("main", path("/assets/references.bib"))
+  assert.eq(validate-bibliographies((bibliography,)).len(), 1)
+  assert.eq(bibliography.at("page-break"), true)
+  let compact-bibliography = bibliography-section(
+    "compact",
+    path("/assets/references.bib"),
+    page_break: false,
+  )
+  assert.eq(compact-bibliography.at("page-break"), false)
+  "ok"
+}

+ 12 - 0
.template/development/tests/domain/legacy-numbering.typ

@@ -0,0 +1,12 @@
+#import "/.template/lib/numbering.typ": cyrillic_numbering, num_11, num_1a, num_1_a
+
+#let run() = {
+  assert.eq(cyrillic_numbering(1), "А")
+  assert.eq(cyrillic_numbering(8), "И")
+  assert.eq(cyrillic_numbering(25), "Я")
+  assert.eq(cyrillic_numbering(26), "26")
+  assert.eq(num_11(1, 2, 3), "1.2.3.")
+  assert.eq(num_1a(1, 2, 3), "1.б.3.")
+  assert.eq(num_1_a(1, 2), "б.")
+  "ok"
+}

+ 46 - 0
.template/development/tests/domain/profiles.typ

@@ -0,0 +1,46 @@
+#import "/.template/lib/domain/parties.typ": recipient, party, signer
+#import "/.template/lib/presentation/profiles/index.typ" as profiles
+#import "/.template/lib/shared/numbering.typ": attachment_numbering
+
+#let run() = {
+  let addressee = recipient(company: "ООО «Адресат»")
+
+  let letter = profiles.letter(recipient: addressee)
+  assert.eq(letter.id, "letter")
+  assert.eq(((letter.validate)(letter.metadata)).recipient.kind, "recipient")
+
+  let offer = profiles.commercial_offer(
+    recipient: addressee,
+    subject: "Предмет",
+    amount: "100",
+  )
+  assert.eq(offer.id, "commercial-offer")
+  assert.eq(((offer.validate)(offer.metadata)).terms.amount, "100")
+
+  let left = party(
+    "left",
+    "Исполнитель",
+    "ООО «Исполнитель»",
+    representative: signer("И.И. Исполнитель", "Директор"),
+  )
+  let right = party(
+    "right",
+    "Заказчик",
+    "ООО «Заказчик»",
+    representative: signer("З.З. Заказчик", "Директор"),
+  )
+  let contract = profiles.contract(
+    number: "1",
+    parties: (left, right),
+    sections: (
+      profiles.contract_section("subject", "Предмет", [Текст раздела]),
+    ),
+  )
+  assert.eq(contract.id, "contract")
+  assert.eq(((contract.validate)(contract.metadata)).sections.len(), 1)
+
+  assert.eq(attachment_numbering("arabic", 2), "2")
+  assert.eq(attachment_numbering("cyrillic", 2), "Б")
+  assert.eq(attachment_numbering("none", 2), "")
+  "ok"
+}

+ 49 - 0
.template/development/tests/fixtures/commercial-offer/main.typ

@@ -0,0 +1,49 @@
+#import "/.template/lib/api.typ": document, recipient, attachment, attachment-set
+#import "/.template/lib/presentation/profiles/commercial-offer.typ": commercial-offer-profile
+
+#let addressee = recipient(
+  company: "АО «Заказчик»",
+  title: "Руководителю службы автоматизации",
+  name: "С.С. Заказчикову",
+)
+
+#let appendices = attachment-set(items: (
+  attachment(
+    "scope",
+    "Техническое задание",
+    [
+      Поставка, настройка и ввод в эксплуатацию тестового комплекса.
+    ],
+  ),
+))
+
+#show: document.with(
+  company: "test-company",
+  profile: commercial-offer-profile(
+    recipient: addressee,
+    subject: "Поставка и внедрение тестового комплекса",
+    amount: "1 250 000",
+    currency: "руб.",
+    tax_note: "включая НДС 20 %",
+    delivery_term: "45 рабочих дней",
+    validity: "30 календарных дней",
+    payment_terms: "30 % аванс, 70 % после приёмки",
+    date: "26.08.2026",
+    reference: "ТКП-001",
+    note: "Контактное лицо: А.А. Автор",
+    attachments: appendices,
+  ),
+)
+
+Уважаемый Сергей Сергеевич!
+
+Предлагаем выполнить комплекс работ по поставке и внедрению оборудования.
+
+== Состав работ
+
+- обследование объекта;
+- поставка оборудования;
+- монтаж и пусконаладка;
+- обучение персонала заказчика.
+
+Гарантийный срок составляет 12 месяцев с даты подписания акта приёмки.

+ 85 - 0
.template/development/tests/fixtures/contract/main.typ

@@ -0,0 +1,85 @@
+#import "/.template/lib/api.typ": document, party, signer, attachment, attachment-set, load-company
+#import "/.template/lib/presentation/profiles/contract.typ": contract-profile, contract-section
+
+#let company = load-company("test-company")
+
+#let contractor = party(
+  "contractor",
+  "Исполнитель",
+  company.legal.name,
+  legal: company.legal,
+  contacts: company.contacts,
+  banking: company.banking,
+  representative: signer(
+    company.director.name,
+    company.director.title,
+    basis: "Устава",
+    signature: company.resources.signature,
+    stamp: company.resources.stamp,
+  ),
+)
+
+#let customer = party(
+  "customer",
+  "Заказчик",
+  "ООО «Заказчик»",
+  legal: (
+    inn: "1111111111",
+    kpp: "111111111",
+    address: "г. Пример, ул. Договорная, 2",
+  ),
+  contacts: (email: "customer@example.invalid"),
+  banking: (
+    bank: "Банк заказчика",
+    account: "11111111111111111111",
+    bik: "111111111",
+  ),
+  representative: signer(
+    "П.П. Заказчиков",
+    "Генеральный директор",
+    basis: "Устава",
+  ),
+)
+
+#let sections = (
+  contract-section(
+    "subject",
+    "Предмет договора",
+    [Исполнитель обязуется выполнить работы, а Заказчик — принять и оплатить их на условиях настоящего договора.],
+  ),
+  contract-section(
+    "price",
+    "Цена и порядок расчётов",
+    [Цена договора составляет 1 250 000 рублей, включая НДС 20 %.],
+  ),
+  contract-section(
+    "liability",
+    "Ответственность сторон",
+    [Стороны несут ответственность в соответствии с законодательством Российской Федерации.],
+  ),
+)
+
+#let appendices = attachment-set(items: (
+  attachment(
+    "specification",
+    "Спецификация",
+    [
+      Перечень работ и оборудования согласуется сторонами в настоящем приложении.
+    ],
+  ),
+))
+
+#show: document.with(
+  company: "test-company",
+  profile: contract-profile(
+    number: "Д-001/2026",
+    date: "26 августа 2026 г.",
+    place: "г. Пример",
+    title: "Договор оказания услуг",
+    parties: (contractor, customer),
+    sections: sections,
+    attachments: appendices,
+  ),
+)
+
+Настоящий договор вступает в силу с момента подписания обеими сторонами.

+ 26 - 0
.template/development/tests/fixtures/core-smoke/main.typ

@@ -0,0 +1,26 @@
+#import "/.template/lib/api.typ": document, document-profile
+
+#let smoke-renderer(body, ctx) = [
+  #set text(font: "Arial", size: 11pt, lang: "ru")
+  #set page(paper: "a4")
+  #text(weight: "bold")[DDD CORE SMOKE]
+
+  Компания: #ctx.company.legal.name
+
+  Профиль: #ctx.at("profile-id")
+
+  #body
+]
+
+#let smoke-profile = document-profile(
+  "smoke",
+  smoke-renderer,
+  metadata: (:),
+)
+
+#show: document.with(
+  company: "test-company",
+  profile: smoke-profile,
+)
+
+Изолированное ядро успешно собрано.

+ 7 - 0
.template/development/tests/fixtures/default-report/main.typ

@@ -0,0 +1,7 @@
+#import "/.template/lib/index.typ": document
+
+#show: document.with(company: "test-company")
+
+#heading(numbering: none)[ПРОВЕРКА ПРОФИЛЯ ПО УМОЛЧАНИЮ]
+
+Корневой фасад формирует отчёт без явной передачи profile.

+ 13 - 0
.template/development/tests/fixtures/facade-smoke/main.typ

@@ -0,0 +1,13 @@
+#import "/.template/lib/index.typ": document, profiles, recipient
+
+#show: document.with(
+  company: "test-company",
+  profile: profiles.letter(
+    recipient: recipient(company: "ООО «Проверка фасада»"),
+    title: "Публичный фасад",
+    show_stamp: false,
+  ),
+  options: (mode: "clean-copy", media-policy: "hide"),
+)
+
+Корневой файл экспортирует единый фасад и namespace профилей.

+ 38 - 0
.template/development/tests/fixtures/letter/main.typ

@@ -0,0 +1,38 @@
+#import "/.template/lib/api.typ": document, recipient, attachment, attachment-set
+#import "/.template/lib/presentation/profiles/letter.typ": letter-profile
+
+#let addressee = recipient(
+  company: "ООО «Получатель»",
+  title: "Генеральному директору",
+  name: "П.П. Получателю",
+  address: "г. Пример, ул. Адресная, 10",
+)
+
+#let appendices = attachment-set(items: (
+  attachment(
+    "specification",
+    "Краткая спецификация",
+    [
+      Состав приложения задаётся пользователем независимо от профиля письма.
+    ],
+  ),
+))
+
+#show: document.with(
+  company: "test-company",
+  profile: letter-profile(
+    recipient: addressee,
+    date: "26.08.2026",
+    reference: "Л-001",
+    title: "О направлении материалов",
+    note: "Исполнитель: А.А. Автор, +7 (000) 000-00-01",
+    attachments: appendices,
+    render_attachments: true,
+  ),
+)
+
+Уважаемый Пётр Петрович!
+
+Направляем материалы для рассмотрения. Профиль письма использует единый источник данных компании и самостоятельно формирует шапку, подвал, список приложений и блок подписи.
+
+Просим подтвердить получение документов.

+ 90 - 0
.template/development/tests/fixtures/list-counters/main.typ

@@ -0,0 +1,90 @@
+#import "/.template/lib/index.typ": numbered-list
+
+#set page("a4", margin: 1cm)
+#set text(size: 9pt)
+#set par(first-line-indent: 0pt, leading: 0.4em)
+
+= SCIENTIA LIST COUNTERS
+
+#let compact-list(style, body) = numbered-list(
+  levels: (style,),
+  suffixes: ".",
+  line-leading: 0.4em,
+  item-spacing: 0pt,
+  body,
+)
+
+== Арабские числа
+
+#compact-list("1")[
++ Арабские: первый
++ Арабские: второй
++ Арабские: третий
+]
+
+== Кириллица
+
+#grid(
+  columns: (1fr, 1fr),
+  gutter: 1cm,
+  [
+    #compact-list("А")[
+    + Кириллица верхняя: первый
+    + Кириллица верхняя: второй
+    + Кириллица верхняя: третий
+    ]
+  ],
+  [
+    #compact-list("а")[
+    + Кириллица нижняя: первый
+    + Кириллица нижняя: второй
+    + Кириллица нижняя: третий
+    ]
+  ],
+)
+
+== Латиница
+
+#grid(
+  columns: (1fr, 1fr),
+  gutter: 1cm,
+  [
+    #compact-list("A")[
+    + Латиница верхняя: первый
+    + Латиница верхняя: второй
+    + Латиница верхняя: третий
+    ]
+  ],
+  [
+    #compact-list("a")[
+    + Латиница нижняя: первый
+    + Латиница нижняя: второй
+    + Латиница нижняя: третий
+    ]
+  ],
+)
+
+== Ведущий ноль
+
+#compact-list("01")[
++ Ведущий ноль: первый
++ Ведущий ноль: второй
++ Ведущий ноль: третий
+]
+
+== Вложенные счётчики
+
+#numbered-list(
+  levels: ("1", "а", "A"),
+  full: true,
+  separators: ".",
+  suffixes: ".",
+  line-leading: 0.4em,
+  item-spacing: 0pt,
+)[
++ Корневой уровень
+  + Второй уровень: первый
+  + Второй уровень: второй
+    + Третий уровень: первый
+    + Третий уровень: второй
+]

+ 60 - 0
.template/development/tests/fixtures/modes/main.typ

@@ -0,0 +1,60 @@
+#import "/.template/lib/index.typ": document, profiles, recipient, party, signer, load-company
+
+#let profile-id = sys.inputs.at("profile", default: "letter")
+#let mode = sys.inputs.at("mode", default: "draft")
+#let company-id = sys.inputs.at("company", default: "test-company")
+#let company = load-company(company-id)
+#let addressee = recipient(company: "ООО «Проверка режима»")
+
+#let left = party(
+  "left",
+  "Исполнитель",
+  company.legal.name,
+  legal: company.legal,
+  contacts: company.contacts,
+  banking: company.banking,
+  representative: signer(
+    company.director.name,
+    company.director.title,
+    signature: company.resources.signature,
+    stamp: company.resources.stamp,
+  ),
+)
+#let right = party(
+  "right",
+  "Заказчик",
+  "ООО «Проверка режима»",
+  representative: signer("П.П. Проверяющий", "Директор"),
+)
+
+#let selected-profile = if profile-id == "report" {
+  profiles.report(title: "Проверка режима выпуска", year: 2026)
+} else if profile-id == "letter" {
+  profiles.letter(recipient: addressee, title: "Проверка режима выпуска")
+} else if profile-id == "commercial-offer" {
+  profiles.commercial_offer(
+    recipient: addressee,
+    subject: "Проверка режима выпуска",
+    amount: "100 000",
+    currency: "руб.",
+  )
+} else if profile-id == "contract" {
+  profiles.contract(
+    number: "MODE-1",
+    date: "26.08.2026",
+    parties: (left, right),
+    sections: (
+      profiles.contract_section("subject", "Предмет договора", [Проверка режима выпуска.]),
+    ),
+  )
+} else {
+  panic("Неизвестный тестовый profile: " + profile-id)
+}
+
+#show: document.with(
+  company: company,
+  profile: selected-profile,
+  options: (mode: mode, watermark: if mode == "draft" { "ЧЕРНОВИК" } else { none }),
+)
+
+Документ собран в режиме #mode для профиля #profile-id.

+ 5 - 0
.template/development/tests/fixtures/negative-company/main.typ

@@ -0,0 +1,5 @@
+#import "/.template/lib/index.typ": document
+
+#show: document.with(company: "unknown-company")
+
+Этот текст не должен попасть в PDF.

+ 13 - 0
.template/development/tests/fixtures/negative-contract/main.typ

@@ -0,0 +1,13 @@
+#import "/.template/lib/index.typ": document, profiles, party
+
+#let duplicate = (
+  party("same", "Исполнитель", "ООО «Первый»"),
+  party("same", "Заказчик", "ООО «Второй»"),
+)
+
+#show: document.with(
+  company: "test-company",
+  profile: profiles.contract(number: "1", parties: duplicate),
+)
+
+Этот текст не должен попасть в PDF.

+ 8 - 0
.template/development/tests/fixtures/negative-letter/main.typ

@@ -0,0 +1,8 @@
+#import "/.template/lib/index.typ": document, profiles
+
+#show: document.with(
+  company: "test-company",
+  profile: profiles.letter(),
+)
+
+Этот текст не должен попасть в PDF.

+ 5 - 0
.template/development/tests/fixtures/negative-list-scheme/main.typ

@@ -0,0 +1,5 @@
+#import "/.template/lib/index.typ": numbered-list
+
+#numbered-list(scheme: "неизвестная-схема")[
++ Тестовый пункт
+]

+ 11 - 0
.template/development/tests/fixtures/negative-offer/main.typ

@@ -0,0 +1,11 @@
+#import "/.template/lib/index.typ": document, profiles, recipient
+
+#show: document.with(
+  company: "test-company",
+  profile: profiles.commercial_offer(
+    recipient: recipient(company: "ООО «Адресат»"),
+    subject: "Предложение без стоимости",
+  ),
+)
+
+Этот текст не должен попасть в PDF.

+ 3 - 0
.template/development/tests/fixtures/negative-reference-case/main.typ

@@ -0,0 +1,3 @@
+#import "/.template/lib/index.typ": vref
+
+#vref(<missing-reference>, grammatical-case: "мест")

+ 5 - 0
.template/development/tests/fixtures/negative-references/main.typ

@@ -0,0 +1,5 @@
+#import "/.template/lib/index.typ": vrefs
+
+#vrefs((<valid-label>, "not-a-label"))
+
+#figure(rect(width: 1cm, height: 1cm), caption: [Элемент]) <valid-label>

+ 39 - 0
.template/development/tests/fixtures/no-media/main.typ

@@ -0,0 +1,39 @@
+#import "/.template/lib/index.typ": document, profiles, recipient, company-profile
+
+#let policy = sys.inputs.at("policy", default: "placeholder")
+#let company = company-profile(
+  "no-media",
+  (
+    name: "ООО «Компания без встроенных графических ресурсов»",
+    short-name: "Без ресурсов",
+    jurisdiction: "RU",
+    inn: "0000000000",
+  ),
+  contacts: (
+    email: "no-media@example.invalid",
+    phone: "+7 (000) 000-00-00",
+    address: "г. Пример, очень длинный адрес для проверки переноса строк в деловом документе, дом 100, офис 200",
+  ),
+  banking: (:),
+  brand: (color: rgb("e39f49")),
+  director: (title: "Генеральный директор", name: "И.И. Подписант"),
+  resources: (logo: none, signature: none, stamp: none),
+)
+
+#show: document.with(
+  company: company,
+  profile: profiles.letter(
+    recipient: recipient(
+      company: "Акционерное общество «Организация с длинным наименованием для проверки устойчивости вёрстки»",
+      title: "Заместителю генерального директора по техническим и коммерческим вопросам",
+      name: "П.П. Получателю",
+      address: "г. Пример, проспект Испытательный, дом 123, строение 45",
+    ),
+    date: "26.08.2026",
+    reference: "NO-MEDIA-001",
+    title: "Проверка формирования документа без логотипа, подписи и печати",
+  ),
+  options: (mode: "final", media-policy: policy),
+)
+
+Документ без графических ресурсов собран с политикой #policy.

+ 49 - 0
.template/development/tests/fixtures/presentation-smoke/main.typ

@@ -0,0 +1,49 @@
+#import "/.template/lib/api.typ": load-company, recipient, signer
+#import "/.template/lib/presentation/foundation.typ": apply-foundation, render-media-slot
+#import "/.template/lib/presentation/components.typ": corp_table, company-footer, letter-header, signature-block
+
+#let company = load-company("test-company")
+#let addressee = recipient(
+  company: "ООО «Получатель»",
+  title: "Руководителю проекта",
+  name: "П.П. Примерову",
+)
+#let director = signer(
+  company.director.name,
+  company.director.title,
+  signature: company.resources.signature,
+  stamp: company.resources.stamp,
+)
+#let ctx = (
+  company: company,
+  options: (watermark: none),
+)
+
+#show: apply-foundation.with(
+  ctx: ctx,
+  page-options: (margin: (x: 2cm, y: 1.5cm)),
+)
+
+#letter-header(
+  company,
+  addressee,
+  date: "26.08.2026",
+  reference: "TEST-1",
+  title: "Проверка компонентов",
+)
+
+#v(0.8cm)
+Общая presentation-основа не зависит от конкретного профиля документа.
+
+#v(0.8cm)
+#corp_table(
+  columns: 2,
+  [Компонент], [Состояние],
+  [Таблица], [Работает],
+  [Media slot], [#render-media-slot(none, width: 3cm, height: 1cm)],
+)
+
+#v(1cm)
+#signature-block(director, company: company)
+
+#place(bottom + left, dy: 0.5cm, company-footer(company))

+ 49 - 0
.template/development/tests/fixtures/reference-cases/main.typ

@@ -0,0 +1,49 @@
+#import "/.template/lib/index.typ": vref, vrefs
+
+#set page(paper: "a4", margin: 15mm)
+#set text(lang: "ru", size: 9pt)
+#set heading(numbering: "1.")
+#set figure(numbering: "1")
+
+#figure(
+  rect(width: 12mm, height: 6mm),
+  caption: [Первый рисунок],
+) <case-figure-a>
+
+#figure(
+  rect(width: 12mm, height: 6mm),
+  caption: [Второй рисунок],
+) <case-figure-b>
+
+#figure(
+  table(columns: 1, [Значение]),
+  caption: [Тестовая таблица],
+) <case-table>
+
+#math.equation(block: true, numbering: "(1)")[$x = 1$] <case-equation>
+
+= Тестовый раздел <case-section>
+
+Предложный по умолчанию: на #vref(<case-figure-a>).
+
+Именительный коротко: #vref(<case-figure-a>, "и").
+
+Родительный коротко: без #vref(<case-figure-a>, "р").
+
+Дательный коротко: к #vref(<case-figure-a>, "д").
+
+Винительный коротко: вижу #vref(<case-figure-a>, "в").
+
+Творительный коротко: перед #vref(<case-figure-a>, "т").
+
+Предложный коротко: на #vref(<case-figure-a>, "п").
+
+Явная строчная форма: на #vref(<case-figure-a>, capitalized: false).
+
+Полное название: на #vref(<case-table>, "предложный").
+
+Формула: согласно #vref(<case-equation>, "дательный").
+
+Раздел: в #vref(<case-section>).
+
+Группа по умолчанию: на #vrefs((<case-figure-a>, <case-figure-b>)).

+ 71 - 0
.template/development/tests/fixtures/report-baseline/main.typ

@@ -0,0 +1,71 @@
+#import "/.template/lib/report.typ": report
+#import "/.template/lib/company.typ": company-defaults
+#import "/.template/lib/components.typ": corp_table, formula
+#import "/.template/lib/appendices.typ": make_appendices
+#import "/.template/lib/references.typ": vref, eqref
+
+#show: report.with(
+  ..company-defaults(company-id: "test-company"),
+  udk: "000.000",
+  director_date: "«26» августа 2026 г.",
+  is_research: true,
+  title: "Тестовый отчёт",
+  theme: "Регрессионная проверка шаблона",
+  is_intermediate: true,
+  stage_num: 1,
+  vol_num: 1,
+  contract_num: "TEST-001",
+  contract_date: "«26» августа 2026",
+  year: 2026,
+  executors: (
+    ("Ответственный исполнитель", "Тестов И.И.", path("/.template/companies/test-company/sign.svg"), 0.5cm),
+    ("Инженер", "Примеров П.П.", path("/.template/companies/test-company/sign.svg"), 0.5cm),
+  ),
+)
+
+#heading(numbering: none)[ВВЕДЕНИЕ]
+
+Этот документ фиксирует исходное поведение шаблона перед архитектурным рефакторингом.
+
+= ОСНОВНОЙ РАЗДЕЛ
+
+Сложные элементы должны сохранять нумерацию, подписи и ссылки.
+
+#figure(
+  image("/.template/companies/test-company/logo.svg", width: 35%),
+  caption: [Синтетический логотип для визуального теста],
+) <baseline-figure>
+
+Ссылка на рисунок: #vref(<baseline-figure>, grammatical-case: "вин").
+
+#figure(
+  corp_table(
+    columns: (1fr, 1fr, 1fr),
+    table.cell(rowspan: 2, align: center + horizon)[Параметр],
+    table.cell(colspan: 2, align: center + horizon)[Значения],
+    [Минимум], [Максимум],
+    [Тестовая строка], [10], [20],
+    [Вторая строка], [30], [40],
+  ),
+  caption: [Таблица с многострочной шапкой],
+) <baseline-table>
+
+Ссылка на таблицу: #vref(<baseline-table>, grammatical-case: "предл").
+
+#formula($a^2 + b^2 = c^2$) <baseline-equation>
+
+Ссылка на формулу: #eqref(<baseline-equation>).
+
+#pagebreak()
+#heading(numbering: none)[ЗАКЛЮЧЕНИЕ]
+
+Компиляция и визуальное сравнение подтверждают стабильность ключевых элементов.
+
+#pagebreak()
+#heading(numbering: none)[СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ]
+#bibliography("/assets/references.bib", title: none, style: "gost-r-705-2008-numeric")
+
+#show: make_appendices
+= ТЕСТОВОЕ ПРИЛОЖЕНИЕ
+
+Содержимое приложения используется для проверки кириллической нумерации.

+ 90 - 0
.template/development/tests/fixtures/report-new/main.typ

@@ -0,0 +1,90 @@
+#import "/.template/lib/api.typ": document, attachment, attachment-set, bibliography-section
+#import "/.template/lib/presentation/profiles/report.typ": report-profile
+#import "/.template/lib/presentation/components.typ": corp_table, formula
+#import "/.template/lib/presentation/references.typ": vref, vrefs, eqref
+
+#let appendices = attachment-set(
+  numbering: "cyrillic",
+  items: (
+    attachment(
+      "test-appendix",
+      "Тестовое приложение",
+      [Приложение создано через независимую доменную модель.],
+    ),
+  ),
+)
+
+#let bibliographies = (
+  bibliography-section(
+    "normative",
+    path("normative.bib"),
+    title: [НОРМАТИВНЫЕ ИСТОЧНИКИ],
+    group: "report-sources",
+  ),
+  bibliography-section(
+    "science",
+    path("science.bib"),
+    title: [НАУЧНЫЕ ИСТОЧНИКИ],
+    group: "report-sources",
+  ),
+)
+
+#show: document.with(
+  company: "test-company",
+  profile: report-profile(
+    title: "Новый модульный отчёт",
+    theme: "Проверка DDD-профиля",
+    udk: "000.000",
+    director_date: "«26» августа 2026 г.",
+    is_research: true,
+    is_intermediate: true,
+    stage_number: 1,
+    volume_number: 1,
+    contract_number: "DDD-001",
+    contract_date: "«26» августа 2026",
+    city: "Пример",
+    year: 2026,
+    executors: (
+      ("Ответственный исполнитель", "Тестов И.И.", path("/.template/companies/test-company/sign.svg"), 0.5cm),
+    ),
+    attachments: appendices,
+    bibliographies: bibliographies,
+  ),
+)
+
+#heading(numbering: none)[ВВЕДЕНИЕ]
+
+Профиль отчёта использует нормативный источник @normative-test и научную публикацию @science-test.
+
+= ПРОВЕРКА ССЫЛОК
+
+#figure(
+  rect(width: 3cm, height: 1.5cm, fill: rgb("fbb20d")),
+  caption: [Первый тестовый рисунок],
+) <new-figure-a>
+
+#figure(
+  circle(radius: 0.7cm, fill: rgb("e39f49")),
+  caption: [Второй тестовый рисунок],
+) <new-figure-b>
+
+Одна ссылка: #vref(<new-figure-a>, grammatical-case: "предл").
+
+Несколько ссылок: #vrefs((<new-figure-a>, <new-figure-b>), grammatical-case: "вин").
+
+#figure(
+  corp_table(
+    columns: 2,
+    [Параметр], [Значение],
+    [Архитектура], [Изолированная],
+  ),
+  caption: [Проверочная таблица],
+)
+
+#formula($x^2 + y^2 = z^2$) <new-equation>
+
+Формула: #eqref(<new-equation>).
+
+#heading(numbering: none)[ЗАКЛЮЧЕНИЕ]
+
+Новый отчёт собирается через `document` и `report-profile`.

+ 6 - 0
.template/development/tests/fixtures/report-new/normative.bib

@@ -0,0 +1,6 @@
+@book{normative-test,
+  title={Синтетический нормативный источник},
+  author={{Тестовый регулятор}},
+  year={2026},
+  publisher={Тестовое издательство}
+}

+ 8 - 0
.template/development/tests/fixtures/report-new/science.bib

@@ -0,0 +1,8 @@
+@article{science-test,
+  title={Синтетическое исследование устойчивости шаблонов},
+  author={Тестов, И. И.},
+  year={2026},
+  journal={Журнал тестовых данных},
+  volume={1},
+  pages={1--10}
+}

+ 136 - 0
.template/development/tests/fixtures/report-stress/main.typ

@@ -0,0 +1,136 @@
+#import "/.template/lib/index.typ": document, profiles, corp-table, formula, vref, vrefs, eqref
+
+#let stress-rows = range(1, 73).map(index => (
+  [#index],
+  [
+    #if index == 3 {
+      [Составной параметр:
+      - первый вложенный пункт;
+      - второй вложенный пункт с длинным пояснением внутри ячейки.]
+    } else {
+      [Параметр #index с достаточно длинным наименованием для проверки переноса строк]
+    }
+  ],
+  [
+    #if index == 4 {
+      [Значение #index. #link("https://example.invalid/table-cell")[Ссылка внутри ячейки] сохраняет кликабельность и не меняет отступы.]
+    } else {
+      [Значение #index. Текст ячейки переносится, но сохраняет одинаковые внутренние поля и межстрочный интервал.]
+    }
+  ],
+)).flatten()
+
+#show: document.with(
+  company: "test-company",
+  profile: profiles.report(
+    title: "Стресс-тест типографики",
+    theme: "Таблицы, списки, формулы и грамматические ссылки",
+    year: 2026,
+    executors: (),
+  ),
+)
+
+= КОНТЕКСТНЫЕ ИНТЕРВАЛЫ
+
+== Заголовок перед абзацем
+
+Обычный абзац после заголовка должен иметь устойчивую красную строку, нормативный межстрочный интервал и не зависеть от элемента, который находился перед заголовком.
+
+== Заголовок перед маркированным списком
+
+- Первый уровень маркированного списка;
+  - второй уровень с длинным текстом, который переносится на новую строку без смещения маркера;
+    - третий уровень.
+- Возврат на первый уровень.
+
+== Заголовок перед нумерованным списком
+
++ Основной пункт
+  + Второй уровень
+    + Третий уровень
++ Следующий основной пункт
+
+== Соседние заголовки
+=== Заголовок третьего уровня
+==== Заголовок четвёртого уровня
+
+Текст после цепочки заголовков.
+
+= ССЫЛКИ С ПАДЕЖАМИ <stress-section>
+
+#figure(
+  rect(width: 3cm, height: 1.3cm, fill: rgb("fbb20d")),
+  caption: [Первый контрольный рисунок],
+) <stress-figure-a>
+
+#figure(
+  circle(radius: 0.65cm, fill: rgb("e39f49")),
+  caption: [Второй контрольный рисунок],
+) <stress-figure-b>
+
+#formula($a^2 + b^2 = c^2$) <stress-equation>
+
+Именительный: #vref(<stress-figure-a>, grammatical-case: "имен").
+Родительный: без #vref(<stress-figure-a>, grammatical-case: "род").
+Дательный: к #vref(<stress-figure-a>, grammatical-case: "дат").
+Винительный: см. #vref(<stress-figure-a>, grammatical-case: "вин").
+Творительный: перед #vref(<stress-figure-a>, grammatical-case: "тв").
+Предложный: на #vref(<stress-figure-a>, grammatical-case: "предл").
+
+Групповая ссылка: на #vrefs(
+  (<stress-figure-a>, <stress-figure-b>),
+  grammatical-case: "предл",
+).
+
+Формула в дательном падеже: к #vref(<stress-equation>, grammatical-case: "дат"); короткая ссылка #eqref(<stress-equation>).
+
+Ссылка на раздел: в #vref(<stress-section>, grammatical-case: "предл").
+
+Внешняя ссылка: #link("https://example.invalid/specification")[контрольная спецификация].
+
+= МНОГОСТРАНИЧНАЯ ТАБЛИЦА
+
+#figure(
+  corp-table(
+    columns: (1.2cm, 1.7fr, 2.3fr),
+    header: (
+      table.cell(rowspan: 2, align: center + horizon)[№],
+      table.cell(colspan: 2, align: center + horizon)[Контрольные данные],
+      [Параметр],
+      [Описание и значение],
+    ),
+    body: stress-rows,
+    repeat_header: true,
+    continuation: true,
+    continuation_text: "Продолжение таблицы",
+    body-leading: 0.65em,
+    body-inset: (x: 4pt, y: 3pt),
+    justify: false,
+    hyphenate: false,
+  ),
+  caption: [Таблица, переходящая через несколько страниц],
+) <stress-table>
+
+= ПРОВЕРКА ПОСЛЕ ТАБЛИЦЫ
+
+Текст после таблицы не должен прилипать к последней строке. Ссылка на #vref(<stress-table>, grammatical-case: "вин") остаётся рабочей.
+
+#pagebreak()
+= ВЫСОКАЯ СТРОКА ТАБЛИЦЫ
+
+#let tall-cell = range(1, 66).map(index => [Строка высокой ячейки #index. Содержимое одной строки таблицы продолжается без потери границ.]).join(linebreak())
+
+#figure(
+  corp-table(
+    columns: (3cm, 1fr),
+    header: ([Идентификатор], [Высокая ячейка]),
+    body: ([ROW-ONE], tall-cell),
+    row_breakable: true,
+    page_break: "fit",
+    repeat_header: true,
+    continuation: true,
+    continuation_text: "Продолжение высокой таблицы",
+    justify: false,
+  ),
+  caption: [Одна высокая строка, переходящая через страницы],
+) <tall-row-table>

+ 453 - 0
.template/development/tests/manifest.json

@@ -0,0 +1,453 @@
+{
+  "version": 1,
+  "typst_min": "0.15.1",
+  "creation_timestamp": 1787702400,
+  "dpi": 110,
+  "visual_threshold": 0.001,
+  "unit_tests": [
+    {
+      "id": "legacy-numbering",
+      "module": ".template/development/tests/domain/legacy-numbering.typ"
+    },
+    {
+      "id": "core-domain",
+      "module": ".template/development/tests/domain/core.typ"
+    },
+    {
+      "id": "profile-domain",
+      "module": ".template/development/tests/domain/profiles.typ"
+    }
+  ],
+  "fixtures": [
+    {
+      "id": "core-smoke",
+      "entry": ".template/development/tests/fixtures/core-smoke/main.typ",
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": [
+        "DDD CORE SMOKE",
+        "ООО «Тестовая компания»",
+        "Изолированное ядро успешно собрано"
+      ],
+      "snapshot_pages": []
+    },
+    {
+      "id": "facade-smoke",
+      "entry": ".template/development/tests/fixtures/facade-smoke/main.typ",
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ПУБЛИЧНЫЙ ФАСАД",
+        "Корневой файл экспортирует единый фасад"
+      ],
+      "snapshot_pages": []
+    },
+    {
+      "id": "default-report",
+      "entry": ".template/development/tests/fixtures/default-report/main.typ",
+      "expected_pages": 3,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ПРОВЕРКА ПРОФИЛЯ ПО УМОЛЧАНИЮ",
+        "Корневой фасад формирует отчёт"
+      ],
+      "snapshot_pages": []
+    },
+    {
+      "id": "presentation-smoke",
+      "entry": ".template/development/tests/fixtures/presentation-smoke/main.typ",
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ПРОВЕРКА КОМПОНЕНТОВ",
+        "Общая presentation-основа",
+        "Media slot"
+      ],
+      "snapshot_pages": [1]
+    },
+    {
+      "id": "report-new",
+      "entry": ".template/development/tests/fixtures/report-new/main.typ",
+      "expected_pages": 7,
+      "expected_page_size": "A4",
+      "required_text": [
+        "Проверка DDD-профиля",
+        "НОРМАТИВНЫЕ ИСТОЧНИКИ",
+        "НАУЧНЫЕ ИСТОЧНИКИ",
+        "Приложение А"
+      ],
+      "snapshot_pages": [1, 2, 5, 6]
+    },
+    {
+      "id": "report-stress",
+      "entry": ".template/development/tests/fixtures/report-stress/main.typ",
+      "expected_pages": 12,
+      "expected_page_size": "A4",
+      "required_text": [
+        "Таблицы, списки, формулы и грамматические ссылки",
+        "1. Основной пункт",
+        "а) Второй уровень",
+        "1) Третий уровень",
+        "на Рисунках 2.1 и 2.2",
+        "контрольная спецификация",
+        "Продолжение таблицы 3.1",
+        "Ссылка на Таблицу 3.1",
+        "Продолжение высокой таблицы 5.1",
+        "Строка высокой ячейки 65."
+      ],
+      "required_text_counts": {
+        "Контрольные данные": 6,
+        "Продолжение таблицы 3.1": 5,
+        "Высокая ячейка": 3,
+        "Продолжение высокой таблицы 5.1": 2
+      },
+      "minimum_external_links": 2,
+      "minimum_internal_links": 8,
+      "snapshot_pages": [3, 4, 5, 9, 10, 11, 12]
+    },
+    {
+      "id": "reference-cases",
+      "entry": ".template/development/tests/fixtures/reference-cases/main.typ",
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": [
+        "Предложный по умолчанию: на Рисунке",
+        "Именительный коротко: Рисунок",
+        "Родительный коротко: без Рисунка",
+        "Дательный коротко: к Рисунку",
+        "Винительный коротко: вижу Рисунок",
+        "Творительный коротко: перед Рисунком",
+        "Предложный коротко: на Рисунке",
+        "Явная строчная форма: на рисунке",
+        "Полное название: на Таблице",
+        "Формула: согласно Формуле",
+        "Раздел: в Разделе",
+        "Группа по умолчанию: на Рисунках"
+      ],
+      "snapshot_pages": []
+    },
+    {
+      "id": "letter",
+      "entry": ".template/development/tests/fixtures/letter/main.typ",
+      "expected_pages": 2,
+      "expected_page_size": "A4",
+      "required_text": [
+        "О НАПРАВЛЕНИИ МАТЕРИАЛОВ",
+        "Приложение 1: Краткая спецификация",
+        "КРАТКАЯ СПЕЦИФИКАЦИЯ"
+      ],
+      "snapshot_pages": [1, 2]
+    },
+    {
+      "id": "commercial-offer",
+      "entry": ".template/development/tests/fixtures/commercial-offer/main.typ",
+      "expected_pages": 2,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ТЕХНИКО-КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ",
+        "1 250 000 руб.",
+        "ТЕХНИЧЕСКОЕ ЗАДАНИЕ"
+      ],
+      "snapshot_pages": [1, 2]
+    },
+    {
+      "id": "contract",
+      "entry": ".template/development/tests/fixtures/contract/main.typ",
+      "expected_pages": 3,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ДОГОВОР ОКАЗАНИЯ УСЛУГ",
+        "РЕКВИЗИТЫ И ПОДПИСИ СТОРОН",
+        "СПЕЦИФИКАЦИЯ"
+      ],
+      "snapshot_pages": [1, 2, 3]
+    },
+    {
+      "id": "root-report",
+      "entry": "main.typ",
+      "expected_pages": 0,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ГЕОМЕХАНИЧЕСКОЕ ОБОСНОВАНИЕ УСТОЙЧИВОСТИ БОРТОВ",
+        "СПИСОК ИСПОЛНИТЕЛЕЙ",
+        "Результаты проверочных расчётов",
+        "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
+        "Приложение А. Исходные данные"
+      ],
+      "snapshot_pages": []
+    },
+    {
+      "id": "public-report",
+      "entry": "docs/examples/documents/report/main.typ",
+      "expected_pages": 0,
+      "expected_page_size": "A4",
+      "required_text": ["Результаты проверочных расчётов", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "public-letter",
+      "entry": "docs/examples/documents/letter/main.typ",
+      "expected_pages": 0,
+      "expected_page_size": "A4",
+      "required_text": ["О НАПРАВЛЕНИИ МАТЕРИАЛОВ ЭТАПА 1", "Уважаемый Иван Иванович"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "public-commercial-offer",
+      "entry": "docs/examples/documents/commercial-offer/main.typ",
+      "expected_pages": 0,
+      "expected_page_size": "A4",
+      "required_text": ["Геомеханическое сопровождение горных работ", "Состав и результат работ", "ТЕХНИЧЕСКОЕ ЗАДАНИЕ"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "public-contract",
+      "entry": "docs/examples/documents/contract/main.typ",
+      "expected_pages": 0,
+      "expected_page_size": "A4",
+      "required_text": ["ДОГОВОР ОКАЗАНИЯ УСЛУГ", "ОТВЕТСТВЕННОСТЬ СТОРОН", "ТЕХНИЧЕСКОЕ ЗАДАНИЕ"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "report-draft",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "report", "mode": "draft"},
+      "expected_pages": 3,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме draft"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "report-clean-copy",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "report", "mode": "clean-copy"},
+      "expected_pages": 3,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме clean-copy"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "letter-draft",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "letter", "mode": "draft"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме draft"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "letter-clean-copy",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "letter", "mode": "clean-copy"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме clean-copy"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "commercial-offer-draft",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "commercial-offer", "mode": "draft"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме draft"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "commercial-offer-clean-copy",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "commercial-offer", "mode": "clean-copy"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме clean-copy"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "contract-draft",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "contract", "mode": "draft"},
+      "expected_pages": 2,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме draft"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "contract-clean-copy",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "inputs": {"profile": "contract", "mode": "clean-copy"},
+      "expected_pages": 2,
+      "expected_page_size": "A4",
+      "required_text": ["Документ собран в режиме clean-copy"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "no-media-placeholder",
+      "entry": ".template/development/tests/fixtures/no-media/main.typ",
+      "inputs": {"policy": "placeholder"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["политикой placeholder"],
+      "snapshot_pages": [1]
+    },
+    {
+      "id": "no-media-hide",
+      "entry": ".template/development/tests/fixtures/no-media/main.typ",
+      "inputs": {"policy": "hide"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["политикой hide"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "no-media-reserve-space",
+      "entry": ".template/development/tests/fixtures/no-media/main.typ",
+      "inputs": {"policy": "reserve-space"},
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": ["политикой reserve-space"],
+      "snapshot_pages": []
+    },
+    {
+      "id": "report-baseline",
+      "entry": ".template/development/tests/fixtures/report-baseline/main.typ",
+      "expected_pages": 7,
+      "expected_page_size": "A4",
+      "required_text": [
+        "ТЕСТОВЫЙ ОТЧЁТ",
+        "СПИСОК ИСПОЛНИТЕЛЕЙ",
+        "СОДЕРЖАНИЕ",
+        "Сложные элементы должны сохранять",
+        "ТЕСТОВОЕ ПРИЛОЖЕНИЕ"
+      ],
+      "snapshot_pages": [1, 2, 3, 4]
+    },
+    {
+      "id": "list-counters",
+      "entry": ".template/development/tests/fixtures/list-counters/main.typ",
+      "expected_pages": 1,
+      "expected_page_size": "A4",
+      "required_text": [
+        "1. Арабские: первый",
+        "2. Арабские: второй",
+        "3. Арабские: третий",
+        "А. Кириллица верхняя: первый",
+        "Б. Кириллица верхняя: второй",
+        "В. Кириллица верхняя: третий",
+        "а. Кириллица нижняя: первый",
+        "б. Кириллица нижняя: второй",
+        "в. Кириллица нижняя: третий",
+        "A. Латиница верхняя: первый",
+        "B. Латиница верхняя: второй",
+        "C. Латиница верхняя: третий",
+        "a. Латиница нижняя: первый",
+        "b. Латиница нижняя: второй",
+        "c. Латиница нижняя: третий",
+        "01. Ведущий ноль: первый",
+        "02. Ведущий ноль: второй",
+        "03. Ведущий ноль: третий",
+        "1.а. Второй уровень: первый",
+        "1.б. Второй уровень: второй",
+        "1.б.A. Третий уровень: первый",
+        "1.б.B. Третий уровень: второй"
+      ],
+      "snapshot_pages": []
+    },
+    {
+      "id": "public-formatting-guide",
+      "entry": "docs/examples/formatting/main.typ",
+      "expected_pages": 0,
+      "expected_page_size": "A4",
+      "required_text": [
+        "РИСУНКИ",
+        "ТАБЛИЦЫ",
+        "Обычная встроенная таблица",
+        "Многострочная шапка и объединение ячеек",
+        "ФОРМУЛЫ",
+        "СПИСКИ И ТЕКСТ",
+        "Быстрая многоуровневая схема ГОСТ",
+        "Полная десятичная нумерация",
+        "Доступные типы счётчиков",
+        "Произвольные разделители",
+        "Маркеры вместо чисел",
+        "Геометрия и интервалы",
+        "Продолжение с нужного номера",
+        "1. Первый уровень.",
+        "а) Второй уровень.",
+        "1.1.1. Вложенный пункт.",
+        "II. Второй.",
+        "III. Третий.",
+        "ii. Второй.",
+        "iii. Третий.",
+        "B. Второй.",
+        "C. Третий.",
+        "b. Второй.",
+        "c. Третий.",
+        "02. Второй.",
+        "03. Третий.",
+        "Б. Второй.",
+        "В. Третий.",
+        "б. Второй.",
+        "в. Третий.",
+        "1.а)A. Третий уровень",
+        "A)1. Цифровой уровень.",
+        "§ 001: Пользовательский уровень",
+        "• Маркер •.",
+        "∙ Маркер ∙.",
+        "‣ Маркер ‣.",
+        "⁃ Маркер ⁃.",
+        "◦ Маркер ◦.",
+        "08. Восьмой пункт",
+        "09. Следующий пункт"
+      ],
+      "snapshot_pages": []
+    }
+  ],
+  "negative_fixtures": [
+    {
+      "id": "negative-letter",
+      "entry": ".template/development/tests/fixtures/negative-letter/main.typ",
+      "required_errors": ["Scientia / letter.recipient"]
+    },
+    {
+      "id": "negative-offer",
+      "entry": ".template/development/tests/fixtures/negative-offer/main.typ",
+      "required_errors": ["Scientia / commercial-offer.terms.amount"]
+    },
+    {
+      "id": "negative-contract",
+      "entry": ".template/development/tests/fixtures/negative-contract/main.typ",
+      "required_errors": ["Scientia / parties", "повторяющийся id"]
+    },
+    {
+      "id": "negative-company",
+      "entry": ".template/development/tests/fixtures/negative-company/main.typ",
+      "required_errors": ["Scientia / load-company.id", "неизвестная компания"]
+    },
+    {
+      "id": "negative-references",
+      "entry": ".template/development/tests/fixtures/negative-references/main.typ",
+      "required_errors": ["Scientia / vrefs", "ожидался label"]
+    },
+    {
+      "id": "negative-reference-case",
+      "entry": ".template/development/tests/fixtures/negative-reference-case/main.typ",
+      "required_errors": ["Scientia / reference", "неизвестный падеж"]
+    },
+    {
+      "id": "negative-list-scheme",
+      "entry": ".template/development/tests/fixtures/negative-list-scheme/main.typ",
+      "required_errors": ["Scientia / numbered-list.scheme", "неизвестная схема"]
+    }
+  ],
+  "compile_matrices": [
+    {
+      "id": "company-profile-matrix",
+      "entry": ".template/development/tests/fixtures/modes/main.typ",
+      "parameters": {
+        "company": ["scientia", "technology", "too", "test-company"],
+        "profile": ["report", "letter", "commercial-offer", "contract"],
+        "mode": ["final"]
+      }
+    }
+  ]
+}

+ 642 - 0
.template/development/tests/run-tests.py

@@ -0,0 +1,642 @@
+from __future__ import annotations
+
+import argparse
+import itertools
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[3]
+TEST_ROOT = ROOT / ".template" / "development" / "tests"
+MANIFEST_PATH = TEST_ROOT / "manifest.json"
+SNAPSHOT_ROOT = TEST_ROOT / "snapshots"
+
+
+class TestFailure(RuntimeError):
+    pass
+
+
+def check_architecture() -> None:
+    """Проверяет направление зависимостей без запуска Typst renderer."""
+    template_root = ROOT / ".template" / "lib"
+    import_pattern = re.compile(r'#import\s+"([^"]+)"')
+    graph: dict[Path, set[Path]] = {}
+
+    for source in template_root.rglob("*.typ"):
+        graph[source.resolve()] = set()
+        text = source.read_text(encoding="utf-8")
+        source_area = source.relative_to(template_root).parts[0]
+        code_without_line_comments = re.sub(r"//.*", "", text)
+        if source_area == "domain":
+            forbidden_runtime = re.search(
+                r"(?:#(?:set|show)\b|\b(?:page|place|image|query|state)\s*\()",
+                code_without_line_comments,
+            )
+            if forbidden_runtime:
+                raise TestFailure(
+                    f"Domain-модуль {source.relative_to(ROOT)} содержит presentation/runtime вызов "
+                    f"{forbidden_runtime.group(0)!r}."
+                )
+        for raw_target in import_pattern.findall(text):
+            if raw_target.startswith("@"):
+                continue
+            target = (source.parent / raw_target).resolve()
+            try:
+                relative = target.relative_to(template_root.resolve())
+            except ValueError:
+                continue
+            if target.suffix == ".typ":
+                graph[source.resolve()].add(target)
+
+            target_area = relative.parts[0]
+            forbidden = {
+                "domain": {"application", "infrastructure", "presentation"},
+                "infrastructure": {"application", "presentation"},
+                "application": {"presentation"},
+            }.get(source_area, set())
+            if target_area in forbidden:
+                raise TestFailure(
+                    "Нарушена DDD-граница: "
+                    f"{source.relative_to(ROOT)} -> {target.relative_to(ROOT)}"
+                )
+
+        if re.search(r'#import\s+"[^\"]*(?:main\.typ|chapters/|assets/|\.private/)', text):
+            raise TestFailure(
+                f"Library-модуль {source.relative_to(ROOT)} импортирует пользовательский слой."
+            )
+
+    visiting: set[Path] = set()
+    visited: set[Path] = set()
+
+    def visit(node: Path, stack: list[Path]) -> None:
+        if node in visiting:
+            cycle = stack[stack.index(node):] + [node]
+            rendered = " -> ".join(str(item.relative_to(ROOT)) for item in cycle)
+            raise TestFailure(f"Циклические Typst-импорты: {rendered}")
+        if node in visited:
+            return
+        visiting.add(node)
+        stack.append(node)
+        for target in graph.get(node, set()):
+            if target in graph:
+                visit(target, stack)
+        stack.pop()
+        visiting.remove(node)
+        visited.add(node)
+
+    for node in graph:
+        visit(node, [])
+
+
+def check_workspace() -> None:
+    """Проверяет минимальный корень и синхронизируемую конфигурацию VS Code."""
+    allowed_directories = {
+        ".git",
+        ".private",
+        ".template",
+        ".vscode",
+        "assets",
+        "chapters",
+        "docs",
+    }
+    actual_directories = {item.name for item in ROOT.iterdir() if item.is_dir()}
+    unexpected = actual_directories - allowed_directories
+    if unexpected:
+        raise TestFailure(
+            "В корне обнаружены лишние каталоги: " + ", ".join(sorted(unexpected))
+        )
+
+    required_files = {
+        ".gitignore",
+        "README.md",
+        "main.typ",
+    }
+    missing_files = {name for name in required_files if not (ROOT / name).is_file()}
+    if missing_files:
+        raise TestFailure(
+            "В корне отсутствуют обязательные файлы: " + ", ".join(sorted(missing_files))
+        )
+
+    extensions_path = ROOT / ".vscode" / "extensions.json"
+    settings_path = ROOT / ".vscode" / "settings.json"
+    extensions = json.loads(extensions_path.read_text(encoding="utf-8"))
+    recommendations = set(extensions.get("recommendations", []))
+    required_extensions = {
+        "alefragnani.bookmarks",
+        "streetsidesoftware.code-spell-checker",
+        "streetsidesoftware.code-spell-checker-russian",
+        "ms-ceintl.vscode-language-pack-ru",
+        "myriad-dreamin.tinymist",
+        "wayou.vscode-todo-highlight",
+        "gruntfuggly.todo-tree",
+        "mhutchie.git-graph",
+        "local.typst-typewriter",
+        "zotst.zotst",
+    }
+    missing_extensions = required_extensions - recommendations
+    if missing_extensions:
+        raise TestFailure(
+            "В extensions.json отсутствуют рекомендации: "
+            + ", ".join(sorted(missing_extensions))
+        )
+
+    settings = json.loads(settings_path.read_text(encoding="utf-8"))
+    if settings.get("files.autoSave") != "afterDelay":
+        raise TestFailure("Workspace должен включать files.autoSave=afterDelay.")
+    if settings.get("files.exclude", {}).get(".template") is not True:
+        raise TestFailure(".template должен быть скрыт в пользовательском Explorer VS Code.")
+    if settings.get("files.exclude", {}).get(".vscode") is not True:
+        raise TestFailure(".vscode должен быть скрыт в пользовательском Explorer VS Code.")
+    if settings.get("files.exclude", {}).get("docs") is True:
+        raise TestFailure("Публичная документация docs/ не должна быть скрыта.")
+    if settings.get("typstTypewriter.navigator.mainFiles") != ["main.typ"]:
+        raise TestFailure("Typewriter должен показывать единственный main.typ.")
+
+    gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8")
+    if ".private/" not in gitignore:
+        raise TestFailure("В .gitignore отсутствует защита каталога .private/.")
+    if ".vscode/" in gitignore:
+        raise TestFailure(".vscode должен синхронизироваться через Git.")
+
+    private_example = ROOT / "docs" / "examples" / "private" / "settings.typ"
+    if not private_example.is_file():
+        raise TestFailure("Отсутствует безопасный пример .private/settings.typ.")
+    private_example_text = private_example.read_text(encoding="utf-8")
+    for employee_id in (
+        "musikhin", "guzeev", "fedorov", "ilyasov", "khimichev",
+        "brusnicin", "ozornin", "buhartdinov", "tkachenko",
+    ):
+        if f"{employee_id}:" not in private_example_text:
+            raise TestFailure(
+                f"В примере private settings отсутствует сотрудник {employee_id}."
+            )
+
+    tasks = json.loads((ROOT / ".vscode" / "tasks.json").read_text(encoding="utf-8"))
+    task_labels = {task.get("label") for task in tasks.get("tasks", [])}
+    if "Scientia: собрать PDF с приватными данными" in task_labels:
+        raise TestFailure("Отдельная private build task больше не должна использоваться.")
+    if (ROOT / ".template" / "development" / "tools" / "build-with-private-assets.ps1").exists():
+        raise TestFailure("Устаревший ZIP installer должен быть удалён.")
+
+    for company_id in ("scientia", "technology", "too"):
+        company_path = ROOT / ".template" / "companies" / company_id / "data.json"
+        company = json.loads(company_path.read_text(encoding="utf-8"))
+        if company.get("sign_image") is not None or company.get("stamp_image") is not None:
+            raise TestFailure(
+                f"Публичный профиль {company_id} не должен содержать подпись или печать."
+            )
+
+    public_docs = (
+        "README.md",
+        "documents.md",
+        "formatting.md",
+        "vscode.md",
+        "git.md",
+        "private-assets.md",
+        "writing-style.md",
+        "troubleshooting.md",
+    )
+    for name in public_docs:
+        if not (ROOT / "docs" / name).is_file():
+            raise TestFailure(f"Публичная документация неполна: docs/{name}.")
+
+    for starter in ("report", "letter", "commercial-offer", "contract"):
+        starter_root = ROOT / "docs" / "examples" / "documents" / starter
+        if not (starter_root / "main.typ").is_file() or not (starter_root / "chapters").is_dir():
+            raise TestFailure(f"Публичный пример {starter} неполон.")
+
+    for obsolete in ("document.typ", "draft.typ", "clean-copy.typ"):
+        if (ROOT / obsolete).exists():
+            raise TestFailure(f"Лишняя точка входа в корне: {obsolete}.")
+
+
+def run(command: list[str], *, cwd: Path = ROOT) -> str:
+    result = subprocess.run(
+        command,
+        cwd=cwd,
+        text=True,
+        encoding="utf-8",
+        errors="replace",
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+    if result.returncode != 0:
+        rendered = subprocess.list2cmdline(command)
+        raise TestFailure(f"Команда завершилась с кодом {result.returncode}:\n{rendered}\n{result.stdout}")
+    return result.stdout
+
+
+def find_executable(
+    name: str,
+    *,
+    env_var: str | None = None,
+    required: bool = True,
+) -> Path | None:
+    if env_var and os.environ.get(env_var):
+        candidate = Path(os.environ[env_var])
+        if candidate.is_file():
+            return candidate
+
+    located = shutil.which(name)
+    if located:
+        return Path(located)
+
+    executable = name + (".exe" if os.name == "nt" else "")
+    candidates: list[Path] = []
+
+    poppler_bin = os.environ.get("POPPLER_BIN")
+    if poppler_bin:
+        candidates.append(Path(poppler_bin) / executable)
+
+    if os.name == "nt":
+        candidates.append(
+            Path.home()
+            / ".cache"
+            / "codex-runtimes"
+            / "codex-primary-runtime"
+            / "dependencies"
+            / "native"
+            / "poppler"
+            / "Library"
+            / "bin"
+            / executable
+        )
+
+    for candidate in candidates:
+        if candidate.is_file():
+            return candidate
+
+    if required:
+        raise TestFailure(
+            f"Не найден исполняемый файл {name}. Добавьте его в PATH"
+            + (f" или задайте {env_var}" if env_var else "")
+            + ("/POPPLER_BIN." if name.startswith("pdf") else ".")
+        )
+    return None
+
+
+def parse_version(value: str) -> tuple[int, ...]:
+    match = re.search(r"(\d+)\.(\d+)\.(\d+)", value)
+    if not match:
+        raise TestFailure(f"Не удалось определить версию из строки: {value!r}")
+    return tuple(int(part) for part in match.groups())
+
+
+def load_manifest() -> dict:
+    return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
+
+
+def compile_fixture(
+    typst: Path,
+    entry: Path,
+    output: Path,
+    creation_timestamp: int,
+    inputs: dict[str, str] | None = None,
+) -> str:
+    command = [
+        str(typst),
+        "compile",
+        "--root",
+        str(ROOT),
+        "--creation-timestamp",
+        str(creation_timestamp),
+    ]
+    for key, value in sorted((inputs or {}).items()):
+        command.extend(("--input", f"{key}={value}"))
+    command.extend((str(entry), str(output)))
+    return run(command)
+
+
+def compile_expected_failure(
+    typst: Path,
+    entry: Path,
+    output: Path,
+    creation_timestamp: int,
+    required_errors: list[str],
+    inputs: dict[str, str] | None = None,
+) -> None:
+    command = [
+        str(typst),
+        "compile",
+        "--root",
+        str(ROOT),
+        "--creation-timestamp",
+        str(creation_timestamp),
+    ]
+    for key, value in sorted((inputs or {}).items()):
+        command.extend(("--input", f"{key}={value}"))
+    command.extend((str(entry), str(output)))
+    result = subprocess.run(
+        command,
+        cwd=ROOT,
+        text=True,
+        encoding="utf-8",
+        errors="replace",
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+    if result.returncode == 0:
+        raise TestFailure(f"{entry.relative_to(ROOT)}: ожидалась ошибка компиляции.")
+    for marker in required_errors:
+        if marker not in result.stdout:
+            raise TestFailure(
+                f"{entry.relative_to(ROOT)}: в diagnostic не найден маркер {marker!r}.\n"
+                f"Фактический вывод:\n{result.stdout}"
+            )
+
+
+def run_unit_test(typst: Path, module: str, creation_timestamp: int) -> None:
+    expression = f'import "/{module.replace(os.sep, "/")}": run; run()'
+    output = run(
+        [
+            str(typst),
+            "eval",
+            "--root",
+            str(ROOT),
+            "--creation-timestamp",
+            str(creation_timestamp),
+            expression,
+        ]
+    ).strip()
+    if json.loads(output) != "ok":
+        raise TestFailure(f"Unit-модуль {module} вернул неожиданное значение: {output}")
+
+
+def inspect_pdf(pdfinfo: Path, pdf: Path) -> tuple[int, str]:
+    info = run([str(pdfinfo), str(pdf)])
+    pages_match = re.search(r"^Pages:\s+(\d+)$", info, flags=re.MULTILINE)
+    size_match = re.search(r"^Page size:.*\(([^)]+)\)$", info, flags=re.MULTILINE)
+    if not pages_match or not size_match:
+        raise TestFailure(f"Не удалось разобрать pdfinfo для {pdf}:\n{info}")
+    return int(pages_match.group(1)), size_match.group(1).strip()
+
+
+def extract_text(pdftotext: Path | None, pdf: Path, target: Path) -> str:
+    if pdftotext:
+        run([str(pdftotext), "-layout", str(pdf), str(target)])
+        return target.read_text(encoding="utf-8", errors="replace")
+
+    try:
+        from pypdf import PdfReader
+    except ImportError as exc:
+        raise TestFailure(
+            "Для проверки текста требуется pdftotext или Python-пакет pypdf."
+        ) from exc
+
+    reader = PdfReader(str(pdf))
+    return "\n".join((page.extract_text() or "") for page in reader.pages)
+
+
+def count_pdf_links(pdf: Path) -> tuple[int, int]:
+    try:
+        from pypdf import PdfReader
+    except ImportError as exc:
+        raise TestFailure("Для проверки PDF-ссылок требуется Python-пакет pypdf.") from exc
+
+    external = internal = 0
+    for page in PdfReader(str(pdf)).pages:
+        for raw_annotation in page.get("/Annots", ()):
+            annotation = raw_annotation.get_object()
+            if annotation.get("/Subtype") != "/Link":
+                continue
+            action = annotation.get("/A")
+            if action is not None and action.get_object().get("/URI") is not None:
+                external += 1
+            else:
+                internal += 1
+    return external, internal
+
+
+def render_page(pdftoppm: Path, pdf: Path, page: int, dpi: int, target: Path) -> Path:
+    target.parent.mkdir(parents=True, exist_ok=True)
+    run(
+        [
+            str(pdftoppm),
+            "-png",
+            "-r",
+            str(dpi),
+            "-f",
+            str(page),
+            "-l",
+            str(page),
+            "-singlefile",
+            str(pdf),
+            str(target.with_suffix("")),
+        ]
+    )
+    return target
+
+
+def image_difference(expected: Path, actual: Path) -> float:
+    try:
+        from PIL import Image, ImageChops
+    except ImportError as exc:
+        raise TestFailure("Для visual regression требуется пакет Pillow.") from exc
+
+    with Image.open(expected).convert("RGB") as left, Image.open(actual).convert("RGB") as right:
+        if left.size != right.size:
+            return 1.0
+        diff = ImageChops.difference(left, right)
+        histogram = diff.histogram()
+        total = sum(value * (index % 256) for index, value in enumerate(histogram))
+        maximum = left.width * left.height * 3 * 255
+        return total / maximum
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Регрессионные тесты шаблона документов Scientia")
+    parser.add_argument("--fixture", action="append", help="Запустить только fixture с указанным id")
+    parser.add_argument("--compile-only", action="store_true", help="Не запускать PDF и visual проверки")
+    parser.add_argument(
+        "--update-snapshots",
+        action="store_true",
+        help="Явно обновить visual snapshots после ручного подтверждения",
+    )
+    args = parser.parse_args()
+
+    manifest = load_manifest()
+    typst = find_executable("typst", env_var="TYPST_BIN")
+    version_output = run([str(typst), "--version"]).strip()
+    if parse_version(version_output) < parse_version(manifest["typst_min"]):
+        raise TestFailure(
+            f"Требуется Typst {manifest['typst_min']}+, найден {version_output}."
+        )
+
+    pdfinfo = pdftotext = pdftoppm = None
+    if not args.compile_only:
+        pdfinfo = find_executable("pdfinfo")
+        pdftotext = find_executable("pdftotext", required=False)
+        pdftoppm = find_executable("pdftoppm")
+
+    creation_timestamp = int(manifest["creation_timestamp"])
+    selected = set(args.fixture or [])
+    known = {
+        item["id"]
+        for item in (
+            *manifest["fixtures"],
+            *manifest.get("negative_fixtures", []),
+            *manifest.get("compile_matrices", []),
+        )
+    }
+    unknown = selected - known
+    if unknown:
+        raise TestFailure(f"Неизвестные fixtures: {', '.join(sorted(unknown))}")
+
+    if not selected:
+        print("[static] architecture")
+        check_architecture()
+        print("[static] workspace")
+        check_workspace()
+
+    for unit in manifest.get("unit_tests", []):
+        if selected:
+            continue
+        print(f"[unit] {unit['id']}")
+        run_unit_test(typst, unit["module"], creation_timestamp)
+
+    temp_dir = Path(tempfile.mkdtemp(prefix="scientia-tests-"))
+    failed = False
+    try:
+        for matrix in manifest.get("compile_matrices", []):
+            if selected and matrix["id"] not in selected:
+                continue
+            keys = tuple(matrix["parameters"])
+            values = tuple(matrix["parameters"][key] for key in keys)
+            for combination in itertools.product(*values):
+                inputs = dict(zip(keys, combination))
+                suffix = ",".join(f"{key}={value}" for key, value in inputs.items())
+                print(f"[matrix] {matrix['id']}[{suffix}]")
+                safe_suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", suffix)
+                compile_fixture(
+                    typst,
+                    ROOT / matrix["entry"],
+                    temp_dir / f"{matrix['id']}-{safe_suffix}.pdf",
+                    creation_timestamp,
+                    inputs=inputs,
+                )
+
+        for fixture in manifest.get("negative_fixtures", []):
+            if selected and fixture["id"] not in selected:
+                continue
+            fixture_id = fixture["id"]
+            print(f"[negative] {fixture_id}")
+            compile_expected_failure(
+                typst,
+                ROOT / fixture["entry"],
+                temp_dir / f"{fixture_id}.pdf",
+                creation_timestamp,
+                fixture.get("required_errors", []),
+                inputs=fixture.get("inputs"),
+            )
+
+        for fixture in manifest["fixtures"]:
+            if selected and fixture["id"] not in selected:
+                continue
+
+            fixture_id = fixture["id"]
+            print(f"[compile] {fixture_id}")
+            entry = ROOT / fixture["entry"]
+            output = temp_dir / f"{fixture_id}.pdf"
+            compile_fixture(
+                typst,
+                entry,
+                output,
+                creation_timestamp,
+                inputs=fixture.get("inputs"),
+            )
+
+            if args.compile_only:
+                continue
+
+            assert pdfinfo and pdftoppm
+            pages, page_size = inspect_pdf(pdfinfo, output)
+            expected_pages = int(fixture["expected_pages"])
+            if expected_pages and pages != expected_pages:
+                raise TestFailure(
+                    f"{fixture_id}: ожидалось {expected_pages} страниц, получено {pages}."
+                )
+            if page_size != fixture["expected_page_size"]:
+                raise TestFailure(
+                    f"{fixture_id}: ожидался формат {fixture['expected_page_size']}, получен {page_size}."
+                )
+
+            text = extract_text(pdftotext, output, temp_dir / f"{fixture_id}.txt")
+            for marker in fixture.get("required_text", []):
+                if marker not in text:
+                    raise TestFailure(f"{fixture_id}: в PDF не найден текстовый маркер {marker!r}.")
+            for marker, minimum in fixture.get("required_text_counts", {}).items():
+                actual = text.count(marker)
+                if actual < int(minimum):
+                    raise TestFailure(
+                        f"{fixture_id}: маркер {marker!r} найден {actual} раз; "
+                        f"требуется не менее {minimum}."
+                    )
+            if "minimum_external_links" in fixture or "minimum_internal_links" in fixture:
+                external_links, internal_links = count_pdf_links(output)
+                minimum_external = int(fixture.get("minimum_external_links", 0))
+                minimum_internal = int(fixture.get("minimum_internal_links", 0))
+                if external_links < minimum_external:
+                    raise TestFailure(
+                        f"{fixture_id}: внешних PDF-ссылок {external_links}; "
+                        f"требуется не менее {minimum_external}."
+                    )
+                if internal_links < minimum_internal:
+                    raise TestFailure(
+                        f"{fixture_id}: внутренних PDF-ссылок {internal_links}; "
+                        f"требуется не менее {minimum_internal}."
+                    )
+
+            for page in fixture.get("snapshot_pages", []):
+                if page > pages:
+                    raise TestFailure(f"{fixture_id}: snapshot page {page} больше количества страниц {pages}.")
+                actual = render_page(
+                    pdftoppm,
+                    output,
+                    page,
+                    int(manifest["dpi"]),
+                    temp_dir / fixture_id / f"page-{page}.png",
+                )
+                expected = SNAPSHOT_ROOT / fixture_id / f"page-{page}.png"
+                if args.update_snapshots:
+                    expected.parent.mkdir(parents=True, exist_ok=True)
+                    shutil.copy2(actual, expected)
+                    print(f"[snapshot:update] {expected.relative_to(ROOT)}")
+                    continue
+                if not expected.is_file():
+                    raise TestFailure(
+                        f"Нет snapshot {expected.relative_to(ROOT)}. "
+                        "После ручной проверки запустите --update-snapshots."
+                    )
+                difference = image_difference(expected, actual)
+                threshold = float(fixture.get("visual_threshold", manifest["visual_threshold"]))
+                if difference > threshold:
+                    raise TestFailure(
+                        f"{fixture_id}, страница {page}: visual diff {difference:.6f} "
+                        f"превышает порог {threshold:.6f}. Фактический файл: {actual}"
+                    )
+                print(f"[snapshot:ok] {fixture_id}/page-{page} diff={difference:.6f}")
+
+        print(f"OK: {version_output}")
+        return 0
+    except Exception:
+        failed = True
+        raise
+    finally:
+        if failed:
+            print(f"Временные результаты сохранены: {temp_dir}", file=sys.stderr)
+        else:
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+
+if __name__ == "__main__":
+    try:
+        raise SystemExit(main())
+    except TestFailure as exc:
+        print(f"FAIL: {exc}", file=sys.stderr)
+        raise SystemExit(1)

+ 63 - 0
.template/development/tools/use-starter.ps1

@@ -0,0 +1,63 @@
+param(
+    [Parameter(Mandatory = $true)]
+    [ValidateSet('report', 'letter', 'commercial-offer', 'contract')]
+    [string]$Name
+)
+
+$ErrorActionPreference = 'Stop'
+
+$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
+$starterRoot = [System.IO.Path]::GetFullPath(
+    (Join-Path $workspaceRoot "docs\examples\documents\$Name")
+)
+$mainTarget = [System.IO.Path]::GetFullPath((Join-Path $workspaceRoot 'main.typ'))
+$chaptersTarget = [System.IO.Path]::GetFullPath((Join-Path $workspaceRoot 'chapters'))
+$assetsTarget = [System.IO.Path]::GetFullPath((Join-Path $workspaceRoot 'assets'))
+
+foreach ($path in @($starterRoot, $mainTarget, $chaptersTarget, $assetsTarget)) {
+    if (-not $path.StartsWith(
+        $workspaceRoot + [System.IO.Path]::DirectorySeparatorChar,
+        [System.StringComparison]::OrdinalIgnoreCase
+    )) {
+        throw "Небезопасный путь за пределами проекта: $path"
+    }
+}
+
+if (-not (Test-Path -LiteralPath (Join-Path $starterRoot 'main.typ'))) {
+    throw "Не найден пример документа: $Name"
+}
+
+# Выбор типа разрешён даже при незакоммиченных изменениях: перед заменой
+# создаётся локальная резервная копия, которая не попадает в Git.
+$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
+$backupRoot = [System.IO.Path]::GetFullPath(
+    (Join-Path $workspaceRoot ".private\starter-backups\$timestamp")
+)
+if (-not $backupRoot.StartsWith(
+    $workspaceRoot + [System.IO.Path]::DirectorySeparatorChar,
+    [System.StringComparison]::OrdinalIgnoreCase
+)) {
+    throw "Небезопасный путь резервной копии: $backupRoot"
+}
+New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null
+
+if (Test-Path -LiteralPath $mainTarget) {
+    Copy-Item -LiteralPath $mainTarget -Destination (Join-Path $backupRoot 'main.typ')
+}
+if (Test-Path -LiteralPath $chaptersTarget) {
+    Copy-Item -LiteralPath $chaptersTarget -Destination (Join-Path $backupRoot 'chapters') -Recurse
+    Remove-Item -LiteralPath $chaptersTarget -Recurse -Force
+}
+
+Copy-Item -LiteralPath (Join-Path $starterRoot 'main.typ') -Destination $mainTarget -Force
+Copy-Item -LiteralPath (Join-Path $starterRoot 'chapters') -Destination $chaptersTarget -Recurse
+
+$starterAssets = Join-Path $starterRoot 'assets'
+if (Test-Path -LiteralPath $starterAssets) {
+    New-Item -ItemType Directory -Path $assetsTarget -Force | Out-Null
+    Copy-Item -Path (Join-Path $starterAssets '*') -Destination $assetsTarget -Recurse -Force
+}
+
+Write-Host "Установлен тип документа '$Name'."
+Write-Host "Предыдущие main.typ и chapters/ сохранены в $backupRoot"
+Write-Host 'Откройте main.typ и последовательно проверьте параметры.'

+ 7 - 0
.template/development/vscode/README.md

@@ -0,0 +1,7 @@
+# VS Code для сопровождающего шаблона
+
+Корневые `.vscode/extensions.json`, `.vscode/settings.json` и `.vscode/tasks.json` намеренно синхронизируются через Git: это часть пользовательского опыта шаблона.
+
+`local.typst-typewriter` и `zotst.zotst` обнаружены как локально установленные расширения. Рекомендация в `extensions.json` сообщает VS Code их идентификаторы, но не распространяет сам пакет. Для нового компьютера соответствующий `.vsix` нужно получить из внутреннего хранилища и выполнить команду **Extensions: Install from VSIX**.
+
+Если принято решение хранить внутренние `.vsix` рядом с шаблоном, размещайте их в `packages/` этого каталога и добавляйте только после проверки лицензии и отсутствия секретов.

+ 8 - 0
.template/development/vscode/packages/README.md

@@ -0,0 +1,8 @@
+# Внутренние VSIX-пакеты
+
+Здесь могут храниться проверенные установочные пакеты локальных расширений:
+
+- `local.typst-typewriter`;
+- `zotst.zotst`.
+
+Перед добавлением `.vsix` проверьте лицензию, состав архива и отсутствие секретов. Само наличие extension ID в `.vscode/extensions.json` не распространяет локальное расширение.

+ 15 - 0
.template/lib/api.typ

@@ -0,0 +1,15 @@
+// ==========================================
+// ВНУТРЕННИЙ API ЯДРА
+// .template/lib/index.typ реэкспортирует только поддерживаемую публичную поверхность.
+// ==========================================
+#import "application/render-document.typ": render-document
+#import "domain/document.typ": document-profile, render-options
+#import "domain/company.typ": company-profile
+#import "domain/parties.typ": party, recipient, signer, approval
+#import "domain/attachments.typ": attachment, attachment-set, validate-attachment-set
+#import "domain/references.typ": bibliography-section
+#import "infrastructure/company-assets.typ": load-company, available-companies
+
+#let document(body, company: "scientia", profile: none, options: (:)) = {
+  render-document(body, company, profile, options: options)
+}

+ 29 - 0
.template/lib/appendices.typ

@@ -0,0 +1,29 @@
+// ==========================================
+// ЛОГИКА ПРИЛОЖЕНИЙ
+// ==========================================
+#import "numbering.typ": cyrillic_numbering
+
+// Макрос для Приложений
+#let make_appendices(body) = {
+  counter(heading).update(0)
+
+  // Настраиваем кириллическую нумерацию и указываем явно supplement
+  set heading(numbering: cyrillic_numbering, supplement: [Приложение])
+
+  show heading.where(level: 1): it => {
+    pagebreak()
+    // Заголовок 1 уровня в приложениях выровнен по правому краю, жирным и чуть меньшим шрифтом
+    set align(right)
+    set text(weight: "bold", size: 12pt)
+    set par(leading: 0.7em)
+    let num = cyrillic_numbering(counter(heading).get().first())
+    [Приложение #num. #it.body]
+    v(0.5em)
+  }
+
+  set figure(numbering: (..nums) => {
+    let h = counter(heading).get().first()
+    cyrillic_numbering(h) + "." + str(nums.pos().last())
+  })
+  body
+}

+ 45 - 0
.template/lib/application/render-document.typ

@@ -0,0 +1,45 @@
+// ==========================================
+// APPLICATION: СБОРКА ДОКУМЕНТА
+// ==========================================
+#import "../domain/document.typ": document-context, render-options, validate-profile-contract
+#import "../domain/company.typ": validate-company
+#import "../domain/parties.typ": validate-parties
+#import "../domain/attachments.typ": attachment-set, validate-attachment-set
+#import "../domain/references.typ": validate-bibliographies
+#import "../infrastructure/company-assets.typ": load-company
+
+#let resolve-company(value) = {
+  if type(value) == str {
+    validate-company(load-company(value))
+  } else {
+    validate-company(value)
+  }
+}
+
+#let render-document(
+  body,
+  company,
+  profile,
+  options: (:),
+) = {
+  let company = resolve-company(company)
+  let profile = validate-profile-contract(profile)
+  let normalized = (profile.normalize)(profile.metadata)
+  let validated = (profile.validate)(normalized)
+  let metadata = if validated == none { normalized } else { validated }
+  let parties = validate-parties(metadata.at("parties", default: ()))
+  let attachments = validate-attachment-set(
+    metadata.at("attachments", default: attachment-set()),
+  )
+  let bibliographies = validate-bibliographies(metadata.at("bibliographies", default: ()))
+  let ctx = document-context(
+    company,
+    profile,
+    metadata,
+    render-options(options: options),
+    parties: parties,
+    attachments: attachments,
+    bibliographies: bibliographies,
+  )
+  (profile.render)(body, ctx)
+}

BIN
.template/lib/assets/icons/bank.png


BIN
.template/lib/assets/icons/card.png


BIN
.template/lib/assets/icons/email.png


BIN
.template/lib/assets/icons/location.png


+ 4 - 0
.template/lib/assets/placeholders/logo.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220">
+  <rect x="8" y="8" width="584" height="204" rx="20" fill="#f5f5f5" stroke="#999" stroke-width="8" stroke-dasharray="18 12"/>
+  <text x="300" y="126" text-anchor="middle" font-family="Arial, sans-serif" font-size="44" font-weight="700" fill="#777">ЛОГОТИП</text>
+</svg>

+ 6 - 0
.template/lib/assets/placeholders/signature.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 180">
+  <g fill="none" stroke="#8a8a8a" stroke-width="8" stroke-linecap="round" opacity="0.72">
+    <path d="M205 34 L395 146"/>
+    <path d="M395 34 L205 146"/>
+  </g>
+</svg>

+ 3 - 0
.template/lib/assets/placeholders/stamp.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420">
+  <circle cx="210" cy="210" r="178" fill="none" stroke="#8a8a8a" stroke-width="8" opacity="0.72"/>
+</svg>

+ 24 - 0
.template/lib/company.typ

@@ -0,0 +1,24 @@
+// ==========================================
+// ДАННЫЕ КОМПАНИИ И ДЕФОЛТЫ ШАБЛОНА
+// ==========================================
+#let load-company-data(company-id: "too") = {
+  json("../companies/" + company-id + "/data.json")
+}
+
+#let company-defaults(company-id: "too") = {
+  let data = load-company-data(company-id: company-id)
+  let company-name = data.at("name", default: "ООО «Скиентия»")
+  let director-title = data.at("director-title", default: "Директор")
+
+  (
+    company_info: data.at(
+      "company_info",
+      default: "ООО «Скиентия» ИНН 6686148633\nул. Шейнкмана, стр. 9, офис 65\nг. Екатеринбург, 620014, Россия\n+7 (922) 203-24-60 ☏\ninfo@scientia.ru 🖂",
+    ),
+    director_company: director-title + " " + company-name,
+    director_name: data.at("director-name", default: "Мусихин А.С."),
+    city: data.at("city", default: "Екатеринбург"),
+    sign_image: data.at("sign_image", default: none),
+    stamp_image: data.at("stamp_image", default: none),
+  )
+}

+ 334 - 0
.template/lib/components.typ

@@ -0,0 +1,334 @@
+// ==========================================
+// ПЕРЕИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ
+// ==========================================
+
+// Корпоративная таблица с оранжевой шапкой.
+// Для простых таблиц первая строка по-прежнему считается шапкой.
+// Для сложной шапки используйте `header: (...)`.
+#let corp_table(
+  columns: auto,
+  table-align: center, // center | left | right
+  row_breakable: true,
+  page_break: "fit",
+  text-size: 12pt,
+  leading: 0.65em,
+  header-leading: auto,
+  body-leading: auto,
+  spacing: auto,
+  header-spacing: auto,
+  body-spacing: auto,
+  justify: auto,
+  first-line-indent: 0pt,
+  hyphenate: auto,
+  header-hyphenate: auto,
+  body-hyphenate: auto,
+  inset: 0% + 5pt,
+  header-inset: auto,
+  body-inset: auto,
+  header: auto,
+  body: auto,
+  repeat_header: true,
+  header_fill: rgb("ffd35f"),
+  continuation: true,
+  continuation_text: "Продолжение таблицы",
+  continuation_with_number: true,
+  continuation_gap: 0.5em,
+  ..args,
+) = {
+  let resolve-table-align = value => {
+    if value == left or value == "left" {
+      left
+    } else if value == right or value == "right" {
+      right
+    } else {
+      center
+    }
+  }
+  let block-align = resolve-table-align(table-align)
+
+  let cols_count = if type(columns) == int {
+    columns
+  } else if type(columns) == array {
+    columns.len()
+  } else {
+    1
+  }
+
+  let pos_args = args.pos()
+  let named_args = args.named()
+
+  let normalize-cells = value => {
+    if value == auto or value == none {
+      ()
+    } else if type(value) == array {
+      value
+    } else {
+      (value,)
+    }
+  }
+
+  let span-of = (item, key) => {
+    if type(item) == content and item.func() == table.cell {
+      item.at(key, default: 1)
+    } else {
+      1
+    }
+  }
+
+  let decay-carries = carries => range(carries.len()).map(i => calc.max(carries.at(i) - 1, 0))
+
+  let advance-slot(row, col, carries) = {
+    if col >= cols_count {
+      advance-slot(row + 1, 0, decay-carries(carries))
+    } else if carries.at(col) > 0 {
+      advance-slot(row, col + 1, carries)
+    } else {
+      (row, col, carries)
+    }
+  }
+
+  let update-carries = (carries, col, colspan, rowspan) => {
+    range(carries.len()).map(i => if i >= col and i < col + colspan { calc.max(carries.at(i), rowspan) } else { carries.at(i) })
+  }
+
+  let update-filled = (filled, row, colspan, rowspan) => {
+    range(filled.len()).map(i => if i >= row and i < row + rowspan { filled.at(i) + colspan } else { filled.at(i) })
+  }
+
+  let rows-filled(filled, row-count) = {
+    if row-count <= 0 {
+      true
+    } else if filled.at(row-count - 1) == cols_count {
+      rows-filled(filled, row-count - 1)
+    } else {
+      false
+    }
+  }
+
+  let infer-header-rows(cells, idx: 0, row: 0, col: 0, carries: range(cols_count).map(_ => 0), max-row: -1) = {
+    if idx >= cells.len() {
+      calc.max(max-row + 1, 0)
+    } else {
+      let item = cells.at(idx)
+      let slot = advance-slot(row, col, carries)
+      let place-row = slot.at(0)
+      let place-col = slot.at(1)
+      let place-carries = slot.at(2)
+      let colspan = span-of(item, "colspan")
+      let rowspan = span-of(item, "rowspan")
+      let next-carries = update-carries(place-carries, place-col, colspan, rowspan)
+      infer-header-rows(
+        cells,
+        idx: idx + 1,
+        row: place-row,
+        col: place-col + colspan,
+        carries: next-carries,
+        max-row: calc.max(max-row, place-row + rowspan - 1),
+      )
+    }
+  }
+
+  let infer-auto-header-len(
+    cells,
+    idx: 0,
+    row: 0,
+    col: 0,
+    carries: range(cols_count).map(_ => 0),
+    filled: none,
+    first-row-max: 1,
+    header-rows: none,
+  ) = {
+    let filled = if filled == none { range(calc.max(cells.len(), 1) + 2).map(_ => 0) } else { filled }
+    if idx >= cells.len() {
+      cells.len()
+    } else {
+      let item = cells.at(idx)
+      let slot = advance-slot(row, col, carries)
+      let place-row = slot.at(0)
+      let place-col = slot.at(1)
+      let place-carries = slot.at(2)
+      let colspan = span-of(item, "colspan")
+      let rowspan = span-of(item, "rowspan")
+      let next-carries = update-carries(place-carries, place-col, colspan, rowspan)
+      let next-filled = update-filled(filled, place-row, colspan, rowspan)
+      let next-first-row-max = if place-row == 0 { calc.max(first-row-max, rowspan) } else { first-row-max }
+      let next-header-rows = if header-rows == none and next-filled.at(0) == cols_count { next-first-row-max } else { header-rows }
+
+      if next-header-rows != none and rows-filled(next-filled, next-header-rows) {
+        idx + 1
+      } else {
+        infer-auto-header-len(
+          cells,
+          idx: idx + 1,
+          row: place-row,
+          col: place-col + colspan,
+          carries: next-carries,
+          filled: next-filled,
+          first-row-max: next-first-row-max,
+          header-rows: next-header-rows,
+        )
+      }
+    }
+  }
+
+  let header_cells = if header == auto {
+    let h_len = infer-auto-header-len(pos_args)
+    pos_args.slice(0, h_len)
+  } else {
+    normalize-cells(header)
+  }
+
+  let body_cells = if body == auto {
+    if header == auto {
+      pos_args.slice(header_cells.len())
+    } else {
+      pos_args
+    }
+  } else {
+    normalize-cells(body)
+  }
+
+  let header_row_count = if header_cells.len() > 0 { infer-header-rows(header_cells) } else { 0 }
+  let has_header = header_row_count > 0
+  if "align" not in named_args {
+    // Центрирование самой таблицы не должно наследоваться текстом ячеек.
+    // Шапка по умолчанию центрируется, содержимое читается слева направо.
+    named_args.insert(
+      "align",
+      (x, y) => if has_header and y >= 1 and y <= header_row_count {
+        center + horizon
+      } else {
+        left + top
+      },
+    )
+  }
+  if type(row_breakable) != bool {
+    panic("Scientia / corp-table.row-breakable: ожидался bool")
+  }
+  let cell_breakable = if type(page_break) == bool {
+    page_break
+  } else if page_break == "fit" {
+    row_breakable
+  } else if page_break == "cell" {
+    false
+  } else {
+    panic("Scientia / corp-table.page-break: ожидались bool, 'fit' или 'cell'")
+  }
+  let resolved-header-leading = if header-leading == auto { leading } else { header-leading }
+  let resolved-body-leading = if body-leading == auto { leading } else { body-leading }
+  let resolved-header-spacing = if header-spacing == auto { spacing } else { header-spacing }
+  let resolved-body-spacing = if body-spacing == auto { spacing } else { body-spacing }
+  let resolved-header-hyphenate = if header-hyphenate == auto { hyphenate } else { header-hyphenate }
+  let resolved-body-hyphenate = if body-hyphenate == auto { hyphenate } else { body-hyphenate }
+  let resolved-header-inset = if header-inset == auto { inset } else { header-inset }
+  let resolved-body-inset = if body-inset == auto { inset } else { body-inset }
+
+  let cont_cell = table.cell(colspan: cols_count, stroke: none, fill: none, align: left, inset: 0pt)[
+    #context {
+      if not continuation {
+        v(0pt)
+      } else {
+        let current_page = counter(page).get().first()
+        let tables_before = query(selector(table).before(here()))
+        let start_page = if tables_before == () {
+          current_page
+        } else {
+          counter(page).at(tables_before.last().location()).first()
+        }
+
+        if current_page > start_page {
+          let continuation_label = if type(continuation_text) == str {
+            text(continuation_text.replace(" ", " "))
+          } else {
+            continuation_text
+          }
+          let continuation_title = if continuation_with_number {
+            [#continuation_label #counter(figure.where(kind: table)).display()]
+          } else {
+            continuation_label
+          }
+          set text(weight: "regular", size: text-size, hyphenate: false)
+          pad(bottom: continuation_gap)[#box(continuation_title)]
+        } else {
+          v(0pt)
+        }
+      }
+    }
+  ]
+
+  set text(size: text-size)
+  set table.cell(breakable: cell_breakable)
+
+  show table.cell: it => {
+    let is-header = has_header and it.y >= 1 and it.y <= header_row_count
+    let current-leading = if is-header { resolved-header-leading } else { resolved-body-leading }
+    let current-spacing = if is-header { resolved-header-spacing } else { resolved-body-spacing }
+    let current-hyphenate = if is-header { resolved-header-hyphenate } else { resolved-body-hyphenate }
+
+    // Важно: block(width:100%, it) ломает align: ... + horizon (вертикальное центрирование).
+    // Решение: возвращаем `it` напрямую, а все set-правила применяем через вложенные if-else,
+    // где `it` находится ВНУТРИ той же ветки, что и set — иначе set не распространяется на it.
+    // spacing применяется снаружи через замыкание, чтобы не дублировать внутренние ветки.
+    set par(leading: current-leading)
+
+    let styled-it = () => {
+      if is-header {
+        set par(first-line-indent: 0pt)
+        set text(weight: "bold")
+        if justify != auto {
+          set par(justify: justify)
+          if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
+        } else if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
+      } else if first-line-indent != auto {
+        set par(first-line-indent: first-line-indent)
+        if justify != auto {
+          set par(justify: justify)
+          if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
+        } else if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
+      } else {
+        if justify != auto {
+          set par(justify: justify)
+          if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
+        } else if current-hyphenate != auto { set text(hyphenate: current-hyphenate); it } else { it }
+      }
+    }
+
+    if current-spacing != auto {
+      set par(spacing: current-spacing)
+      styled-it()
+    } else {
+      styled-it()
+    }
+  }
+
+  align(block-align)[
+    #table(
+      columns: columns,
+      stroke: 0.5pt,
+      fill: (x, y) => if has_header and y >= 1 and y <= header_row_count { header_fill } else { none },
+      inset: (x, y) => if has_header and y >= 1 and y <= header_row_count { resolved-header-inset } else { resolved-body-inset },
+      ..named_args,
+      ..if has_header {
+        (
+          table.header(
+            repeat: repeat_header,
+            cont_cell,
+            ..header_cells,
+          ),
+        )
+      } else {
+        ()
+      },
+      ..body_cells
+    )
+  ]
+}
+
+// Объект формулы по ГОСТ:
+// центрирование формулы и номер справа настраиваются глобально в report.typ.
+// Использование: #formula($ ... $) <eq_label>
+#let formula(body, ..args) = math.equation(
+  body,
+  block: true,
+  ..args,
+)

+ 77 - 0
.template/lib/domain/attachments.typ

@@ -0,0 +1,77 @@
+// ==========================================
+// DOMAIN: ПРИЛОЖЕНИЯ
+// ==========================================
+#import "document.typ": fail
+
+#let attachment(
+  id,
+  title,
+  body,
+  subtitle: none,
+  number: auto,
+  outlined: true,
+) = {
+  if type(id) != str or id.trim() == "" {
+    fail("attachment.id", "нужна непустая строка")
+  }
+  if type(title) not in (str, content) {
+    fail("attachment.title", "ожидались str или content")
+  }
+  if type(body) not in (content, function) {
+    fail("attachment.body", "ожидались content или function")
+  }
+  if subtitle != none and type(subtitle) not in (str, content) {
+    fail("attachment.subtitle", "ожидались none, str или content")
+  }
+  (
+    kind: "attachment",
+    id: id,
+    title: title,
+    subtitle: subtitle,
+    body: body,
+    number: number,
+    outlined: outlined,
+  )
+}
+
+#let attachment-set(items: (), numbering: "arabic", start: 1) = {
+  if type(items) != array {
+    fail("attachments.items", "ожидался array")
+  }
+  if numbering not in ("arabic", "cyrillic", "none") {
+    fail("attachments.numbering", "допустимы arabic, cyrillic, none")
+  }
+  if type(start) != int or start < 1 {
+    fail("attachments.start", "нужно положительное целое число")
+  }
+  let seen = ()
+  for item in items {
+    if type(item) != dictionary or item.at("kind", default: none) != "attachment" {
+      fail("attachments.items", "каждый элемент должен быть attachment")
+    }
+    if item.id in seen {
+      fail("attachments.items", "повторяющийся id " + repr(item.id))
+    }
+    seen.push(item.id)
+  }
+  (
+    kind: "attachment-set",
+    items: items,
+    numbering: numbering,
+    start: start,
+  )
+}
+
+#let validate-attachment-set(value) = {
+  if type(value) == array {
+    attachment-set(items: value)
+  } else if type(value) != dictionary or value.at("kind", default: none) != "attachment-set" {
+    fail("attachments", "ожидались array или attachment-set")
+  } else {
+    attachment-set(
+      items: value.at("items", default: ()),
+      numbering: value.at("numbering", default: "arabic"),
+      start: value.at("start", default: 1),
+    )
+  }
+}

+ 146 - 0
.template/lib/domain/company.typ

@@ -0,0 +1,146 @@
+// ==========================================
+// DOMAIN: ПРОФИЛЬ ОРГАНИЗАЦИИ
+// ==========================================
+#import "document.typ": fail, ensure-dictionary
+
+#let optional-text(value) = {
+  if value == none {
+    none
+  } else if type(value) != str {
+    value
+  } else if value.trim() == "" {
+    none
+  } else {
+    value
+  }
+}
+
+#let fill-defaults(defaults, values) = {
+  let result = defaults
+  for (key, value) in values {
+    result.insert(key, optional-text(value))
+  }
+  result
+}
+
+#let company-profile(
+  id,
+  legal,
+  contacts: (:),
+  banking: (:),
+  brand: (:),
+  director: (:),
+  resources: (:),
+) = {
+  if type(id) != str or id.trim() == "" {
+    fail("company.id", "нужна непустая строка")
+  }
+  let legal = fill-defaults(
+    (
+      name: none,
+      short-name: none,
+      jurisdiction: none,
+      inn: none,
+      kpp: none,
+      ogrn: none,
+      bin: none,
+      kbe: none,
+    ),
+    ensure-dictionary(legal, "company.legal"),
+  )
+  let contacts = fill-defaults(
+    (
+      email: none,
+      website: none,
+      phone: none,
+      address: none,
+      address-en: none,
+      city: none,
+      summary: none,
+    ),
+    ensure-dictionary(contacts, "company.contacts"),
+  )
+  let banking = fill-defaults(
+    (
+      bank: none,
+      account: none,
+      correspondent-account: none,
+      bik: none,
+      iban: none,
+    ),
+    ensure-dictionary(banking, "company.banking"),
+  )
+  let brand = fill-defaults(
+    (color: rgb("e39f49"),),
+    ensure-dictionary(brand, "company.brand"),
+  )
+  let director = fill-defaults(
+    (title: none, name: none),
+    ensure-dictionary(director, "company.director"),
+  )
+  let resources = fill-defaults(
+    (logo: none, signature: none, stamp: none),
+    ensure-dictionary(resources, "company.resources"),
+  )
+
+  let profile = (
+    kind: "company-profile",
+    id: id,
+    legal: legal,
+    contacts: contacts,
+    banking: banking,
+    brand: brand,
+    director: director,
+    resources: resources,
+  )
+  profile
+}
+
+#let validate-company(profile) = {
+  let profile = ensure-dictionary(profile, "company")
+  if profile.at("kind", default: none) != "company-profile" {
+    fail("company.kind", "ожидался company-profile")
+  }
+  for key in ("legal", "contacts", "banking", "brand", "director", "resources") {
+    if key not in profile or type(profile.at(key)) != dictionary {
+      fail("company." + key, "ожидался dictionary")
+    }
+  }
+  let name = profile.legal.at("name", default: none)
+  if type(name) != str or name.trim() == "" {
+    fail("company.legal.name", "полное наименование обязательно")
+  }
+  let jurisdiction = profile.legal.at("jurisdiction", default: none)
+  if type(jurisdiction) != str or jurisdiction.trim() == "" {
+    fail("company.legal.jurisdiction", "юрисдикция обязательна")
+  }
+  for key in ("title", "name") {
+    let value = profile.director.at(key, default: none)
+    if type(value) != str or value.trim() == "" {
+      fail("company.director." + key, "поле обязательно")
+    }
+  }
+  if type(profile.brand.color) != color {
+    fail("company.brand.color", "ожидался color")
+  }
+  for key in ("logo", "signature", "stamp") {
+    let resource = profile.resources.at(key, default: none)
+    if resource != none and type(resource) not in (path, content) {
+      fail("company.resources." + key, "ожидались none, path или content")
+    }
+  }
+  profile
+}
+
+#let company-display-name(profile, short: false) = {
+  let profile = validate-company(profile)
+  if short {
+    profile.legal.at("short-name", default: profile.legal.name)
+  } else {
+    profile.legal.name
+  }
+}
+
+#let company-resource(profile, key) = {
+  validate-company(profile).resources.at(key, default: none)
+}

+ 115 - 0
.template/lib/domain/document.typ

@@ -0,0 +1,115 @@
+// ==========================================
+// DOMAIN: ДОКУМЕНТ И КОНТРАКТ ПРОФИЛЯ
+// ==========================================
+
+#let fail(scope, message) = panic("Scientia / " + scope + ": " + message)
+
+#let ensure-dictionary(value, scope) = {
+  if type(value) != dictionary {
+    fail(scope, "ожидался dictionary, получено " + repr(type(value)))
+  }
+  value
+}
+
+#let merge-known(defaults, overrides, scope: "options", allow-extra: false) = {
+  let defaults = ensure-dictionary(defaults, scope + ".defaults")
+  let overrides = ensure-dictionary(overrides, scope)
+  let result = defaults
+  for (key, value) in overrides {
+    if not allow-extra and key not in defaults {
+      fail(scope, "неизвестный параметр " + repr(key))
+    }
+    result.insert(key, value)
+  }
+  result
+}
+
+#let render-options(options: (:)) = {
+  let resolved = merge-known(
+    (
+      mode: "final",
+      watermark: none,
+      media-policy: "placeholder",
+      diagnostics: true,
+    ),
+    options,
+    scope: "render-options",
+  )
+
+  if resolved.mode not in ("final", "draft", "clean-copy") {
+    fail("render-options.mode", "допустимы final, draft, clean-copy")
+  }
+  if resolved.at("media-policy") not in ("hide", "placeholder", "reserve-space") {
+    fail("render-options.media-policy", "допустимы hide, placeholder, reserve-space")
+  }
+  resolved
+}
+
+#let document-profile(
+  id,
+  render,
+  metadata: (:),
+  normalize: value => value,
+  validate: value => value,
+) = {
+  if type(id) != str or id.trim() == "" {
+    fail("document-profile.id", "нужна непустая строка")
+  }
+  if type(metadata) != dictionary {
+    fail("document-profile.metadata", "ожидался dictionary")
+  }
+  if type(normalize) != function {
+    fail("document-profile.normalize", "ожидалась function")
+  }
+  if type(validate) != function {
+    fail("document-profile.validate", "ожидалась function")
+  }
+  if type(render) != function {
+    fail("document-profile.render", "ожидалась function")
+  }
+  (
+    kind: "document-profile",
+    id: id,
+    metadata: metadata,
+    normalize: normalize,
+    validate: validate,
+    render: render,
+  )
+}
+
+#let validate-profile-contract(profile) = {
+  let profile = ensure-dictionary(profile, "profile")
+  if profile.at("kind", default: none) != "document-profile" {
+    fail("profile.kind", "ожидался document-profile")
+  }
+  for key in ("id", "metadata", "normalize", "validate", "render") {
+    if key not in profile {
+      fail("profile", "отсутствует обязательное поле " + repr(key))
+    }
+  }
+  if type(profile.normalize) != function or type(profile.validate) != function or type(profile.render) != function {
+    fail("profile", "normalize, validate и render должны быть functions")
+  }
+  profile
+}
+
+#let document-context(
+  company,
+  profile,
+  metadata,
+  options,
+  parties: (),
+  attachments: (),
+  bibliographies: (),
+) = {
+  (
+    kind: "document-context",
+    company: company,
+    profile-id: profile.id,
+    metadata: ensure-dictionary(metadata, "document-context.metadata"),
+    options: render-options(options: options),
+    parties: parties,
+    attachments: attachments,
+    bibliographies: bibliographies,
+  )
+}

+ 124 - 0
.template/lib/domain/parties.typ

@@ -0,0 +1,124 @@
+// ==========================================
+// DOMAIN: СТОРОНЫ, АДРЕСАТЫ И ПОДПИСАНТЫ
+// ==========================================
+#import "document.typ": fail, ensure-dictionary
+
+#let normalize-text(value, scope, required: false) = {
+  if value == none {
+    if required { fail(scope, "поле обязательно") }
+    none
+  } else if type(value) != str {
+    fail(scope, "ожидалась строка")
+  } else if value.trim() == "" {
+    if required { fail(scope, "поле не может быть пустым") }
+    none
+  } else {
+    value
+  }
+}
+
+#let signer(
+  name,
+  title,
+  basis: none,
+  signature: none,
+  stamp: none,
+) = {
+  for (key, resource) in (("signature", signature), ("stamp", stamp)) {
+    if resource != none and type(resource) not in (path, content) {
+      fail("signer." + key, "ожидались none, path или content")
+    }
+  }
+  (
+    kind: "signer",
+    name: normalize-text(name, "signer.name", required: true),
+    title: normalize-text(title, "signer.title", required: true),
+    basis: normalize-text(basis, "signer.basis"),
+    signature: signature,
+    stamp: stamp,
+  )
+}
+
+#let recipient(
+  company: none,
+  title: none,
+  name: none,
+  address: none,
+) = {
+  let resolved = (
+    kind: "recipient",
+    company: normalize-text(company, "recipient.company"),
+    title: normalize-text(title, "recipient.title"),
+    name: normalize-text(name, "recipient.name"),
+    address: normalize-text(address, "recipient.address"),
+  )
+  if resolved.company == none and resolved.name == none {
+    fail("recipient", "нужно указать организацию или имя адресата")
+  }
+  resolved
+}
+
+#let party(
+  id,
+  role,
+  name,
+  legal: (:),
+  contacts: (:),
+  banking: (:),
+  representative: none,
+) = {
+  if representative != none and (
+    type(representative) != dictionary
+    or representative.at("kind", default: none) != "signer"
+  ) {
+    fail("party.representative", "ожидались none или signer")
+  }
+  (
+    kind: "party",
+    id: normalize-text(id, "party.id", required: true),
+    role: normalize-text(role, "party.role", required: true),
+    name: normalize-text(name, "party.name", required: true),
+    legal: ensure-dictionary(legal, "party.legal"),
+    contacts: ensure-dictionary(contacts, "party.contacts"),
+    banking: ensure-dictionary(banking, "party.banking"),
+    representative: representative,
+  )
+}
+
+#let approval(signer, date: none, label: "УТВЕРЖДАЮ") = {
+  if type(signer) != dictionary or signer.at("kind", default: none) != "signer" {
+    fail("approval.signer", "ожидался signer")
+  }
+  (
+    kind: "approval",
+    signer: signer,
+    date: normalize-text(date, "approval.date"),
+    label: normalize-text(label, "approval.label", required: true),
+  )
+}
+
+#let validate-parties(parties, minimum: 0) = {
+  if type(parties) != array {
+    fail("parties", "ожидался array")
+  }
+  if parties.len() < minimum {
+    fail("parties", "нужно не менее " + str(minimum) + " сторон")
+  }
+  let seen = ()
+  for item in parties {
+    if type(item) != dictionary or item.at("kind", default: none) != "party" {
+      fail("parties", "каждый элемент должен быть party")
+    }
+    if item.id in seen {
+      fail("parties", "повторяющийся id " + repr(item.id))
+    }
+    if item.representative != none and (
+      type(item.representative) != dictionary
+      or item.representative.at("kind", default: none) != "signer"
+    ) {
+      fail("parties", "representative должен быть signer")
+    }
+    seen.push(item.id)
+  }
+  parties
+}

+ 62 - 0
.template/lib/domain/references.typ

@@ -0,0 +1,62 @@
+// ==========================================
+// DOMAIN: БИБЛИОГРАФИЧЕСКИЕ СЕКЦИИ
+// ==========================================
+#import "document.typ": fail
+
+#let bibliography-section(
+  id,
+  sources,
+  title: auto,
+  style: "gost-r-705-2008-numeric",
+  target: auto,
+  group: auto,
+  full: false,
+  page_break: true,
+) = {
+  if type(id) != str or id.trim() == "" {
+    fail("bibliography.id", "нужна непустая строка")
+  }
+  let normalized-sources = if type(sources) == array { sources } else { (sources,) }
+  if normalized-sources.len() == 0 {
+    fail("bibliography.sources", "нужен хотя бы один источник")
+  }
+  for source in normalized-sources {
+    if type(source) not in (str, path, bytes) {
+      fail("bibliography.sources", "ожидались str, path или bytes")
+    }
+  }
+  if type(full) != bool {
+    fail("bibliography.full", "ожидался bool")
+  }
+  if type(page_break) != bool {
+    fail("bibliography.page-break", "ожидался bool")
+  }
+  (
+    kind: "bibliography-section",
+    id: id,
+    sources: normalized-sources,
+    title: title,
+    style: style,
+    target: target,
+    group: group,
+    full: full,
+    page-break: page_break,
+  )
+}
+
+#let validate-bibliographies(sections) = {
+  if type(sections) != array {
+    fail("bibliographies", "ожидался array")
+  }
+  let seen = ()
+  for section in sections {
+    if type(section) != dictionary or section.at("kind", default: none) != "bibliography-section" {
+      fail("bibliographies", "каждый элемент должен быть bibliography-section")
+    }
+    if section.id in seen {
+      fail("bibliographies", "повторяющийся id " + repr(section.id))
+    }
+    seen.push(section.id)
+  }
+  sections
+}

+ 73 - 0
.template/lib/index.typ

@@ -0,0 +1,73 @@
+// ==========================================
+// SCIENTIA DOCUMENTS — ПУБЛИЧНЫЙ ФАСАД
+// ==========================================
+// Единственная точка входа для пользовательских документов.
+
+#import "api.typ" as api
+#import "presentation/profiles/index.typ" as profile-module
+#import "presentation/foundation.typ" as foundation-module
+#import "presentation/components.typ" as component-module
+#import "presentation/references.typ" as reference-module
+#import "presentation/lists.typ" as list-module
+#import "infrastructure/employees.typ" as employee-module
+
+// Сборка документа и доменные конструкторы.
+#let document(
+  body,
+  company: "scientia",
+  profile: profile-module.report(),
+  options: (:),
+) = api.document(body, company: company, profile: profile, options: options)
+#let document-profile = api.document-profile
+#let company-profile = api.company-profile
+#let party = api.party
+#let recipient = api.recipient
+#let signer = api.signer
+#let approval = api.approval
+#let attachment = api.attachment
+#let attachment-set = api.attachment-set
+#let validate-attachment-set = api.validate-attachment-set
+#let bibliography-section = api.bibliography-section
+#let load-company = api.load-company
+#let available-companies = api.available-companies
+#let render-options = api.render-options
+
+// Публичный справочник сотрудников и безопасное подключение private media.
+#let employee-directory = employee-module.employee-directory
+#let empty-private-settings = employee-module.empty-private-settings
+#let private-company-media = employee-module.private-company-media
+#let report-executor = employee-module.report-executor
+
+// Профили доступны и через namespace, и короткими именами.
+#let profiles = profile-module
+#let report = profile-module.report
+#let letter = profile-module.letter
+#let commercial-offer = profile-module.commercial_offer
+#let contract = profile-module.contract
+#let contract-section = profile-module.contract_section
+#let commercial-terms = profile-module.commercial_terms
+
+// Стабильные переиспользуемые элементы представления.
+#let components = component-module
+#let foundation = foundation-module
+#let apply-foundation = foundation-module.apply-foundation
+#let render-media-slot = foundation-module.render-media-slot
+#let corp-table = component-module.corp_table
+#let formula = component-module.formula
+#let company-logo = component-module.company-logo
+#let signature-block = component-module.signature-block
+#let approval-block = component-module.approval-block
+#let requisites-table = component-module.requisites-table
+
+// Настраиваемые многоуровневые списки.
+#let numbered-list = list-module.numbered-list
+#let list-scheme = list-module.list-scheme
+#let list-level = list-module.list-level
+#let list-numbering = list-module.list-numbering
+#let list-schemes = list-module.list-schemes
+
+// Ссылки и библиографии.
+#let references = reference-module
+#let vref = reference-module.vref
+#let vrefs = reference-module.vrefs
+#let eqref = reference-module.eqref

+ 109 - 0
.template/lib/infrastructure/company-assets.typ

@@ -0,0 +1,109 @@
+// ==========================================
+// INFRASTRUCTURE: ПУБЛИЧНЫЕ ДАННЫЕ КОМПАНИЙ
+// ==========================================
+#import "../domain/company.typ": company-profile
+#import "../domain/document.typ": fail
+
+#let available-companies() = ("scientia", "technology", "too", "test-company")
+
+#let company-data-path(id) = {
+  if id == "scientia" {
+    path("../../companies/scientia/data.json")
+  } else if id == "technology" {
+    path("../../companies/technology/data.json")
+  } else if id == "too" {
+    path("../../companies/too/data.json")
+  } else if id == "test-company" {
+    path("../../companies/test-company/data.json")
+  } else {
+    fail(
+      "load-company.id",
+      "неизвестная компания " + repr(id) + "; доступны " + available-companies().join(", "),
+    )
+  }
+}
+
+#let internal-asset(value) = {
+  let value = if value == "" { none } else { value }
+  if value == none {
+    none
+  } else if type(value) == path or type(value) == content {
+    value
+  } else if type(value) == str and value.starts-with("companies/") {
+    path("../../" + value)
+  } else if type(value) == str {
+    fail("company-asset", "путь должен начинаться с companies/: " + repr(value))
+  } else {
+    fail("company-asset", "ожидались none, str, path или content")
+  }
+}
+
+#let external-resource(value, field) = {
+  if value == none or type(value) == path or type(value) == content {
+    value
+  } else {
+    fail("load-company." + field, "ожидались none, path или content")
+  }
+}
+
+#let normalize-company-data(id, raw) = {
+  let jurisdiction = if id == "too" { "KZ" } else { "RU" }
+  let color-value = raw.at("brand-color", default: "#e39f49")
+  let brand-color = if type(color-value) == color { color-value } else { rgb(color-value) }
+
+  company-profile(
+    id,
+    (
+      name: raw.at("name"),
+      short-name: raw.at("short-name", default: raw.at("name")),
+      jurisdiction: jurisdiction,
+      inn: raw.at("inn", default: none),
+      kpp: raw.at("kpp", default: none),
+      ogrn: raw.at("ogrn", default: none),
+      bin: raw.at("bin", default: none),
+      kbe: raw.at("kbe", default: none),
+    ),
+    contacts: (
+      email: raw.at("email", default: none),
+      website: raw.at("website", default: none),
+      phone: raw.at("phone", default: none),
+      address: raw.at("address", default: none),
+      address-en: raw.at("address_en", default: none),
+      city: raw.at("city", default: none),
+      summary: raw.at("company_info", default: none),
+    ),
+    banking: (
+      bank: raw.at("bank", default: none),
+      correspondent-account: raw.at("ks", default: none),
+      bik: raw.at("bik", default: none),
+    ),
+    brand: (color: brand-color),
+    director: (
+      title: raw.at("director-title", default: "Директор"),
+      name: raw.at("director-name", default: none),
+    ),
+    resources: (
+      logo: internal-asset(raw.at("logo_image", default: none)),
+      signature: internal-asset(raw.at("sign_image", default: none)),
+      stamp: internal-asset(raw.at("stamp_image", default: none)),
+    ),
+  )
+}
+
+#let load-company(
+  id,
+  logo: auto,
+  signature: auto,
+  stamp: auto,
+) = {
+  if type(id) != str {
+    fail("load-company.id", "ожидалась строка")
+  }
+  let company = normalize-company-data(id, json(company-data-path(id)))
+  let resources = company.resources
+  if logo != auto { resources.insert("logo", external-resource(logo, "logo")) }
+  if signature != auto { resources.insert("signature", external-resource(signature, "signature")) }
+  if stamp != auto { resources.insert("stamp", external-resource(stamp, "stamp")) }
+  company.insert("resources", resources)
+  company
+}

+ 207 - 0
.template/lib/infrastructure/employees.typ

@@ -0,0 +1,207 @@
+// =============================================================
+// INFRASTRUCTURE: ПУБЛИЧНЫЙ СПРАВОЧНИК СОТРУДНИКОВ И ПОДПИСЕЙ
+// =============================================================
+// ФИО, обычная должность и имя файла не являются приватными.
+// Наличие подписи и её индивидуальное смещение задаются только в
+// игнорируемом Git файле /.private/settings.typ.
+
+#import "../domain/document.typ": fail
+
+#let employee-directory = (
+  musikhin: (
+    name: "Мусихин А.С.",
+    default-role: "Ответственный исполнитель",
+    signature-file: "Musikhin.png",
+  ),
+  guzeev: (
+    name: "Гузеев И.А.",
+    default-role: "Главный геомеханик",
+    signature-file: "Guzeev.png",
+  ),
+  fedorov: (
+    name: "Федоров Д.А.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Fedorov.png",
+  ),
+  ilyasov: (
+    name: "Ильясов Б.Т.",
+    default-role: "Технический директор, к.т.н.",
+    signature-file: "Ilyasov.png",
+  ),
+  khimichev: (
+    name: "Химичев С.С.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Khimichev.png",
+  ),
+  brusnicin: (
+    name: "Брусницын И.В.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Brusnicin.png",
+  ),
+  ozornin: (
+    name: "Озорнин Д.А.",
+    default-role: "Геолог",
+    signature-file: "Ozornin.png",
+  ),
+  buhartdinov: (
+    name: "Бухартдинов А.С.",
+    default-role: "Главный маркшейдер",
+    signature-file: "Buhartdinov.png",
+  ),
+  tkachenko: (
+    name: "Ткаченко А.С.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Tkachenko.png",
+  ),
+  moshin: (
+    name: "Мошин В.Е.",
+    default-role: "Гидрогеолог",
+    signature-file: "Moshin.png",
+  ),
+  mitrokhin: (
+    name: "Митрохин В.А.",
+    default-role: "Главный гидрогеолог",
+    signature-file: "Mitrokhin.png",
+  ),
+  luzina: (
+    name: "Лузина М.В.",
+    default-role: "Геолог",
+    signature-file: "Luzina.png",
+  ),
+  balandin: (
+    name: "Баландин А.",
+    default-role: "Геолог",
+    signature-file: "Balandin.png",
+  ),
+  baigali: (
+    name: "Байгали Р.К.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Baigali.png",
+  ),
+  sugatov: (
+    name: "Сугатов Н.С.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Sugatov.png",
+  ),
+  soluyanov: (
+    name: "Солуянов Н.О.",
+    default-role: "Ведущий геомеханик",
+    signature-file: "Soluyanov.png",
+  ),
+  ecenkov: (
+    name: "ЛЕценков И.А.",
+    default-role: "Инженер-геомеханик",
+    signature-file: "Ecenkov.png",
+  ),
+  savin: (
+    name: "Савин Д.А.",
+    default-role: "Делопроизводитель",
+    signature-file: "Savin.png",
+  ),
+  trescov: (
+    name: "Тресцов Н.Н.",
+    default-role: "Геолог",
+    signature-file: "Trescov.png",
+  ),
+)
+
+#let empty-private-settings = (
+  companies: (:),
+  signatures: (:),
+)
+
+#let validate-private-settings(settings) = {
+  if type(settings) != dictionary {
+    fail("private-settings", "ожидался словарь из .private/settings.typ")
+  }
+  for field in ("companies", "signatures") {
+    if type(settings.at(field, default: (:))) != dictionary {
+      fail("private-settings." + field, "ожидался словарь")
+    }
+  }
+  settings
+}
+
+#let private-company-media(settings, company-id) = {
+  let settings = validate-private-settings(settings)
+  let company = settings.companies.at(company-id, default: none)
+
+  if company == none {
+    (signature: none, stamp: none)
+  } else {
+    if type(company) != dictionary {
+      fail("private-settings.companies." + company-id, "ожидался словарь")
+    }
+    let has-signature = company.at("signature", default: false)
+    let has-stamp = company.at("stamp", default: false)
+    if type(has-signature) != bool or type(has-stamp) != bool {
+      fail(
+        "private-settings.companies." + company-id,
+        "поля signature и stamp должны быть true или false",
+      )
+    }
+    (
+      signature: if has-signature {
+        path("/.private/" + company-id + "/sign.png")
+      } else {
+        none
+      },
+      stamp: if has-stamp {
+        path("/.private/" + company-id + "/stamp.png")
+      } else {
+        none
+      },
+    )
+  }
+}
+
+#let report-executor(employee-id, role: auto, private-settings: empty-private-settings) = {
+  if type(employee-id) != str or not employee-directory.keys().contains(employee-id) {
+    fail(
+      "report-executor.employee-id",
+      "неизвестный сотрудник " + repr(employee-id) + "; доступны "
+        + employee-directory.keys().join(", "),
+    )
+  }
+
+  let person = employee-directory.at(employee-id)
+  let resolved-role = if role == auto { person.default-role } else { role }
+  if type(resolved-role) != str or resolved-role.trim() == "" {
+    fail("report-executor.role", "должность должна быть непустой строкой")
+  }
+
+  let settings = validate-private-settings(private-settings)
+  let signature = settings.signatures.at(employee-id, default: none)
+  let signature-image = none
+  let signature-offset = 0cm
+
+  if signature != none {
+    if type(signature) != dictionary {
+      fail("private-settings.signatures." + employee-id, "ожидался словарь")
+    }
+    let enabled = signature.at("enabled", default: true)
+    if type(enabled) != bool {
+      fail(
+        "private-settings.signatures." + employee-id + ".enabled",
+        "ожидалось true или false",
+      )
+    }
+    if enabled {
+      signature-image = path("/.private/executors/" + person.signature-file)
+      signature-offset = signature.at("offset", default: 0cm)
+      if type(signature-offset) != length {
+        fail(
+          "private-settings.signatures." + employee-id + ".offset",
+          "ожидалась длина, например 1.25cm",
+        )
+      }
+    }
+  }
+
+  (
+    resolved-role,
+    person.name,
+    signature-image,
+    signature-offset,
+  )
+}

+ 105 - 0
.template/lib/numbering.typ

@@ -0,0 +1,105 @@
+// ==========================================
+// НУМЕРАЦИЯ, КИРИЛЛИЦА И ФОРМАТЫ СПИСКОВ
+// ==========================================
+
+// Глобальный массив кириллических букв для нумерации по ГОСТ
+// (обычно исключают Ё, З, Й, О, Ч, Ь, Ы, Ъ)
+#let cyrillic_letters = (
+  "А",
+  "Б",
+  "В",
+  "Г",
+  "Д",
+  "Е",
+  "Ж",
+  "И",
+  "К",
+  "Л",
+  "М",
+  "Н",
+  "П",
+  "Р",
+  "С",
+  "Т",
+  "У",
+  "Ф",
+  "Х",
+  "Ц",
+  "Ш",
+  "Щ",
+  "Э",
+  "Ю",
+  "Я",
+)
+
+#let cyrillic_letters_lower = (
+  "а",
+  "б",
+  "в",
+  "г",
+  "д",
+  "е",
+  "ж",
+  "и",
+  "к",
+  "л",
+  "м",
+  "н",
+  "п",
+  "р",
+  "с",
+  "т",
+  "у",
+  "ф",
+  "х",
+  "ц",
+  "ш",
+  "щ",
+  "э",
+  "ю",
+  "я",
+)
+
+// Фильтр для форматирования счетчика в заглавную букву (Приложение А)
+#let cyrillic_numbering = (..nums) => {
+  let n = nums.pos().first()
+  if n > 0 and n <= cyrillic_letters.len() {
+    cyrillic_letters.at(n - 1)
+  } else {
+    str(n)
+  }
+}
+
+// Формат 1.1. (наследует всех родителей)
+#let num_11 = (..nums) => nums.pos().map(str).join(".") + "."
+
+// Формат 1.а. (наследует всех родителей, чередует цифры и кириллицу)
+#let num_1a = (..nums) => {
+  let pos = nums.pos()
+  let out = ""
+  for (i, n) in pos.enumerate() {
+    if calc.rem(i, 2) == 0 { out += str(n) + "." } else {
+      let letter = if n > 0 and n <= cyrillic_letters_lower.len() {
+        cyrillic_letters_lower.at(n - 1)
+      } else {
+        str(n)
+      }
+      out += letter + "."
+    }
+  }
+  out
+}
+
+// Формат 1., а. (без наследования родителей, чередует цифры и кириллицу)
+#let num_1_a = (..nums) => {
+  let pos = nums.pos()
+  let n = pos.last()
+  if calc.rem(pos.len() - 1, 2) == 0 { str(n) + "." } else {
+    let letter = if n > 0 and n <= cyrillic_letters_lower.len() {
+      cyrillic_letters_lower.at(n - 1)
+    } else {
+      str(n)
+    }
+    letter + "."
+  }
+}

+ 383 - 0
.template/lib/presentation/components.typ

@@ -0,0 +1,383 @@
+// ==========================================
+// PRESENTATION: ПЕРЕИСПОЛЬЗУЕМЫЕ КОМПОНЕНТЫ
+// ==========================================
+#import "../components.typ": corp_table, formula
+#import "foundation.typ": render-media-slot
+#import "../shared/numbering.typ": attachment_numbering
+
+#let letter-accent = rgb("fbb20d")
+
+#let footer-icon(name, color) = {
+  box(width: 20pt, height: 20pt, fill: color, radius: 2pt)[
+    #align(center + horizon)[
+      #image(
+        path("../assets/icons/" + name + ".png"),
+        width: 15pt,
+        height: 15pt,
+        fit: "contain",
+      )
+    ]
+  ]
+}
+
+#let company-logo(company, width: 4cm, height: 2.5cm, policy: "placeholder") = {
+  render-media-slot(
+    company.resources.logo,
+    policy: policy,
+    width: width,
+    height: height,
+    label: "ЛОГОТИП",
+  )
+}
+
+#let signing-media(stamp, signature, policy, show_stamp: true, offset: -1cm) = {
+  let has-missing-media = signature == none or (show_stamp and stamp == none)
+  let stamp-resource = if stamp == none and policy == "placeholder" {
+    path("../assets/placeholders/stamp.svg")
+  } else {
+    stamp
+  }
+  let signature-resource = if signature == none and policy == "placeholder" {
+    path("../assets/placeholders/signature.svg")
+  } else {
+    signature
+  }
+  if policy == "placeholder" and has-missing-media {
+    if show_stamp {
+      place(center, dx: -1.6cm, dy: offset)[
+        #render-media-slot(
+          stamp-resource,
+          policy: policy,
+          width: 2.8cm,
+          height: 2.8cm,
+          label: "ПЕЧАТЬ",
+        )
+      ]
+    }
+    place(center, dx: if show_stamp { 1.6cm } else { 0cm }, dy: offset)[
+        #render-media-slot(
+          signature-resource,
+          policy: policy,
+          width: 2.8cm,
+          height: 1.4cm,
+          label: "ПОДПИСЬ",
+        )
+      ]
+  } else {
+    if show_stamp {
+      place(center, dx: -0.5cm, dy: offset)[
+        #render-media-slot(
+          stamp-resource,
+          policy: policy,
+          width: 3.5cm,
+          height: 3.5cm,
+          label: "МЕСТО ПЕЧАТИ",
+        )
+      ]
+    }
+    place(center, dx: if show_stamp { 1.5cm } else { 0cm }, dy: offset)[
+        #render-media-slot(
+          signature-resource,
+          policy: policy,
+          width: 3.5cm,
+          height: 2.5cm,
+          label: "МЕСТО ПОДПИСИ",
+        )
+      ]
+  }
+}
+
+#let signature-block(
+  signer,
+  company: none,
+  media_policy: "placeholder",
+  show_stamp: true,
+  note: none,
+) = {
+  let signature = signer.at(
+    "signature",
+    default: if company == none { none } else { company.resources.signature },
+  )
+  let stamp = signer.at(
+    "stamp",
+    default: if company == none { none } else { company.resources.stamp },
+  )
+
+  set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
+  let left-content = [
+    #signer.title
+    #if company != none { [\ #company.legal.name] }
+  ]
+  let right-content = [#signer.name]
+  let media-content = signing-media(
+    stamp,
+    signature,
+    media_policy,
+    show_stamp: show_stamp,
+  )
+  v(2cm)
+  layout(size => {
+    let left-width = measure(left-content).width
+    let right-width = measure(right-content).width
+    let roomy = left-width + right-width + 5.5cm <= size.width
+    if roomy {
+      grid(
+        columns: (auto, 1fr, auto),
+        align: (left + bottom, center + bottom, right + bottom),
+        left-content,
+        media-content,
+        right-content,
+      )
+    } else {
+      grid(
+        columns: (1fr, 6.5cm, 1fr),
+        column-gutter: 8pt,
+        align: (left + bottom, center + bottom, right + bottom),
+        left-content,
+        media-content,
+        right-content,
+      )
+    }
+  })
+  if note != none {
+    text(font: "Arial", size: 7.5pt, fill: luma(90))[#note]
+  }
+}
+
+#let approval-block(approval, company, media_policy: "placeholder") = {
+  let signer = approval.signer
+  let signature = signer.at("signature", default: company.resources.signature)
+  let signature-resource = if signature == none and media_policy == "placeholder" {
+    path("../assets/placeholders/signature.svg")
+  } else {
+    signature
+  }
+  set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
+  box(width: 8cm)[
+    #align(right)[
+      #text(weight: "bold")[#approval.label] \
+      #signer.title \
+      #company.legal.name \
+      #v(0.2cm)
+      #render-media-slot(
+        signature-resource,
+        policy: media_policy,
+        width: 3cm,
+        height: 1.2cm,
+        label: "ПОДПИСЬ",
+      ) \
+      #signer.name \
+      #if approval.date != none { approval.date }
+    ]
+  ]
+}
+
+#let company-footer(company) = {
+  let contacts = company.contacts
+  let banking = company.banking
+  let contact-lines = ()
+  if contacts.phone != none { contact-lines.push(contacts.phone) }
+  if contacts.email != none { contact-lines.push(contacts.email) }
+  if contacts.website != none { contact-lines.push(contacts.website) }
+  let id-lines = ()
+  for (key, label) in (
+    ("inn", "ИНН"),
+    ("kpp", "КПП"),
+    ("ogrn", "ОГРН"),
+    ("bin", "БИН"),
+    ("kbe", "КБЕ"),
+  ) {
+    let value = company.legal.at(key, default: none)
+    if value != none { id-lines.push([#label #value]) }
+  }
+  let banking-lines = ()
+  if banking.bank != none { banking-lines.push([Банк: #banking.bank]) }
+  if banking.at("correspondent-account", default: none) != none {
+    banking-lines.push([Кор. счёт: #banking.at("correspondent-account")])
+  }
+  if banking.bik != none { banking-lines.push([БИК: #banking.bik]) }
+  if contacts.at("address-en", default: none) != none {
+    banking-lines.push(contacts.at("address-en"))
+  }
+  set text(font: "Arial", size: 7.5pt, fill: luma(85))
+  set par(first-line-indent: 0pt, justify: false, leading: 0.2em)
+  grid(
+    columns: (1fr, 1.2fr),
+    column-gutter: 15pt,
+    [
+      #grid(
+        columns: (20pt, 1fr),
+        rows: (auto, auto),
+        column-gutter: 8pt,
+        row-gutter: 8pt,
+        if contact-lines.len() == 0 { none } else { footer-icon("email", company.brand.color) },
+        contact-lines.join([\ ]),
+        if contacts.address == none { none } else { footer-icon("location", company.brand.color) },
+        if contacts.address == none { none } else { contacts.address },
+      )
+    ],
+    grid(
+      columns: (1fr, 20pt),
+      rows: (auto, auto),
+      column-gutter: 8pt,
+      row-gutter: 8pt,
+      align: (right, center + horizon),
+      align(right, id-lines.join([\ ])),
+      if id-lines.len() == 0 { none } else { footer-icon("card", company.brand.color) },
+      align(right, banking-lines.join([\ ])),
+      if banking-lines.len() == 0 { none } else { footer-icon("bank", company.brand.color) },
+    ),
+  )
+}
+
+#let letter-header(
+  company,
+  recipient,
+  date: none,
+  reference: none,
+  title: none,
+  media_policy: "placeholder",
+) = {
+  set par(first-line-indent: 0pt, justify: false, leading: 0.65em)
+  place(top + left, dx: 10cm, dy: -1.5cm)[
+    #rect(
+      width: 6.5cm,
+      height: 2cm,
+      fill: letter-accent,
+      radius: (bottom: 2pt),
+    )
+  ]
+  v(30pt)
+  box(width: 4cm, height: 2.5cm)[
+    #company-logo(company, width: 4cm, height: 2.5cm, policy: media_policy)
+  ]
+  v(-50pt)
+  v(2.5cm)
+  grid(
+    columns: (1.4fr, 1fr),
+    [
+      #if reference != none { [Исх. № #reference] }
+      #if date != none { [ от #date] }
+    ],
+    [
+      #if recipient.company != none { [#recipient.company\ ] }
+      #if recipient.title != none { [#recipient.title\ ] }
+      #if recipient.name != none { recipient.name }
+      #if recipient.address != none { [\ #recipient.address] }
+    ],
+  )
+  v(0.7cm)
+  if title != none {
+    align(center)[#text(weight: "bold")[#upper(title)]]
+    v(0.2cm)
+  }
+}
+
+#let attachment-list(attachments) = {
+  let items = if type(attachments) == dictionary { attachments.items } else { attachments }
+  if items.len() > 0 {
+    set par(first-line-indent: 0pt, justify: false)
+    for (index, item) in items.enumerate() {
+      let number = if item.number == auto { index + 1 } else { item.number }
+      [Приложение #number: #item.title]
+      if index < items.len() - 1 { linebreak() }
+    }
+  }
+}
+
+#let requisites-table(parties) = {
+  let field-labels = (
+    inn: "ИНН",
+    kpp: "КПП",
+    ogrn: "ОГРН",
+    bin: "БИН",
+    kbe: "КБе",
+    address: "Юридический адрес",
+    legal-address: "Юридический адрес",
+    phone: "Телефон",
+    email: "E-mail",
+    website: "Сайт",
+    bank: "Банк",
+    account: "Расчётный счёт",
+    settlement-account: "Расчётный счёт",
+    correspondent-account: "Корреспондентский счёт",
+    ks: "Корреспондентский счёт",
+    bik: "БИК",
+    iban: "IBAN",
+  )
+  let hidden-fields = (
+    "name",
+    "short-name",
+    "jurisdiction",
+    "summary",
+    "address-en",
+    "city",
+  )
+  let render-fields(fields) = {
+    for (key, value) in fields {
+      if value != none and key not in hidden-fields {
+        let label = field-labels.at(key, default: key)
+        [#label: #value\ ]
+      }
+    }
+  }
+  let columns = range(parties.len()).map(_ => 1fr)
+  table(
+    columns: columns,
+    stroke: 0.5pt,
+    inset: 6pt,
+    ..parties.map(item => [
+      #set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
+      #text(weight: "bold")[#item.role] \
+      #item.name \
+      #render-fields(item.legal)
+      #render-fields(item.contacts)
+      #render-fields(item.banking)
+    ]),
+  )
+}
+
+#let multi-party-signing(parties, media_policy: "reserve-space") = {
+  let columns = range(parties.len()).map(_ => 1fr)
+  grid(
+    columns: columns,
+    column-gutter: 16pt,
+    ..parties.map(item => {
+      let person = item.representative
+      [
+        #set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
+        #if person != none {
+          grid(
+            columns: (1fr,),
+            rows: (auto, 0.35cm, 3.5cm, 0.25cm, auto),
+            [#text(weight: "bold")[#item.role] \ #person.title],
+            [],
+            block(width: 100%, height: 3.5cm)[
+              #signing-media(
+                person.stamp,
+                person.signature,
+                media_policy,
+                show_stamp: true,
+                offset: 0cm,
+              )
+            ],
+            [],
+            [#person.name],
+          )
+        } else {
+          text(weight: "bold")[#item.role]
+        }
+      ]
+    }),
+  )
+}
+
+#let appendix-heading(item, display-number) = {
+  set par(first-line-indent: 0pt, justify: false, leading: 0.75em)
+  align(right)[Приложение #display-number]
+  v(0.2cm)
+  align(center)[
+    #text(weight: "bold")[#upper(item.title)]
+    #if item.subtitle != none { [\ #text(weight: "bold")[#item.subtitle]] }
+  ]
+}

+ 119 - 0
.template/lib/presentation/foundation.typ

@@ -0,0 +1,119 @@
+// ==========================================
+// PRESENTATION: ОБЩАЯ ОСНОВА ВЁРСТКИ
+// ==========================================
+
+#let design-tokens(company, overrides: (:)) = {
+  let defaults = (
+    brand-color: company.brand.at("color", default: rgb("e39f49")),
+    text-font: "Times New Roman",
+    sans-font: "Arial",
+    text-size: 12pt,
+    small-size: 8pt,
+    line-height: 1.5em,
+    compact-line-height: 0.65em,
+    paper: "a4",
+    page-margin: (left: 3cm, right: 1.5cm, top: 2cm, bottom: 2cm),
+  )
+  let result = defaults
+  for (key, value) in overrides {
+    if key not in defaults {
+      panic("Scientia / design-tokens: неизвестный token " + repr(key))
+    }
+    result.insert(key, value)
+  }
+  result
+}
+
+#let watermark-layer(value, font: "Arial") = {
+  if value == none {
+    none
+  } else {
+    [
+      #place(center + horizon, dx: -4.8cm, dy: -8.5cm)[
+        #rotate(35deg)[#text(font: font, size: 40pt, weight: "bold", fill: rgb("e8c9c9").transparentize(65%))[#value]]
+      ]
+      #place(center + horizon)[
+        #rotate(35deg)[#text(font: font, size: 40pt, weight: "bold", fill: rgb("e8c9c9").transparentize(65%))[#value]]
+      ]
+      #place(center + horizon, dx: 4.8cm, dy: 8.5cm)[
+        #rotate(35deg)[#text(font: font, size: 40pt, weight: "bold", fill: rgb("e8c9c9").transparentize(65%))[#value]]
+      ]
+    ]
+  }
+}
+
+#let render-media-slot(
+  resource,
+  policy: "placeholder",
+  width: 4cm,
+  height: 2cm,
+  label: "РЕСУРС НЕ ЗАДАН",
+  fit: "contain",
+) = {
+  if policy not in ("hide", "placeholder", "reserve-space") {
+    panic("Scientia / media-slot: неизвестная политика " + repr(policy))
+  }
+
+  if resource == none {
+    if policy == "hide" {
+      none
+    } else if policy == "reserve-space" {
+      box(width: width, height: height)
+    } else {
+      box(
+        width: width,
+        height: height,
+        stroke: 0.6pt + luma(150),
+        fill: luma(245),
+        inset: 4pt,
+        align(center + horizon)[
+          #text(font: "Arial", size: 7pt, fill: luma(110), weight: "bold")[#label]
+        ],
+      )
+    }
+  } else if type(resource) == path {
+    image(resource, width: width, height: height, fit: fit)
+  } else if type(resource) == content {
+    box(width: width, height: height, align(center + horizon, resource))
+  } else {
+    panic("Scientia / media-slot: ожидались none, path или content")
+  }
+}
+
+#let apply-foundation(
+  body,
+  ctx: none,
+  tokens: (:),
+  page-options: (:),
+  text-options: (:),
+  par-options: (:),
+) = {
+  if ctx == none {
+    panic("Scientia / foundation: document context обязателен")
+  }
+  let tokens = design-tokens(ctx.company, overrides: tokens)
+  let page-defaults = (
+    paper: tokens.paper,
+    margin: tokens.at("page-margin"),
+    foreground: watermark-layer(ctx.options.watermark, font: tokens.at("sans-font")),
+  )
+  let resolved-page = page-defaults
+  for (key, value) in page-options { resolved-page.insert(key, value) }
+
+  set page(..resolved-page)
+  set text(
+    font: tokens.at("text-font"),
+    size: tokens.at("text-size"),
+    lang: "ru",
+    ..text-options,
+  )
+  set par(
+    justify: true,
+    first-line-indent: (amount: 1.25cm, all: true),
+    leading: tokens.at("line-height"),
+    ..par-options,
+  )
+  show table.cell: set par(first-line-indent: 0pt)
+  show figure.caption.where(kind: table): set par(first-line-indent: 0pt)
+  body
+}

+ 256 - 0
.template/lib/presentation/lists.typ

@@ -0,0 +1,256 @@
+// ==========================================
+// PRESENTATION: НАСТРАИВАЕМЫЕ СПИСКИ
+// ==========================================
+
+// Кириллическая последовательность по ГОСТ: буквы, которые легко спутать
+// с цифрами или другими обозначениями, намеренно пропущены.
+#let cyrillic-upper = (
+  "А", "Б", "В", "Г", "Д", "Е", "Ж", "И", "К", "Л", "М", "Н",
+  "П", "Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ш", "Щ", "Э", "Ю", "Я",
+)
+#let cyrillic-lower = cyrillic-upper.map(lower)
+
+#let alphabetic-number(value, alphabet) = {
+  if type(value) != int or value < 1 {
+    str(value)
+  } else {
+    let current = value
+    let result = ""
+    while current > 0 {
+      current -= 1
+      result = alphabet.at(calc.rem(current, alphabet.len())) + result
+      current = calc.floor(current / alphabet.len())
+    }
+    result
+  }
+}
+
+#let padded-number(value, width) = {
+  let raw = str(value)
+  "0" * calc.max(width - raw.len(), 0) + raw
+}
+
+// Расширенное описание одного уровня. В простых случаях достаточно строки
+// в levels: "1", "I", "i", "A", "a", "01", "А", "а" или маркера.
+#let list-level(style, prefix: [], suffix: auto, width: auto) = (
+  kind: "list-level",
+  style: style,
+  prefix: prefix,
+  suffix: suffix,
+  width: width,
+)
+
+#let list-scheme(
+  levels: ("1", "а", "1"),
+  full: false,
+  separators: [],
+  suffixes: (".", ")", ")"),
+) = {
+  if type(levels) != array or levels.len() == 0 {
+    panic("Scientia / list-scheme.levels: нужен непустой array")
+  }
+  if type(full) != bool {
+    panic("Scientia / list-scheme.full: ожидался bool")
+  }
+  (
+    kind: "list-scheme",
+    levels: levels,
+    full: full,
+    separators: separators,
+    suffixes: suffixes,
+  )
+}
+
+#let list-schemes = (
+  // 1. -> а) -> 1)
+  gost: list-scheme(),
+  // 1. -> 1.1. -> 1.1.1.
+  decimal: list-scheme(
+    levels: ("1",),
+    full: true,
+    separators: ".",
+    suffixes: ".",
+  ),
+  // 1 -> 1.1 -> 1.1.1
+  decimal-plain: list-scheme(
+    levels: ("1",),
+    full: true,
+    separators: ".",
+    suffixes: [],
+  ),
+  // 1. -> а. -> ‣
+  local-mixed: list-scheme(
+    levels: ("1", "а", "‣"),
+    suffixes: (".", ".", []),
+  ),
+  // 1. -> 1.а. -> 1.а.‣
+  full-mixed: list-scheme(
+    levels: ("1", "а", "‣"),
+    full: true,
+    separators: ".",
+    suffixes: (".", ".", []),
+  ),
+  // A) -> A)1.
+  legal: list-scheme(
+    levels: ("A", "1"),
+    full: true,
+    separators: ")",
+    suffixes: (")", "."),
+  ),
+  bullets: list-scheme(
+    levels: ("•", "∙", "‣", "⁃", "◦"),
+    suffixes: [],
+  ),
+)
+
+#let sequence-value(values, index, scope) = {
+  let values = if type(values) == array { values } else { (values,) }
+  if values.len() == 0 {
+    panic("Scientia / " + scope + ": последовательность не должна быть пустой")
+  }
+  values.at(calc.min(index, values.len() - 1))
+}
+
+#let resolve-list-scheme(
+  scheme,
+  levels: auto,
+  full: auto,
+  separators: auto,
+  suffixes: auto,
+) = {
+  let base = if type(scheme) == str {
+    if scheme not in list-schemes {
+      panic(
+        "Scientia / numbered-list.scheme: неизвестная схема " + repr(scheme)
+        + "; доступны " + list-schemes.keys().map(repr).join(", "),
+      )
+    }
+    list-schemes.at(scheme)
+  } else if type(scheme) == dictionary and scheme.at("kind", default: none) == "list-scheme" {
+    scheme
+  } else {
+    panic("Scientia / numbered-list.scheme: ожидались имя схемы или list-scheme")
+  }
+  list-scheme(
+    levels: if levels == auto { base.levels } else { levels },
+    full: if full == auto { base.full } else { full },
+    separators: if separators == auto { base.separators } else { separators },
+    suffixes: if suffixes == auto { base.suffixes } else { suffixes },
+  )
+}
+
+#let render-level-core(level, value) = {
+  let description = if type(level) == dictionary and level.at("kind", default: none) == "list-level" {
+    level
+  } else {
+    list-level(level)
+  }
+  let style = description.style
+  let width = description.width
+  let core = if type(style) == function {
+    style(value)
+  } else if style == "1" {
+    if width == auto { str(value) } else { padded-number(value, width) }
+  } else if style == "01" {
+    padded-number(value, if width == auto { 2 } else { width })
+  } else if style == "I" or style == "i" or style == "A" or style == "a" {
+    numbering(style, value)
+  } else if style == "А" {
+    alphabetic-number(value, cyrillic-upper)
+  } else if style == "а" {
+    alphabetic-number(value, cyrillic-lower)
+  } else if type(style) == str or type(style) == content {
+    style
+  } else {
+    panic("Scientia / list-level.style: ожидались строка, content или function")
+  }
+  [#description.prefix#core]
+}
+
+#let level-suffix(config, depth) = {
+  let level = sequence-value(config.levels, depth, "list-scheme.levels")
+  if type(level) == dictionary and level.at("kind", default: none) == "list-level" and level.suffix != auto {
+    level.suffix
+  } else {
+    sequence-value(config.suffixes, depth, "list-scheme.suffixes")
+  }
+}
+
+#let list-numbering(
+  scheme: "gost",
+  levels: auto,
+  full: auto,
+  separators: auto,
+  suffixes: auto,
+) = {
+  let config = resolve-list-scheme(
+    scheme,
+    levels: levels,
+    full: full,
+    separators: separators,
+    suffixes: suffixes,
+  )
+  (..numbers) => {
+    let values = numbers.pos()
+    let depth = values.len() - 1
+    let visible = if config.full { range(values.len()) } else { (depth,) }
+    let parts = ()
+    for (position, index) in visible.enumerate() {
+      if position > 0 {
+        parts.push(sequence-value(config.separators, index - 1, "list-scheme.separators"))
+      }
+      let level = sequence-value(config.levels, index, "list-scheme.levels")
+      parts.push(render-level-core(level, values.at(index)))
+    }
+    parts.push(level-suffix(config, depth))
+    parts.join()
+  }
+}
+
+// Оболочка сохраняет нативный enum: вложенность, переносы страниц,
+// многоабзацные пункты и явные начальные номера продолжают работать штатно.
+#let numbered-list(
+  body,
+  scheme: "gost",
+  levels: auto,
+  full: auto,
+  separators: auto,
+  suffixes: auto,
+  outer-indent: 0pt,
+  level-indent: 0.55cm,
+  body-indent: 0.3em,
+  line-leading: 0.55em,
+  item-spacing: 0.85em,
+  paragraph-spacing: 0pt,
+  number-align: end,
+) = {
+  let numbering = list-numbering(
+    scheme: scheme,
+    levels: levels,
+    full: full,
+    separators: separators,
+    suffixes: suffixes,
+  )
+  block(width: 100%)[
+    #set enum(
+      full: true,
+      numbering: numbering,
+      indent: level-indent,
+      body-indent: body-indent,
+      spacing: item-spacing,
+      number-align: number-align,
+    )
+    // Важно применять параметры абзаца ко всему enum. Show-правило для
+    // enum.item повторно контекстуализирует каждый вложенный пункт и в Typst
+    // 0.15 сбрасывает его локальный счётчик на 1.
+    #show enum: it => {
+      set par(
+        first-line-indent: 0pt,
+        leading: line-leading,
+        spacing: paragraph-spacing,
+      )
+      it
+    }
+    #pad(left: outer-indent)[#body]
+  ]
+}

+ 203 - 0
.template/lib/presentation/profiles/commercial-offer.typ

@@ -0,0 +1,203 @@
+// ==========================================
+// PROFILE: ТЕХНИКО-КОММЕРЧЕСКОЕ ПРЕДЛОЖЕНИЕ
+// ==========================================
+#import "../../domain/document.typ": document-profile, fail
+#import "../../domain/attachments.typ": attachment-set
+#import "../../domain/parties.typ": signer
+#import "../foundation.typ": apply-foundation
+#import "../components.typ": attachment-list, appendix-heading, company-footer, letter-header, signature-block
+
+#let commercial-terms(
+  amount: none,
+  currency: "RUB",
+  tax_note: none,
+  delivery_term: none,
+  validity: none,
+  payment_terms: none,
+) = {
+  (
+    amount: amount,
+    currency: currency,
+    tax-note: tax_note,
+    delivery-term: delivery_term,
+    validity: validity,
+    payment-terms: payment_terms,
+  )
+}
+
+#let normalize-offer(metadata) = {
+  if type(metadata.attachments) == array {
+    metadata.insert("attachments", attachment-set(items: metadata.attachments))
+  }
+  metadata
+}
+
+#let validate-offer(metadata) = {
+  if metadata.recipient == none or type(metadata.recipient) != dictionary or metadata.recipient.at("kind", default: none) != "recipient" {
+    fail("commercial-offer.recipient", "ожидался recipient")
+  }
+  if type(metadata.subject) != str or metadata.subject.trim() == "" {
+    fail("commercial-offer.subject", "предмет предложения обязателен")
+  }
+  if type(metadata.terms) != dictionary {
+    fail("commercial-offer.terms", "ожидался commercial-terms")
+  }
+  if metadata.terms.amount == none {
+    fail("commercial-offer.terms.amount", "стоимость обязательна")
+  }
+  if type(metadata.attachments) != dictionary or metadata.attachments.at("kind", default: none) != "attachment-set" {
+    fail("commercial-offer.attachments", "ожидался attachment-set")
+  }
+  metadata
+}
+
+#let effective-offer-signer(data, company, include-media: true) = {
+  let source = if data.signer == none {
+    signer(
+      company.director.name,
+      company.director.title,
+      signature: company.resources.signature,
+      stamp: company.resources.stamp,
+    )
+  } else {
+    data.signer
+  }
+  signer(
+    source.name,
+    source.title,
+    basis: source.basis,
+    signature: if include-media { source.signature } else { none },
+    stamp: if include-media { source.stamp } else { none },
+  )
+}
+
+#let offer-summary(data) = {
+  let terms = data.terms
+  set par(first-line-indent: 0pt, justify: false, leading: 0.7em)
+  table(
+    columns: (4cm, 1fr),
+    stroke: 0.5pt + luma(170),
+    inset: 6pt,
+    fill: (x, _) => if x == 0 { luma(245) } else { none },
+    [*Предмет*], [#data.subject],
+    [*Стоимость*], [#terms.amount #terms.currency],
+    ..if terms.at("tax-note") != none { ([*Налоги*], [#terms.at("tax-note")]) } else { () },
+    ..if terms.at("delivery-term") != none { ([*Срок выполнения*], [#terms.at("delivery-term")]) } else { () },
+    ..if terms.validity != none { ([*Срок действия*], [#terms.validity]) } else { () },
+    ..if terms.at("payment-terms") != none { ([*Порядок оплаты*], [#terms.at("payment-terms")]) } else { () },
+  )
+}
+
+#let render-offer-attachments(attachments, appendix-state) = {
+  for (index, item) in attachments.items.enumerate() {
+    pagebreak()
+    appendix-state.update(true)
+    let number = if item.number == auto { index + attachments.start } else { item.number }
+    appendix-heading(item, str(number))
+    v(0.5cm)
+    if type(item.body) == function { (item.body)() } else { item.body }
+  }
+}
+
+#let render-commercial-offer(body, ctx) = {
+  let company = ctx.company
+  let data = ctx.metadata
+  let include-media = ctx.options.mode == "final"
+  let person = effective-offer-signer(data, company, include-media: include-media)
+  let media-policy = if include-media { ctx.options.at("media-policy") } else { "reserve-space" }
+  let appendix-state = state("scientia-offer-in-appendix", false)
+  let footer = context {
+    if not appendix-state.get() { company-footer(company) }
+  }
+  let composed = [
+    #letter-header(
+      company,
+      data.recipient,
+      date: data.date,
+      reference: data.reference,
+      title: data.title,
+      media_policy: ctx.options.at("media-policy"),
+    )
+    #v(0.6cm)
+    #offer-summary(data)
+    #v(0.7cm)
+    #body
+    #if data.attachments.items.len() > 0 {
+      v(0.8cm)
+      attachment-list(data.attachments)
+    }
+    #signature-block(
+      person,
+      company: company,
+      media_policy: media-policy,
+      show_stamp: data.at("show-stamp"),
+      note: data.note,
+    )
+    #if data.at("render-attachments") and data.attachments.items.len() > 0 {
+      render-offer-attachments(data.attachments, appendix-state)
+    }
+  ]
+
+  apply-foundation(
+    composed,
+    ctx: ctx,
+    tokens: (
+      text-font: "Arial",
+      text-size: 10pt,
+      line-height: 0.65em,
+      page-margin: (top: 1.5cm, bottom: 4cm, left: 1.5cm, right: 1.5cm),
+    ),
+    page-options: (
+      footer: footer,
+      footer-descent: 20%,
+    ),
+  )
+}
+
+#let commercial-offer-profile(
+  recipient: none,
+  subject: "",
+  amount: none,
+  currency: "RUB",
+  tax_note: none,
+  delivery_term: none,
+  validity: none,
+  payment_terms: none,
+  date: none,
+  reference: none,
+  title: "Технико-коммерческое предложение",
+  signer: none,
+  note: none,
+  show_stamp: true,
+  attachments: attachment-set(),
+  render_attachments: true,
+) = {
+  document-profile(
+    "commercial-offer",
+    render-commercial-offer,
+    metadata: (
+      recipient: recipient,
+      subject: subject,
+      terms: commercial-terms(
+        amount: amount,
+        currency: currency,
+        tax_note: tax_note,
+        delivery_term: delivery_term,
+        validity: validity,
+        payment_terms: payment_terms,
+      ),
+      date: date,
+      reference: reference,
+      title: title,
+      signer: signer,
+      note: note,
+      show-stamp: show_stamp,
+      parties: (),
+      attachments: attachments,
+      bibliographies: (),
+      render-attachments: render_attachments,
+    ),
+    normalize: normalize-offer,
+    validate: validate-offer,
+  )
+}

Some files were not shown because too many files changed in this diff