rpr-cli 0.1.25__tar.gz → 0.1.26__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/PKG-INFO +8 -4
  2. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/README.md +7 -3
  3. rpr_cli-0.1.26/Sample-dot-github/scaffolds/frontend/virtual-list/VirtualComponent.spec.tsx +147 -0
  4. rpr_cli-0.1.26/Sample-dot-github/scaffolds/frontend/virtual-list/VirtualComponent.tsx +94 -0
  5. rpr_cli-0.1.26/Sample-dot-github/scaffolds/frontend/virtual-list/VirtualComponentList.spec.tsx +277 -0
  6. rpr_cli-0.1.26/Sample-dot-github/scaffolds/frontend/virtual-list/VirtualComponentList.tsx +112 -0
  7. rpr_cli-0.1.26/Sample-dot-github/scaffolds/frontend/virtual-list/index.ts +1 -0
  8. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/code-map.md +6 -6
  9. rpr_cli-0.1.26/plan-languageAwareTemplateAdd.prompt.md +63 -0
  10. rpr_cli-0.1.26/src/rpr/__init__.py +1 -0
  11. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/checks/instructions.py +2 -1
  12. rpr_cli-0.1.26/src/rpr/commands/add.py +298 -0
  13. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/generate/domain.py +2 -2
  14. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/generate/ui.py +5 -2
  15. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/js_special_files/sticky-navigation.md +15 -9
  16. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/sticky-navigation.spec.md +231 -0
  17. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/virtual-component-list.md +145 -0
  18. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/virtual-component-list.spec.md +304 -0
  19. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/virtual-component.md +110 -0
  20. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/virtual-component.spec.md +168 -0
  21. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/virtual-list-index.md +9 -0
  22. rpr_cli-0.1.26/src/rpr/scaffolds/js_special_files/vitest-browser-config.md +24 -0
  23. rpr_cli-0.1.26/src/rpr/templates/registry.py +232 -0
  24. rpr_cli-0.1.26/tests/test_add.py +367 -0
  25. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_check.py +7 -0
  26. rpr_cli-0.1.25/src/rpr/__init__.py +0 -1
  27. rpr_cli-0.1.25/src/rpr/commands/add.py +0 -166
  28. rpr_cli-0.1.25/src/rpr/templates/registry.py +0 -81
  29. rpr_cli-0.1.25/tests/test_add.py +0 -155
  30. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/.github/agents/story.agent.md +0 -0
  31. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/.github/workflows/publish-pypi.yml +0 -0
  32. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/.gitignore +0 -0
  33. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/CLAUDE.md +0 -0
  34. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/GEMINI.md +0 -0
  35. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/LICENSE +0 -0
  36. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/all.instructions.md +0 -0
  37. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/api.instructions.md +0 -0
  38. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/domain.instructions.md +0 -0
  39. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/frontend.instructions.md +0 -0
  40. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/rust-engine.instructions.md +0 -0
  41. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/setup-guide.instructions.md +0 -0
  42. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/tooling-setup.instructions.md +0 -0
  43. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/instructions/tooling.instructions.md +0 -0
  44. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/scaffolds/README.md +0 -0
  45. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/scaffolds/api/main.py +0 -0
  46. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/scaffolds/domain/base-repo.md +0 -0
  47. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/scaffolds/domain/base_entity.md +0 -0
  48. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/scaffolds/frontend/api.service.ts +0 -0
  49. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/Sample-dot-github/scaffolds/frontend/useStateNavigation.ts +0 -0
  50. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/changelogs/mui_v9.txt +0 -0
  51. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/nx_uv_setup.md +0 -0
  52. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/patch_output.py +0 -0
  53. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/pyproject.toml +0 -0
  54. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/Cargo.lock +0 -0
  55. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/Cargo.toml +0 -0
  56. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/pyproject.toml +0 -0
  57. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/rpr_parser.pyi +0 -0
  58. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/config/container.rs +0 -0
  59. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/config/mod.rs +0 -0
  60. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/config/settings.rs +0 -0
  61. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/graph.rs +0 -0
  62. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/lib.rs +0 -0
  63. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/models/dtos/mod.rs +0 -0
  64. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/models/entities/mod.rs +0 -0
  65. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/models/entities/users.rs +0 -0
  66. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/models/mod.rs +0 -0
  67. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/parse.rs +0 -0
  68. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/repos/base.rs +0 -0
  69. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/repos/mod.rs +0 -0
  70. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/repos/users.rs +0 -0
  71. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/resolve.rs +0 -0
  72. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/src/walker.rs +0 -0
  73. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/rpr-parser/uv.lock +0 -0
  74. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/special_files.md +0 -0
  75. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/__init__.py +0 -0
  76. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/approval.py +0 -0
  77. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/bootstrap.py +0 -0
  78. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/client.py +0 -0
  79. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/control.py +0 -0
  80. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/mock.py +0 -0
  81. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/runtime.py +0 -0
  82. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/session.py +0 -0
  83. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/tools/__init__.py +0 -0
  84. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/tools/base.py +0 -0
  85. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/tools/mutating.py +0 -0
  86. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/tools/readonly.py +0 -0
  87. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/agent/tools/registry.py +0 -0
  88. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/__init__.py +0 -0
  89. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/catalog.py +0 -0
  90. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/chat_service.py +0 -0
  91. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/checks.py +0 -0
  92. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/cli_adapter.py +0 -0
  93. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/completer.py +0 -0
  94. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/conversation_service.py +0 -0
  95. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/prompt_service.py +0 -0
  96. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/selector.py +0 -0
  97. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/application/shell.py +0 -0
  98. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/checks/__init__.py +0 -0
  99. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/checks/base.py +0 -0
  100. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/checks/packages.py +0 -0
  101. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/checks/workspace.py +0 -0
  102. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/cli.py +0 -0
  103. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/__init__.py +0 -0
  104. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/chat.py +0 -0
  105. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/check.py +0 -0
  106. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/generate/__init__.py +0 -0
  107. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/generate/api.py +0 -0
  108. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/generate/engine.py +0 -0
  109. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/generate/storybook.py +0 -0
  110. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/init.py +0 -0
  111. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/map.py +0 -0
  112. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/refresh.py +0 -0
  113. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/settings.py +0 -0
  114. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/commands/sync.py +0 -0
  115. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/context.py +0 -0
  116. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/dependencies.py +0 -0
  117. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/dependency_manifest_data.py +0 -0
  118. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/__init__.py +0 -0
  119. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/base.py +0 -0
  120. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/claude.py +0 -0
  121. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/copilot.py +0 -0
  122. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/cursor.py +0 -0
  123. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/gemini.py +0 -0
  124. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/generators/github.py +0 -0
  125. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/manifest_refresh.py +0 -0
  126. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/__init__.py +0 -0
  127. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/architecture.py +0 -0
  128. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/chains.py +0 -0
  129. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/classifier.py +0 -0
  130. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/coverage.py +0 -0
  131. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/dependencies.py +0 -0
  132. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/extractor.py +0 -0
  133. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/graph.py +0 -0
  134. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/output.py +0 -0
  135. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/responsibility.py +0 -0
  136. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/topology.py +0 -0
  137. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/map/walker.py +0 -0
  138. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/domain/base_entity.md +0 -0
  139. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/domain/base_repo.md +0 -0
  140. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/domain/container.md +0 -0
  141. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/domain/settings.md +0 -0
  142. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/all.instructions.md +0 -0
  143. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/api.instructions.md +0 -0
  144. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/domain.instructions.md +0 -0
  145. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/frontend.instructions.md +0 -0
  146. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/rust-engine.instructions.md +0 -0
  147. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/setup-guide.instructions.md +0 -0
  148. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/tooling-setup.instructions.md +0 -0
  149. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/instructions/tooling.instructions.md +0 -0
  150. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/js_special_files/fetch.service.md +0 -0
  151. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/special_files/domain_container.md +0 -0
  152. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/special_files/domain_settings.md +0 -0
  153. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/special_files/dto_util.md +0 -0
  154. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/special_files/encrypted_column.md +0 -0
  155. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/special_files/mapper_util.md +0 -0
  156. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/scaffolds/special_files/partial_update.md +0 -0
  157. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/templates/__init__.py +0 -0
  158. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/ui/__init__.py +0 -0
  159. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/ui/console.py +0 -0
  160. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/ui/markdown.py +0 -0
  161. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/ui/prompt_session.py +0 -0
  162. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/ui/renderers.py +0 -0
  163. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/ui/theme.py +0 -0
  164. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/src/rpr/workspace.py +0 -0
  165. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/README.md +0 -0
  166. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/00-bootstrap-cli.md +0 -0
  167. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/01-rpr-init.md +0 -0
  168. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/02-rpr-generate-domain.md +0 -0
  169. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/03-rpr-generate-api.md +0 -0
  170. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/04-rpr-generate-ui.md +0 -0
  171. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/05-rpr-generate-engine.md +0 -0
  172. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/06-rpr-generate-storybook.md +0 -0
  173. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/07-rpr-sync-rules.md +0 -0
  174. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/08-rpr-add-template.md +0 -0
  175. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/09-rpr-check.md +0 -0
  176. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/10-rpr-domain-config.md +0 -0
  177. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/18-rpr-terminal-ui-foundation.md +0 -0
  178. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/19-rpr-markdown-rendering.md +0 -0
  179. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/20-rpr-chat-repl.md +0 -0
  180. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/21-rpr-agent-tool-loop.md +0 -0
  181. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/22-rpr-chat-tui.md +0 -0
  182. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/23-rpr-chat-unified-command-shell.md +0 -0
  183. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/24-rpr-async-chat-runtime-and-services.md +0 -0
  184. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/25-rpr-prompt-service-and-conversation-lifecycle.md +0 -0
  185. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/26-rpr-shared-command-catalog.md +0 -0
  186. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/27-rpr-slash-command-selection-panel.md +0 -0
  187. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/28-rpr-chat-cancellable-and-multiline-input.md +0 -0
  188. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/chat/29-rpr-file-context-selection.md +0 -0
  189. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/11-rpr-map-command.md +0 -0
  190. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/12-rpr-map-file-walker.md +0 -0
  191. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/13-rpr-map-rust-engine.md +0 -0
  192. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/13a-rpr-map-gitignore.md +0 -0
  193. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/14-rpr-map-graph-builder.md +0 -0
  194. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/15-rpr-map-analysis-bridge.md +0 -0
  195. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/16-rpr-map-layer-classifier.md +0 -0
  196. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/17-rpr-map-output.md +0 -0
  197. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/17a-rpr-map-directory-topology.md +0 -0
  198. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/17b-rpr-map-architecture-assessment.md +0 -0
  199. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/17c-rpr-map-representative-chains.md +0 -0
  200. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/completed/map/17d-rpr-map-multi-resolution-output.md +0 -0
  201. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/map/30-rpr-map-directory-view-and-cycle-detection.md +0 -0
  202. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/map/31-rpr-map-coupling-hotspots-and-verdict-reasoning.md +0 -0
  203. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/stories/map/32-rpr-map-dependencies-responsibilities-and-test-coverage.md +0 -0
  204. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/__init__.py +0 -0
  205. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_architecture.py +0 -0
  206. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_architecture_unwrapped.py +0 -0
  207. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_catalog.py +0 -0
  208. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_chat.py +0 -0
  209. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_chat_service.py +0 -0
  210. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_completer.py +0 -0
  211. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_context.py +0 -0
  212. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_generate_api.py +0 -0
  213. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_generate_domain.py +0 -0
  214. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_generate_engine.py +0 -0
  215. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_generate_storybook.py +0 -0
  216. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_generate_ui.py +0 -0
  217. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_init.py +0 -0
  218. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map.py +0 -0
  219. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_chains.py +0 -0
  220. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_classifier.py +0 -0
  221. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_coverage.py +0 -0
  222. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_dependencies.py +0 -0
  223. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_extractor.py +0 -0
  224. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_graph.py +0 -0
  225. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_output.py +0 -0
  226. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_responsibility.py +0 -0
  227. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_topology.py +0 -0
  228. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_map_walker.py +0 -0
  229. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_packaging.py +0 -0
  230. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_prompt_session.py +0 -0
  231. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_refresh.py +0 -0
  232. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_rpr_parser_stub.py +0 -0
  233. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_selector.py +0 -0
  234. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_settings.py +0 -0
  235. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_sync.py +0 -0
  236. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/tests/test_ui.py +0 -0
  237. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/update_output.py +0 -0
  238. {rpr_cli-0.1.25 → rpr_cli-0.1.26}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rpr-cli
3
- Version: 0.1.25
3
+ Version: 0.1.26
4
4
  Summary: RPR CLI -- React Python Rust monorepo scaffolding orchestrator
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -106,11 +106,15 @@ The scaffold version manifest that powers generated dependency specs and the syn
106
106
  Sync project context to your favorite AI tools and add common code patterns.
107
107
 
108
108
  ```bash
109
- rpr sync rules # Sync AI instructions to Cursor, Copilot, Claude Code, and Gemini
110
- rpr add template mapper_util # Add a common code template to your project
111
- rpr add template --list # List all available code templates
109
+ rpr sync rules # Sync AI instructions to Cursor, Copilot, Claude Code, and Gemini
110
+ rpr add template python mapper_util # Add a Python code template to your project
111
+ rpr add template node sticky_navigation --include-tests
112
+ rpr add template node -l # List available Node/TSX templates
113
+ rpr add template --list # List all available code templates
112
114
  ```
113
115
 
116
+ Use `--output-dir` to write generated files to a different directory while keeping the template filenames. Templates with test companions can use `--include-tests`; Node browser-test templates print the extra npm dev dependencies and any Playwright setup notes they need. Existing config files such as `vitest.config.ts` are not merged automatically; RPR leaves them unchanged unless you rerun with `--force` to replace them.
117
+
114
118
  ### 💬 Interactive Shell
115
119
 
116
120
  `rpr chat` is the primary interactive shell for the product. It runs a Rich-based session with slash commands for both chat controls and existing CLI workflows.
@@ -91,11 +91,15 @@ The scaffold version manifest that powers generated dependency specs and the syn
91
91
  Sync project context to your favorite AI tools and add common code patterns.
92
92
 
93
93
  ```bash
94
- rpr sync rules # Sync AI instructions to Cursor, Copilot, Claude Code, and Gemini
95
- rpr add template mapper_util # Add a common code template to your project
96
- rpr add template --list # List all available code templates
94
+ rpr sync rules # Sync AI instructions to Cursor, Copilot, Claude Code, and Gemini
95
+ rpr add template python mapper_util # Add a Python code template to your project
96
+ rpr add template node sticky_navigation --include-tests
97
+ rpr add template node -l # List available Node/TSX templates
98
+ rpr add template --list # List all available code templates
97
99
  ```
98
100
 
101
+ Use `--output-dir` to write generated files to a different directory while keeping the template filenames. Templates with test companions can use `--include-tests`; Node browser-test templates print the extra npm dev dependencies and any Playwright setup notes they need. Existing config files such as `vitest.config.ts` are not merged automatically; RPR leaves them unchanged unless you rerun with `--force` to replace them.
102
+
99
103
  ### 💬 Interactive Shell
100
104
 
101
105
  `rpr chat` is the primary interactive shell for the product. It runs a Rich-based session with slash commands for both chat controls and existing CLI workflows.
@@ -0,0 +1,147 @@
1
+ import { useState } from 'react';
2
+ import { flushSync } from 'react-dom';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { render } from 'vitest-browser-react';
5
+ import { VirtualComponent } from './VirtualComponent';
6
+
7
+ const createRect = (height: number) =>
8
+ ({
9
+ x: 0,
10
+ y: 0,
11
+ width: 100,
12
+ height,
13
+ top: 0,
14
+ right: 100,
15
+ bottom: height,
16
+ left: 0,
17
+ toJSON: () => ({}),
18
+ }) as DOMRect;
19
+
20
+ class MockResizeObserver implements ResizeObserver {
21
+ static instances: MockResizeObserver[] = [];
22
+
23
+ observe = vi.fn();
24
+ unobserve = vi.fn();
25
+ disconnect = vi.fn();
26
+
27
+ constructor(private readonly callback: ResizeObserverCallback) {
28
+ MockResizeObserver.instances.push(this);
29
+ }
30
+
31
+ emit() {
32
+ this.callback([], this);
33
+ }
34
+ }
35
+
36
+ describe('VirtualComponent', () => {
37
+ let measuredHeight = 72;
38
+
39
+ beforeEach(() => {
40
+ MockResizeObserver.instances = [];
41
+ measuredHeight = 72;
42
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
43
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
44
+ function getBoundingClientRect(this: HTMLElement) {
45
+ return (this as HTMLElement).classList.contains('virtual-component-item')
46
+ ? createRect(measuredHeight)
47
+ : createRect(0);
48
+ }
49
+ );
50
+ Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
51
+ configurable: true,
52
+ get() {
53
+ return measuredHeight;
54
+ },
55
+ });
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.restoreAllMocks();
60
+ vi.unstubAllGlobals();
61
+ });
62
+
63
+ it('preserves the measured height when hidden and unmounts children', async () => {
64
+ function Harness() {
65
+ const [isVisible, setIsVisible] = useState(true);
66
+
67
+ return (
68
+ <>
69
+ <button onClick={() => setIsVisible((current) => !current)}>
70
+ Toggle
71
+ </button>
72
+ <VirtualComponent isVisible={isVisible}>
73
+ <div>Measured child</div>
74
+ </VirtualComponent>
75
+ </>
76
+ );
77
+ }
78
+
79
+ const screen = await render(<Harness />);
80
+
81
+ const item = document.querySelector<HTMLDivElement>('.virtual-component-item');
82
+
83
+ expect(item).toBeTruthy();
84
+ expect(item?.style.flexShrink).toBe('0');
85
+ await expect.element(screen.getByText('Measured child')).toBeInTheDocument();
86
+
87
+ await screen.getByRole('button', { name: 'Toggle' }).click();
88
+
89
+ await expect.element(screen.getByText('Measured child')).not.toBeInTheDocument();
90
+ expect(item?.style.height).toBe('72px');
91
+ expect(item?.style.visibility).toBe('hidden');
92
+ expect(item?.dataset.virtualVisible).toBe('false');
93
+
94
+ await screen.getByRole('button', { name: 'Toggle' }).click();
95
+
96
+ await expect.element(screen.getByText('Measured child')).toBeInTheDocument();
97
+ expect(item?.style.height).toBe('');
98
+ expect(item?.style.flexShrink).toBe('0');
99
+ expect(item?.style.visibility).toBe('');
100
+ expect(item?.dataset.virtualVisible).toBe('true');
101
+ });
102
+
103
+ it('uses the latest ResizeObserver measurement before hiding', async () => {
104
+ function Harness() {
105
+ const [isVisible, setIsVisible] = useState(true);
106
+
107
+ return (
108
+ <>
109
+ <button onClick={() => setIsVisible(false)}>Hide</button>
110
+ <VirtualComponent isVisible={isVisible}>
111
+ <div>Resizable child</div>
112
+ </VirtualComponent>
113
+ </>
114
+ );
115
+ }
116
+
117
+ const screen = await render(<Harness />);
118
+
119
+ measuredHeight = 96;
120
+ flushSync(() => {
121
+ MockResizeObserver.instances[0].emit();
122
+ });
123
+
124
+ const item = document.querySelector<HTMLDivElement>('.virtual-component-item');
125
+
126
+ await screen.getByRole('button', { name: 'Hide' }).click();
127
+
128
+ await expect.element(screen.getByText('Resizable child')).not.toBeInTheDocument();
129
+ expect(item?.style.height).toBe('96px');
130
+ expect(item?.style.visibility).toBe('hidden');
131
+ });
132
+
133
+ it('observes on mount and disconnects on unmount', async () => {
134
+ const { unmount } = await render(
135
+ <VirtualComponent>
136
+ <div>Observed child</div>
137
+ </VirtualComponent>
138
+ );
139
+
140
+ expect(MockResizeObserver.instances).toHaveLength(1);
141
+ expect(MockResizeObserver.instances[0].observe).toHaveBeenCalledTimes(1);
142
+
143
+ await unmount();
144
+
145
+ expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1);
146
+ });
147
+ });
@@ -0,0 +1,94 @@
1
+ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ type VirtualComponentProps = React.PropsWithChildren<{
4
+ isVisible?: boolean;
5
+ }>;
6
+
7
+ const setForwardedRef = (
8
+ ref: React.ForwardedRef<HTMLDivElement>,
9
+ node: HTMLDivElement | null
10
+ ) => {
11
+ if (typeof ref === 'function') {
12
+ ref(node);
13
+ } else if (ref) {
14
+ ref.current = node;
15
+ }
16
+ };
17
+
18
+ export const VirtualComponent = React.memo(
19
+ React.forwardRef<HTMLDivElement, VirtualComponentProps>(
20
+ ({ children, isVisible = true }, forwardedRef) => {
21
+ const ref = useRef<HTMLDivElement>(null);
22
+ const measuredHeight = useRef<number | null>(null);
23
+ const [height, setHeight] = useState<number | null>(null);
24
+
25
+ const setRef = useCallback(
26
+ (node: HTMLDivElement | null) => {
27
+ ref.current = node;
28
+ setForwardedRef(forwardedRef, node);
29
+ },
30
+ [forwardedRef]
31
+ );
32
+
33
+ const measureHeight = useCallback(() => {
34
+ const node = ref.current;
35
+ if (!node) return;
36
+
37
+ const nextHeight = Math.ceil(
38
+ Math.max(node.getBoundingClientRect().height, node.scrollHeight)
39
+ );
40
+
41
+ if (nextHeight > 0 && measuredHeight.current !== nextHeight) {
42
+ measuredHeight.current = nextHeight;
43
+ setHeight(nextHeight);
44
+ }
45
+ }, []);
46
+
47
+ const shouldRenderChildren = isVisible || height === null;
48
+
49
+ useLayoutEffect(() => {
50
+ if (shouldRenderChildren) {
51
+ measureHeight();
52
+ }
53
+ }, [measureHeight, shouldRenderChildren]);
54
+
55
+ useLayoutEffect(() => {
56
+ const node = ref.current;
57
+ if (!node || typeof ResizeObserver === 'undefined') return;
58
+
59
+ const observer = new ResizeObserver(() => {
60
+ if (shouldRenderChildren) {
61
+ measureHeight();
62
+ }
63
+ });
64
+
65
+ observer.observe(node);
66
+
67
+ return () => {
68
+ observer.disconnect();
69
+ };
70
+ }, [measureHeight, shouldRenderChildren]);
71
+
72
+ const visibleStyle: React.CSSProperties = { flexShrink: 0 };
73
+ const hiddenStyle: React.CSSProperties = height
74
+ ? {
75
+ flexShrink: 0,
76
+ height,
77
+ overflow: 'hidden',
78
+ visibility: 'hidden',
79
+ }
80
+ : visibleStyle;
81
+
82
+ return (
83
+ <div
84
+ ref={setRef}
85
+ className="virtual-component-item"
86
+ data-virtual-visible={isVisible ? 'true' : 'false'}
87
+ style={isVisible ? visibleStyle : hiddenStyle}
88
+ >
89
+ {shouldRenderChildren ? children : null}
90
+ </div>
91
+ );
92
+ }
93
+ )
94
+ );
@@ -0,0 +1,277 @@
1
+ import { flushSync } from 'react-dom';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { render } from 'vitest-browser-react';
4
+ import { VirtualComponentList } from './VirtualComponentList';
5
+
6
+ const createRect = (height: number) =>
7
+ ({
8
+ x: 0,
9
+ y: 0,
10
+ width: 100,
11
+ height,
12
+ top: 0,
13
+ right: 100,
14
+ bottom: height,
15
+ left: 0,
16
+ toJSON: () => ({}),
17
+ }) as DOMRect;
18
+
19
+ class MockIntersectionObserver implements IntersectionObserver {
20
+ static instances: MockIntersectionObserver[] = [];
21
+
22
+ readonly root: Element | Document | null;
23
+ readonly rootMargin: string;
24
+ readonly scrollMargin: string;
25
+ readonly thresholds: ReadonlyArray<number>;
26
+ observedElements = new Set<Element>();
27
+ observe = vi.fn((element: Element) => {
28
+ this.observedElements.add(element);
29
+ });
30
+ unobserve = vi.fn((element: Element) => {
31
+ this.observedElements.delete(element);
32
+ });
33
+ disconnect = vi.fn(() => {
34
+ this.observedElements.clear();
35
+ });
36
+ takeRecords = vi.fn(() => []);
37
+
38
+ constructor(
39
+ private readonly callback: IntersectionObserverCallback,
40
+ options: IntersectionObserverInit = {}
41
+ ) {
42
+ this.root = options.root ?? null;
43
+ this.rootMargin = options.rootMargin ?? '0px';
44
+ this.scrollMargin = '0px';
45
+ this.thresholds = Array.isArray(options.threshold)
46
+ ? options.threshold
47
+ : [options.threshold ?? 0];
48
+ MockIntersectionObserver.instances.push(this);
49
+ }
50
+
51
+ emit(entries: Array<{ target: Element; isIntersecting: boolean }>) {
52
+ this.callback(
53
+ entries.map(
54
+ (entry) =>
55
+ ({
56
+ boundingClientRect: createRect(0),
57
+ intersectionRatio: entry.isIntersecting ? 1 : 0,
58
+ intersectionRect: createRect(0),
59
+ isIntersecting: entry.isIntersecting,
60
+ rootBounds: createRect(0),
61
+ target: entry.target,
62
+ time: 0,
63
+ }) as IntersectionObserverEntry
64
+ ),
65
+ this
66
+ );
67
+ }
68
+ }
69
+
70
+ class MockResizeObserver implements ResizeObserver {
71
+ observe = vi.fn();
72
+ unobserve = vi.fn();
73
+ disconnect = vi.fn();
74
+
75
+ constructor(private readonly callback: ResizeObserverCallback) {}
76
+
77
+ emit() {
78
+ this.callback([], this);
79
+ }
80
+ }
81
+
82
+ const renderItems = (count: number) =>
83
+ Array.from({ length: count }, (_, index) => <div key={index}>Item {index}</div>);
84
+
85
+ const paragraphHeights = [56, 104, 148, 92, 180, 128, 72, 156, 116, 204];
86
+
87
+ const renderVariableParagraphs = (count: number) =>
88
+ Array.from({ length: count }, (_, index) => (
89
+ <section key={index} data-paragraph-index={index}>
90
+ <h3>Paragraph container {index + 1}</h3>
91
+ {Array.from({ length: (index % 4) + 1 }, (__, paragraphIndex) => (
92
+ <p key={paragraphIndex}>
93
+ Paragraph {index + 1}.{paragraphIndex + 1} has variable content.
94
+ </p>
95
+ ))}
96
+ </section>
97
+ ));
98
+
99
+ const getVirtualItems = () =>
100
+ Array.from(document.querySelectorAll<HTMLDivElement>('.virtual-component-item'));
101
+
102
+ describe('VirtualComponentList', () => {
103
+ beforeEach(() => {
104
+ MockIntersectionObserver.instances = [];
105
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
106
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
107
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
108
+ function getBoundingClientRect(this: HTMLElement) {
109
+ const textContent = (this as HTMLElement).textContent ?? '';
110
+ const itemMatch = /Item (\d+)/.exec(textContent);
111
+ const paragraphMatch = /Paragraph container (\d+)/.exec(textContent);
112
+ const height = itemMatch
113
+ ? 40 + Number(itemMatch[1]) * 10
114
+ : paragraphMatch
115
+ ? paragraphHeights[Number(paragraphMatch[1]) - 1] ?? 80
116
+ : 0;
117
+
118
+ return createRect(height);
119
+ }
120
+ );
121
+ Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
122
+ configurable: true,
123
+ get() {
124
+ const textContent = (this as HTMLElement).textContent ?? '';
125
+ const itemMatch = /Item (\d+)/.exec(textContent);
126
+ const paragraphMatch = /Paragraph container (\d+)/.exec(textContent);
127
+
128
+ if (itemMatch) return 40 + Number(itemMatch[1]) * 10;
129
+ if (paragraphMatch) {
130
+ return paragraphHeights[Number(paragraphMatch[1]) - 1] ?? 80;
131
+ }
132
+
133
+ return 0;
134
+ },
135
+ });
136
+ });
137
+
138
+ afterEach(() => {
139
+ vi.restoreAllMocks();
140
+ vi.unstubAllGlobals();
141
+ });
142
+
143
+ it('observes children on mount and disconnects the observer on unmount', async () => {
144
+ const { unmount } = await render(
145
+ <VirtualComponentList>{renderItems(3)}</VirtualComponentList>
146
+ );
147
+
148
+ const observer = MockIntersectionObserver.instances[0];
149
+
150
+ expect(observer.root).toBe(document.getElementById('virtual-component-list'));
151
+ expect(observer.rootMargin).toBe('100% 0px');
152
+ expect(observer.observe).toHaveBeenCalledTimes(3);
153
+ expect(observer.observedElements.size).toBe(3);
154
+
155
+ await unmount();
156
+
157
+ expect(observer.disconnect).toHaveBeenCalledTimes(1);
158
+ expect(observer.observedElements.size).toBe(0);
159
+ });
160
+
161
+ it('unmounts hidden item content from intersection events while preserving height', async () => {
162
+ const screen = await render(
163
+ <VirtualComponentList>{renderItems(3)}</VirtualComponentList>
164
+ );
165
+
166
+ const observer = MockIntersectionObserver.instances[0];
167
+ const items = getVirtualItems();
168
+
169
+ flushSync(() => {
170
+ observer.emit([{ target: items[1], isIntersecting: false }]);
171
+ });
172
+
173
+ await expect.element(screen.locator.getByText('Item 1')).not.toBeInTheDocument();
174
+ expect(items[1].style.height).toBe('50px');
175
+ expect(items[1].style.flexShrink).toBe('0');
176
+ expect(items[1].style.visibility).toBe('hidden');
177
+ expect(items[1].dataset.virtualVisible).toBe('false');
178
+ await expect.element(screen.locator.getByText('Item 0')).toBeInTheDocument();
179
+ await expect.element(screen.locator.getByText('Item 2')).toBeInTheDocument();
180
+
181
+ flushSync(() => {
182
+ observer.emit([{ target: items[1], isIntersecting: true }]);
183
+ });
184
+
185
+ await expect.element(screen.locator.getByText('Item 1')).toBeInTheDocument();
186
+ expect(items[1].style.height).toBe('');
187
+ expect(items[1].style.visibility).toBe('');
188
+ expect(items[1].dataset.virtualVisible).toBe('true');
189
+ });
190
+
191
+ it('preserves each measured height for variable-sized paragraph containers', async () => {
192
+ const screen = await render(
193
+ <VirtualComponentList>{renderVariableParagraphs(10)}</VirtualComponentList>
194
+ );
195
+
196
+ const observer = MockIntersectionObserver.instances[0];
197
+ const items = getVirtualItems();
198
+
199
+ flushSync(() => {
200
+ observer.emit([
201
+ { target: items[1], isIntersecting: false },
202
+ { target: items[4], isIntersecting: false },
203
+ { target: items[8], isIntersecting: false },
204
+ ]);
205
+ });
206
+
207
+ await expect
208
+ .element(screen.locator.getByText('Paragraph container 2'))
209
+ .not.toBeInTheDocument();
210
+ await expect
211
+ .element(screen.locator.getByText('Paragraph container 5'))
212
+ .not.toBeInTheDocument();
213
+ await expect
214
+ .element(screen.locator.getByText('Paragraph container 9'))
215
+ .not.toBeInTheDocument();
216
+ expect(items[1].style.height).toBe('104px');
217
+ expect(items[4].style.height).toBe('180px');
218
+ expect(items[8].style.height).toBe('116px');
219
+ expect(items[1].style.flexShrink).toBe('0');
220
+ expect(items[4].style.flexShrink).toBe('0');
221
+ expect(items[8].style.flexShrink).toBe('0');
222
+
223
+ flushSync(() => {
224
+ observer.emit([{ target: items[4], isIntersecting: true }]);
225
+ });
226
+
227
+ await expect
228
+ .element(screen.locator.getByText('Paragraph container 5'))
229
+ .toBeInTheDocument();
230
+ expect(items[4].style.height).toBe('');
231
+ expect(items[1].style.height).toBe('104px');
232
+ expect(items[8].style.height).toBe('116px');
233
+ });
234
+
235
+ it('unobserves removed children without disconnecting the whole list observer', async () => {
236
+ const { rerender } = await render(
237
+ <VirtualComponentList>{renderItems(3)}</VirtualComponentList>
238
+ );
239
+
240
+ const observer = MockIntersectionObserver.instances[0];
241
+ const removedItem = getVirtualItems()[2];
242
+
243
+ await rerender(<VirtualComponentList>{renderItems(2)}</VirtualComponentList>);
244
+
245
+ expect(observer.unobserve).toHaveBeenCalledWith(removedItem);
246
+ expect(observer.disconnect).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('does not re-render the parent container when item visibility changes', async () => {
250
+ const onParentRender = vi.fn();
251
+
252
+ function Parent() {
253
+ onParentRender();
254
+
255
+ return <VirtualComponentList>{renderItems(3)}</VirtualComponentList>;
256
+ }
257
+
258
+ await render(<Parent />);
259
+
260
+ const parentRenderCount = onParentRender.mock.calls.length;
261
+ const observer = MockIntersectionObserver.instances[0];
262
+ const items = getVirtualItems();
263
+
264
+ flushSync(() => {
265
+ observer.emit([
266
+ { target: items[0], isIntersecting: false },
267
+ { target: items[1], isIntersecting: false },
268
+ ]);
269
+ });
270
+
271
+ flushSync(() => {
272
+ observer.emit([{ target: items[0], isIntersecting: true }]);
273
+ });
274
+
275
+ expect(onParentRender).toHaveBeenCalledTimes(parentRenderCount);
276
+ });
277
+ });
@@ -0,0 +1,112 @@
1
+ import { Stack, type BoxProps } from '@mui/material';
2
+ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { VirtualComponent } from './VirtualComponent';
4
+
5
+ type VirtualListProps = {
6
+ children: React.ReactNode;
7
+ sx?: BoxProps['sx'];
8
+ }
9
+
10
+ type VisibilityListener = (isVisible: boolean) => void;
11
+
12
+ type RegisterVirtualComponent = (
13
+ node: HTMLElement,
14
+ onVisibilityChange: VisibilityListener
15
+ ) => () => void;
16
+
17
+ type VirtualComponentListItemProps = React.PropsWithChildren<{
18
+ registerVirtualComponent: RegisterVirtualComponent;
19
+ }>;
20
+
21
+ const VirtualComponentListItem = React.memo(
22
+ ({ children, registerVirtualComponent }: VirtualComponentListItemProps) => {
23
+ const [isVisible, setIsVisible] = useState(true);
24
+ const nodeRef = useRef<HTMLDivElement | null>(null);
25
+
26
+ const setNodeRef = useCallback((node: HTMLDivElement | null) => {
27
+ nodeRef.current = node;
28
+ }, []);
29
+
30
+ useLayoutEffect(() => {
31
+ const node = nodeRef.current;
32
+ if (!node) return;
33
+
34
+ return registerVirtualComponent(node, (nextIsVisible) => {
35
+ setIsVisible((currentIsVisible) =>
36
+ currentIsVisible === nextIsVisible ? currentIsVisible : nextIsVisible
37
+ );
38
+ });
39
+ }, [registerVirtualComponent]);
40
+
41
+ return (
42
+ <VirtualComponent ref={setNodeRef} isVisible={isVisible}>
43
+ {children}
44
+ </VirtualComponent>
45
+ );
46
+ }
47
+ );
48
+
49
+ export const VirtualComponentList = ({ children, sx }: VirtualListProps) => {
50
+ const stackSx = useMemo(() => [
51
+ {
52
+ overflowY: 'auto',
53
+ height: '100%',
54
+ justifyContent: 'flex-start',
55
+ alignItems: 'stretch',
56
+ },
57
+ ...(Array.isArray(sx) ? sx : [sx]),
58
+ ], [sx]);
59
+
60
+ const containerRef = useRef<HTMLDivElement>(null);
61
+ const intersectionObserver = useRef<IntersectionObserver | null>(null);
62
+ const visibilityListeners = useRef(new Map<Element, VisibilityListener>());
63
+
64
+ const registerVirtualComponent = useCallback<RegisterVirtualComponent>(
65
+ (node, onVisibilityChange) => {
66
+ visibilityListeners.current.set(node, onVisibilityChange);
67
+ intersectionObserver.current?.observe(node);
68
+
69
+ return () => {
70
+ intersectionObserver.current?.unobserve(node);
71
+ visibilityListeners.current.delete(node);
72
+ };
73
+ },
74
+ []
75
+ );
76
+
77
+ useLayoutEffect(() => {
78
+ const currentVisibilityListeners = visibilityListeners.current;
79
+ const observer = new IntersectionObserver(
80
+ (entries) => {
81
+ entries.forEach((entry) => {
82
+ currentVisibilityListeners.get(entry.target)?.(entry.isIntersecting);
83
+ });
84
+ },
85
+ {
86
+ root: containerRef.current,
87
+ rootMargin: '100% 0px',
88
+ }
89
+ );
90
+
91
+ intersectionObserver.current = observer;
92
+ currentVisibilityListeners.forEach((_, node) => {
93
+ observer.observe(node);
94
+ });
95
+
96
+ return () => {
97
+ observer.disconnect();
98
+ intersectionObserver.current = null;
99
+ currentVisibilityListeners.clear();
100
+ };
101
+ }, [])
102
+
103
+ return (<Stack id='virtual-component-list' sx={stackSx} direction="column" ref={containerRef}>
104
+ {React.Children.map(children, (child) => {
105
+ return (
106
+ <VirtualComponentListItem registerVirtualComponent={registerVirtualComponent}>
107
+ {child}
108
+ </VirtualComponentListItem>
109
+ );
110
+ })}
111
+ </Stack>)
112
+ }
@@ -0,0 +1 @@
1
+ export * from './VirtualComponentList';