@svgrid/enterprise 2.3.2 → 2.5.0

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 (165) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +5917 -5545
  2. package/dist/designer/assets/index-oYok52zd.css +1 -0
  3. package/dist/designer/assets/{index-DVrv9dH6.js → index-qL7ir9C7.js} +89 -89
  4. package/dist/designer/assets/src-96d31O63.js +2873 -0
  5. package/dist/designer/assets/src-D-O2F805.css +1 -0
  6. package/dist/designer/index.html +6 -6
  7. package/dist/node/studio.js +681 -84
  8. package/package.json +3 -3
  9. package/src/SvGridEditPanel.dom.test.ts +174 -0
  10. package/src/SvGridEditPanel.svelte +209 -46
  11. package/src/edit-panel.test.ts +145 -36
  12. package/src/edit-panel.ts +167 -16
  13. package/src/expressions/evaluate.ts +1 -1
  14. package/src/expressions/parse.ts +3 -3
  15. package/src/index.ts +8 -0
  16. package/src/license-core.ts +77 -5
  17. package/src/license-expiry.test.ts +91 -0
  18. package/src/license.ts +31 -2
  19. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +1 -0
  20. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +178 -0
  21. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js +3875 -0
  22. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-ZrPsKxob.js.map +1 -0
  23. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js +21 -0
  24. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-ACqvWGE2.js.map +1 -0
  25. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js +1938 -0
  26. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-D3w5jt92.js.map +1 -0
  27. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js +205 -0
  28. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-CC2V3H0y.js.map +1 -0
  29. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js +18 -0
  30. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-BXP37tAa.js.map +1 -0
  31. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js +264 -0
  32. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-DwubnU5b.js.map +1 -0
  33. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js +697 -0
  34. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-hD7f6PUz.js.map +1 -0
  35. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +3 -0
  36. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +134 -0
  37. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +1 -0
  38. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +55 -0
  39. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +1 -0
  40. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +2 -0
  41. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +46843 -0
  42. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +1 -0
  43. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +237 -0
  44. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +1 -0
  45. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +2 -0
  46. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +4 -0
  47. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +1 -0
  48. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +7 -0
  49. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +4 -0
  50. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +1 -0
  51. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +5 -0
  52. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +1 -0
  53. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +5 -0
  54. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +1 -0
  55. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +5 -0
  56. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +1 -0
  57. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +6 -0
  58. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +83 -0
  59. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +1 -0
  60. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +559 -0
  61. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +1 -0
  62. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +24 -0
  63. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +1 -0
  64. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +122 -0
  65. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +1 -0
  66. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +2 -0
  67. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +89 -0
  68. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +1 -0
  69. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +241 -0
  70. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +1 -0
  71. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js +4938 -0
  72. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DBx1SCN6.js.map +1 -0
  73. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js +495 -0
  74. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-gjfpSTFS.js.map +1 -0
  75. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js +190 -0
  76. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-DKoOKg8S.js.map +1 -0
  77. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js +8 -0
  78. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-C0MiR7eR.js.map +1 -0
  79. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js +138 -0
  80. package/src/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-B6Y7P0Q-.js.map +1 -0
  81. package/src/schema.ts +63 -0
  82. package/src/studio/emit-project.test.ts +191 -0
  83. package/src/studio/emit-project.ts +237 -27
  84. package/src/studio/emit-schema.ts +4 -1
  85. package/src/studio/index.ts +2 -0
  86. package/src/studio/init-flow.test.ts +150 -0
  87. package/src/studio/init-flow.ts +169 -22
  88. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +1 -0
  89. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +178 -0
  90. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +3875 -0
  91. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +1 -0
  92. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +21 -0
  93. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +1 -0
  94. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +1938 -0
  95. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +1 -0
  96. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +205 -0
  97. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +1 -0
  98. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +18 -0
  99. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +1 -0
  100. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +264 -0
  101. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +1 -0
  102. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +697 -0
  103. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +1 -0
  104. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +3 -0
  105. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +134 -0
  106. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +1 -0
  107. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +55 -0
  108. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +1 -0
  109. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +2 -0
  110. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +46843 -0
  111. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +1 -0
  112. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +237 -0
  113. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +1 -0
  114. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +2 -0
  115. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +4 -0
  116. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +1 -0
  117. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +7 -0
  118. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +4 -0
  119. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +1 -0
  120. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +5 -0
  121. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +1 -0
  122. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +5 -0
  123. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +1 -0
  124. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +5 -0
  125. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +1 -0
  126. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +6 -0
  127. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +83 -0
  128. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +1 -0
  129. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +559 -0
  130. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +1 -0
  131. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +24 -0
  132. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +1 -0
  133. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +122 -0
  134. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +1 -0
  135. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +2 -0
  136. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +89 -0
  137. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +1 -0
  138. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +241 -0
  139. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +1 -0
  140. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +4938 -0
  141. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +1 -0
  142. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +495 -0
  143. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +1 -0
  144. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +190 -0
  145. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +1 -0
  146. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +8 -0
  147. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +1 -0
  148. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +138 -0
  149. package/src/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +1 -0
  150. package/src/studio/project.test.ts +30 -0
  151. package/src/studio/project.ts +72 -8
  152. package/src/studio/samples/crm.ts +11 -2
  153. package/src/studio/samples/insurance.ts +10 -4
  154. package/src/studio/samples/samples.test.ts +36 -0
  155. package/src/studio/samples/shared.ts +14 -0
  156. package/src/studio/samples/support.ts +12 -5
  157. package/src/studio/screen-suites.test.ts +43 -0
  158. package/src/studio/screen-suites.ts +5 -2
  159. package/src/studio/user-error.ts +31 -0
  160. package/src/upgrade-prompt.ts +23 -10
  161. package/src/version.test.ts +17 -0
  162. package/src/version.ts +13 -0
  163. package/dist/designer/assets/index-DjPp1mn7.css +0 -1
  164. package/dist/designer/assets/src-BRKu53tV.js +0 -2842
  165. package/dist/designer/assets/src-DseB1VRN.css +0 -1
@@ -4,6 +4,8 @@ import {
4
4
  buildInitialValues,
5
5
  controlKind,
6
6
  editMode,
7
+ evalValidationRules,
8
+ fieldState,
7
9
  rowId,
8
10
  toSubmitValues,
9
11
  validateAll,
@@ -13,7 +15,9 @@ import {
13
15
  fromNumberValue,
14
16
  toSliderValue,
15
17
  toTags,
18
+ visibleFormFields,
16
19
  } from './edit-panel'
20
+ import { schemaToFormFields, type FormFieldDescriptor } from './schema'
17
21
 
18
22
  type Customer = {
19
23
  id: string
@@ -175,39 +179,144 @@ describe('rowId', () => {
175
179
  expect(rowId(schema, { id: 99 } as unknown as Customer)).toBe('99')
176
180
  })
177
181
  })
178
-
179
- describe('editor value coercion', () => {
180
- it('formats a Date to a local yyyy-MM-dd (no UTC shift)', () => {
181
- // Local midnight - toISOString would shift the day in negative-offset zones; local formatting must not.
182
- const d = new Date(2024, 0, 2, 0, 0) // 2 Jan 2024, local
183
- expect(toDateString(d)).toBe('2024-01-02')
184
- expect(toDateString(null)).toBe('')
185
- })
186
-
187
- it('formats a Date to a local yyyy-MM-ddTHH:mm', () => {
188
- expect(toDateTimeString(new Date(2024, 2, 4, 9, 5))).toBe('2024-03-04T09:05')
189
- expect(toDateTimeString(null)).toBe('')
190
- })
191
-
192
- it('coerces numbers in/out with the empty-string convention', () => {
193
- expect(toNumberValue(1200)).toBe(1200)
194
- expect(toNumberValue('1200')).toBeNull() // strings are not numbers
195
- expect(toNumberValue(NaN)).toBeNull()
196
- expect(fromNumberValue(null)).toBe('')
197
- expect(fromNumberValue(0)).toBe(0) // 0 is a real value, not empty
198
- })
199
-
200
- it('slider falls back to min when unset', () => {
201
- expect(toSliderValue(42, 0)).toBe(42)
202
- expect(toSliderValue('', 10)).toBe(10)
203
- expect(toSliderValue(undefined, 5)).toBe(5)
204
- })
205
-
206
- it('coerces tags from arrays, comma strings, or empty', () => {
207
- expect(toTags(['a', 'b'])).toEqual(['a', 'b'])
208
- expect(toTags('a, b ,c')).toEqual(['a', 'b', 'c'])
209
- expect(toTags('')).toEqual([])
210
- expect(toTags(null)).toEqual([])
211
- expect(toTags([1, 2])).toEqual(['1', '2'])
212
- })
213
- })
182
+
183
+ describe('editor value coercion', () => {
184
+ it('formats a Date to a local yyyy-MM-dd (no UTC shift)', () => {
185
+ // Local midnight - toISOString would shift the day in negative-offset zones; local formatting must not.
186
+ const d = new Date(2024, 0, 2, 0, 0) // 2 Jan 2024, local
187
+ expect(toDateString(d)).toBe('2024-01-02')
188
+ expect(toDateString(null)).toBe('')
189
+ })
190
+
191
+ it('formats a Date to a local yyyy-MM-ddTHH:mm', () => {
192
+ expect(toDateTimeString(new Date(2024, 2, 4, 9, 5))).toBe('2024-03-04T09:05')
193
+ expect(toDateTimeString(null)).toBe('')
194
+ })
195
+
196
+ it('coerces numbers in/out with the empty-string convention', () => {
197
+ expect(toNumberValue(1200)).toBe(1200)
198
+ expect(toNumberValue('1200')).toBeNull() // strings are not numbers
199
+ expect(toNumberValue(NaN)).toBeNull()
200
+ expect(fromNumberValue(null)).toBe('')
201
+ expect(fromNumberValue(0)).toBe(0) // 0 is a real value, not empty
202
+ })
203
+
204
+ it('slider falls back to min when unset', () => {
205
+ expect(toSliderValue(42, 0)).toBe(42)
206
+ expect(toSliderValue('', 10)).toBe(10)
207
+ expect(toSliderValue(undefined, 5)).toBe(5)
208
+ })
209
+
210
+ it('coerces tags from arrays, comma strings, or empty', () => {
211
+ expect(toTags(['a', 'b'])).toEqual(['a', 'b'])
212
+ expect(toTags('a, b ,c')).toEqual(['a', 'b', 'c'])
213
+ expect(toTags('')).toEqual([])
214
+ expect(toTags(null)).toEqual([])
215
+ expect(toTags([1, 2])).toEqual(['1', '2'])
216
+ })
217
+ })
218
+
219
+ // --- the form builder: value-driven fields -----------------------------------
220
+
221
+ /** `field <op> value` as a serializable predicate leaf. */
222
+ const cmp = (column: string, op: string, value: unknown) =>
223
+ ({ kind: 'cmp', column, op, value }) as never
224
+
225
+ type Order = { id: string; reason: string; other: string; total: number; approver: string }
226
+
227
+ const orders: EntitySchema<Order> = {
228
+ name: 'orders',
229
+ idField: 'id',
230
+ fields: [
231
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
232
+ { field: 'reason', type: 'enum', options: [{ value: 'damaged', label: 'Damaged' }, { value: 'other', label: 'Other' }] },
233
+ // Only asked for, and only demanded, when the reason is "other".
234
+ {
235
+ field: 'other',
236
+ type: 'text',
237
+ when: { visible: cmp('reason', 'equals', 'other'), required: cmp('reason', 'equals', 'other') },
238
+ },
239
+ { field: 'total', type: 'number' },
240
+ // Locked once the order is large enough to need sign-off.
241
+ { field: 'approver', type: 'text', when: { disabled: cmp('total', 'lessThan', 1000) } },
242
+ ],
243
+ }
244
+
245
+ describe('conditional fields', () => {
246
+ const specOf = (name: string) => schemaToFormFields(orders).find((f) => f.field === name)!
247
+
248
+ it('shows and demands a field only while its condition holds', () => {
249
+ const hidden = fieldState(specOf('other'), { reason: 'damaged', other: '' })
250
+ expect(hidden.visible).toBe(false)
251
+ expect(hidden.required).toBe(false)
252
+
253
+ const shown = fieldState(specOf('other'), { reason: 'other', other: '' })
254
+ expect(shown.visible).toBe(true)
255
+ expect(shown.required).toBe(true)
256
+ })
257
+
258
+ it('locks a field while its disabled condition holds, and readonly always wins', () => {
259
+ expect(fieldState(specOf('approver'), { total: 10 }).disabled).toBe(true)
260
+ expect(fieldState(specOf('approver'), { total: 5000 }).disabled).toBe(false)
261
+ expect(fieldState(specOf('id'), { total: 5000 }).disabled).toBe(true)
262
+ })
263
+
264
+ it('lists only the fields the form is currently showing', () => {
265
+ expect(visibleFormFields(orders, { reason: 'damaged' }).map((f) => f.field)).not.toContain('other')
266
+ expect(visibleFormFields(orders, { reason: 'other' }).map((f) => f.field)).toContain('other')
267
+ })
268
+
269
+ it('never blocks a save on a field the user cannot see', async () => {
270
+ // `other` is required-when-visible, but the reason is "damaged", so it is
271
+ // neither shown nor demanded.
272
+ expect(await validateAll(orders, { reason: 'damaged', other: '', total: 1 })).toEqual({})
273
+ expect(await validateAll(orders, { reason: 'other', other: '', total: 1 })).toEqual({
274
+ other: 'Other is required',
275
+ })
276
+ })
277
+
278
+ it('leaves a hidden field out of the payload, so a stale value is never written', () => {
279
+ const hidden = toSubmitValues(orders, { reason: 'damaged', other: 'stale text', total: 1, approver: '' })
280
+ expect(hidden).not.toHaveProperty('other')
281
+ const shown = toSubmitValues(orders, { reason: 'other', other: 'kept', total: 1, approver: '' })
282
+ expect(shown.other).toBe('kept')
283
+ })
284
+
285
+ it('falls back to showing a field when its condition is malformed', () => {
286
+ // A broken rule must never hide data or block a save.
287
+ const broken: FormFieldDescriptor = {
288
+ field: 'x', label: 'X', type: 'text', editorType: 'text',
289
+ required: false, readonly: false, span: 1,
290
+ when: { visible: { kind: 'nonsense' } as never },
291
+ }
292
+ expect(fieldState(broken, {}).visible).toBe(true)
293
+ expect(fieldState(broken, {}).disabled).toBe(false)
294
+ })
295
+ })
296
+
297
+ describe('no-code validation rules at runtime', () => {
298
+ it('applies each operator the generated server applies', () => {
299
+ const values = { name: 'ab', qty: 5, max: 3 }
300
+ expect(evalValidationRules([{ field: 'name', op: 'minLen', value: 3, message: 'Too short' }], values))
301
+ .toEqual({ name: 'Too short' })
302
+ expect(evalValidationRules([{ field: 'name', op: 'minLen', value: 2, message: 'Too short' }], values)).toEqual({})
303
+ expect(evalValidationRules([{ field: 'qty', op: 'lte', compareTo: 'max', message: 'Over the max' }], values))
304
+ .toEqual({ qty: 'Over the max' })
305
+ expect(evalValidationRules([{ field: 'missing', op: 'required', message: 'Needed' }], values))
306
+ .toEqual({ missing: 'Needed' })
307
+ })
308
+
309
+ it('runs as part of validateAll, so a rule fires in the form, not just on the server', async () => {
310
+ const withRules: EntitySchema<{ id: string; code: string }> = {
311
+ name: 'things',
312
+ idField: 'id',
313
+ fields: [
314
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
315
+ { field: 'code', type: 'text' },
316
+ ],
317
+ validations: [{ field: 'code', op: 'minLen', value: 4, message: 'Code needs 4+ characters' }],
318
+ }
319
+ expect(await validateAll(withRules, { code: 'ab' })).toEqual({ code: 'Code needs 4+ characters' })
320
+ expect(await validateAll(withRules, { code: 'abcd' })).toEqual({})
321
+ })
322
+ })
package/src/edit-panel.ts CHANGED
@@ -13,7 +13,10 @@ import {
13
13
  validateField,
14
14
  type EntitySchema,
15
15
  type FormFieldDescriptor,
16
- } from './schema'
16
+ type ValidationRuleSpec,
17
+ } from './schema.js'
18
+ import { evaluatePredicate } from './expressions/evaluate.js'
19
+ import type { PredicateExpr } from './expressions/expression-types.js'
17
20
 
18
21
  export type EditMode = 'create' | 'edit'
19
22
 
@@ -136,6 +139,117 @@ export function builtInError(field: FormFieldDescriptor, value: unknown): string
136
139
  return null
137
140
  }
138
141
 
142
+ /**
143
+ * Evaluate one of a field's `when` conditions against the values currently in
144
+ * the form. A malformed condition falls back rather than throwing: a bad rule
145
+ * should not make the form unusable, and the safe answer differs per flag
146
+ * (a field stays visible, stays enabled, keeps its static required flag).
147
+ */
148
+ function test(expr: PredicateExpr | undefined, values: Record<string, unknown>, fallback: boolean): boolean {
149
+ if (!expr) return fallback
150
+ try {
151
+ const result = evaluatePredicate(expr, { row: values })
152
+ // An unrecognised node evaluates to undefined rather than throwing, so a
153
+ // non-boolean answer counts as "no opinion" and takes the fallback too.
154
+ return typeof result === 'boolean' ? result : fallback
155
+ } catch {
156
+ return fallback
157
+ }
158
+ }
159
+
160
+ /** Whether a field is shown, editable, and demanded, for the current values. */
161
+ export type FieldState = { visible: boolean; disabled: boolean; required: boolean }
162
+
163
+ /**
164
+ * Resolve a field's live state from its `when` conditions. Read-only always
165
+ * wins over a `disabled` condition, and `when.required` replaces the static
166
+ * `required` flag rather than adding to it, so a rule can make a normally
167
+ * required field optional as well as the other way round.
168
+ */
169
+ export function fieldState(field: FormFieldDescriptor, values: Record<string, unknown>): FieldState {
170
+ return {
171
+ visible: test(field.when?.visible, values, true),
172
+ disabled: field.readonly || test(field.when?.disabled, values, false),
173
+ required: field.when?.required ? test(field.when.required, values, false) : field.required,
174
+ }
175
+ }
176
+
177
+ /** Whether a form section's `visibleWhen` holds for the current values. */
178
+ export function sectionVisible(expr: PredicateExpr, values: Record<string, unknown>): boolean {
179
+ return test(expr, values, true)
180
+ }
181
+
182
+ /** The fields the form currently shows, in order. */
183
+ export function visibleFormFields<TData extends RowData>(
184
+ schema: EntitySchema<TData>,
185
+ values: Record<string, unknown>,
186
+ ): FormFieldDescriptor[] {
187
+ return schemaToFormFields(schema).filter((f) => fieldState(f, values).visible)
188
+ }
189
+
190
+ /** True when any field's behaviour depends on the values (i.e. has a `when`). */
191
+ export function hasFieldConditions<TData extends RowData>(schema: EntitySchema<TData>): boolean {
192
+ return schema.fields.some((f) => !!f.when && (!!f.when.visible || !!f.when.disabled || !!f.when.required))
193
+ }
194
+
195
+ /**
196
+ * Drop the values of fields hidden by a `when.visible` condition.
197
+ *
198
+ * The edit panel already leaves them out via `toSubmitValues`; a server that
199
+ * builds its own payload (the generated SSR route reads a `FormData`) runs it
200
+ * through this so both paths write exactly the same fields. Without it, a field
201
+ * the user could not see would still be saved from a posted form.
202
+ */
203
+ export function stripHiddenValues<TData extends RowData>(
204
+ schema: EntitySchema<TData>,
205
+ values: Record<string, unknown>,
206
+ ): Record<string, unknown> {
207
+ if (!hasFieldConditions(schema)) return values
208
+ const out: Record<string, unknown> = {}
209
+ const shown = new Set(visibleFormFields(schema, values).map((f) => f.field))
210
+ for (const [field, value] of Object.entries(values)) {
211
+ if (!schema.fields.some((f) => f.field === field) || shown.has(field)) out[field] = value
212
+ }
213
+ return out
214
+ }
215
+
216
+ /**
217
+ * Run the schema's no-code validation rules (`EntitySchema.validations`).
218
+ *
219
+ * These already compile into the generated app's server-side `hooks.validate`;
220
+ * this is the matching interpreter, so the same rule also fires live in the edit
221
+ * panel and the designer preview instead of only after a round-trip to a
222
+ * generated server. The comparisons mirror `compileValidation` in
223
+ * `studio/emit-schema.ts` exactly - string equality for eq/ne, numeric ordering
224
+ * for lt/lte/gt/gte, length for minLen/maxLen - so a rule cannot mean one thing
225
+ * in the form and another on the server.
226
+ */
227
+ export function evalValidationRules(rules: ReadonlyArray<ValidationRuleSpec>, values: Record<string, unknown>): Record<string, string> {
228
+ const errors: Record<string, string> = {}
229
+ for (const rule of rules) {
230
+ if (errors[rule.field]) continue // first failing rule per field wins
231
+ const left = values[rule.field]
232
+ const num = () => (rule.compareTo ? Number(values[rule.compareTo]) : Number(rule.value))
233
+ const str = () => (rule.compareTo ? String(values[rule.compareTo]) : String(rule.value ?? ''))
234
+ const len = String(left ?? '').length
235
+ let holds: boolean
236
+ switch (rule.op) {
237
+ case 'eq': holds = String(left) === str(); break
238
+ case 'ne': holds = String(left) !== str(); break
239
+ case 'lt': holds = Number(left) < num(); break
240
+ case 'lte': holds = Number(left) <= num(); break
241
+ case 'gt': holds = Number(left) > num(); break
242
+ case 'gte': holds = Number(left) >= num(); break
243
+ case 'required': holds = left != null && left !== ''; break
244
+ case 'maxLen': holds = len <= Number(rule.value); break
245
+ case 'minLen': holds = len >= Number(rule.value); break
246
+ default: holds = true
247
+ }
248
+ if (!holds) errors[rule.field] = rule.message
249
+ }
250
+ return errors
251
+ }
252
+
139
253
  /**
140
254
  * Seed the form's editable state. Uses the row's value when editing, else the
141
255
  * field's `defaultValue`, else a type-appropriate empty. Covers every field the
@@ -156,6 +270,48 @@ export function buildInitialValues<TData extends RowData>(
156
270
  return values
157
271
  }
158
272
 
273
+ /**
274
+ * The error for one field, or null. Required-but-empty first, then the field's
275
+ * own constraints, then its Standard Schema validator. Read-only and
276
+ * condition-hidden fields never produce one: the user cannot act on them, so an
277
+ * error there would be a dead end.
278
+ *
279
+ * Shared by whole-form validation and by the panel's on-blur check, so a field
280
+ * cannot pass one and fail the other.
281
+ */
282
+ async function fieldError(f: FormFieldDescriptor, values: Record<string, unknown>): Promise<string | null> {
283
+ if (f.readonly) return null
284
+ const state = fieldState(f, values)
285
+ if (!state.visible) return null
286
+ const value = values[f.field]
287
+ if (state.required && f.type !== 'boolean' && isEmpty(value)) return `${f.label} is required`
288
+ const builtIn = builtInError(f, value)
289
+ if (builtIn) return builtIn
290
+ if (!isEmpty(value)) return (await validateField(f, value)) ?? null
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * Validate a single field, for checking as the user leaves it rather than making
296
+ * them submit to find out. Includes the schema-level rules that name this field,
297
+ * so a cross-field rule surfaces on the field it blames.
298
+ */
299
+ export async function validateOne<TData extends RowData>(
300
+ schema: EntitySchema<TData>,
301
+ field: string,
302
+ values: Record<string, unknown>,
303
+ ): Promise<string | null> {
304
+ const descriptor = schemaToFormFields(schema).find((f) => f.field === field)
305
+ if (!descriptor) return null
306
+ const own = await fieldError(descriptor, values)
307
+ if (own) return own
308
+ if (schema.validations?.length) {
309
+ const byRule = evalValidationRules(schema.validations, values)[field]
310
+ if (byRule) return byRule
311
+ }
312
+ return (await validateEntity(schema, values as Partial<TData>))[field] ?? null
313
+ }
314
+
159
315
  /**
160
316
  * Validate every editable field: required-but-empty first, then the field's
161
317
  * Standard Schema validator (if any), then the schema-level cross-field
@@ -169,23 +325,15 @@ export async function validateAll<TData extends RowData>(
169
325
  ): Promise<Record<string, string>> {
170
326
  const errors: Record<string, string> = {}
171
327
  for (const f of schemaToFormFields(schema)) {
172
- if (f.readonly) continue
173
- const value = values[f.field]
174
- if (f.required && f.type !== 'boolean' && isEmpty(value)) {
175
- errors[f.field] = `${f.label} is required`
176
- continue
177
- }
178
- const builtIn = builtInError(f, value)
179
- if (builtIn) {
180
- errors[f.field] = builtIn
181
- continue
182
- }
183
- if (!isEmpty(value)) {
184
- const msg = await validateField(f, value)
185
- if (msg) errors[f.field] = msg
328
+ const msg = await fieldError(f, values)
329
+ if (msg) errors[f.field] = msg
330
+ }
331
+ // No-code rules, then the cross-field hook. Neither overwrites a per-field error.
332
+ if (schema.validations?.length) {
333
+ for (const [field, message] of Object.entries(evalValidationRules(schema.validations, values))) {
334
+ if (!errors[field]) errors[field] = message
186
335
  }
187
336
  }
188
- // Cross-field rules run last, and never overwrite a per-field error.
189
337
  const crossField = await validateEntity(schema, values as Partial<TData>)
190
338
  for (const [field, message] of Object.entries(crossField)) {
191
339
  if (!errors[field]) errors[field] = message
@@ -216,6 +364,9 @@ export function toSubmitValues<TData extends RowData>(
216
364
  const out: Record<string, unknown> = {}
217
365
  for (const f of schemaToFormFields(schema)) {
218
366
  if (f.readonly) continue
367
+ // A field hidden by its `when.visible` condition is left out entirely, so a
368
+ // value the user can no longer see is never written back.
369
+ if (!fieldState(f, values).visible) continue
219
370
  out[f.field] = coerceOut(f, values[f.field])
220
371
  }
221
372
  return out as Partial<TData>
@@ -11,7 +11,7 @@ import type {
11
11
  ExprRow,
12
12
  PredicateExpr,
13
13
  ScalarExpr,
14
- } from './expression-types'
14
+ } from './expression-types.js'
15
15
 
16
16
  type Scalar = number | string | boolean | null
17
17
 
@@ -28,13 +28,13 @@ import type {
28
28
  ComparisonOp,
29
29
  PredicateExpr,
30
30
  ScalarExpr,
31
- } from './expression-types'
31
+ } from './expression-types.js'
32
32
  import {
33
33
  columnRefText,
34
34
  resolveColumnRef,
35
35
  type ExprColumn,
36
- } from './expression-columns'
37
- import { isBuiltinFunction } from './evaluate'
36
+ } from './expression-columns.js'
37
+ import { isBuiltinFunction } from './evaluate.js'
38
38
 
39
39
  // ---------------------------------------------------------------------------
40
40
  // Tokenizer
package/src/index.ts CHANGED
@@ -181,6 +181,8 @@ export {
181
181
  type EntityFieldType,
182
182
  type EntityHooks,
183
183
  type FormFieldDescriptor,
184
+ type FormLayout,
185
+ type FormSection,
184
186
  type StudioEditorType,
185
187
  type StandardSchemaV1,
186
188
  type StandardSchemaResult,
@@ -274,11 +276,17 @@ export {
274
276
  buildInitialValues,
275
277
  controlKind,
276
278
  editMode,
279
+ evalValidationRules,
280
+ fieldState,
281
+ hasFieldConditions,
277
282
  rowId,
283
+ stripHiddenValues,
278
284
  toSubmitValues,
279
285
  validateAll,
286
+ visibleFormFields,
280
287
  type EditMode,
281
288
  type ControlKind,
289
+ type FieldState,
282
290
  } from './edit-panel'
283
291
  export { default as SvGridEditPanel } from './SvGridEditPanel.svelte'
284
292
  export { default as SvLookupInput } from './SvLookupInput.svelte'
@@ -22,14 +22,86 @@ export type LicenseInfo = {
22
22
  status: LicenseStatus
23
23
  /** True when the key permits use (dev / eval / licensed). */
24
24
  valid: boolean
25
+ /** Expiry encoded in the key, when it carries one. Undefined otherwise. */
26
+ expiresAt?: Date
27
+ /**
28
+ * True once `expiresAt` has passed. Deliberately does NOT clear `valid`:
29
+ * an expired trial keeps working and the callers nudge instead. Turning a
30
+ * lapsed key into a hard stop would break a running app on a date boundary,
31
+ * which is exactly what a soft gate exists to avoid.
32
+ */
33
+ expired?: boolean
25
34
  }
26
35
 
27
- /** Classify a license key string. Pure and side-effect-free. */
28
- export function checkLicenseKey(key: string | null | undefined): LicenseInfo {
36
+ /**
37
+ * Pull the expiry out of a key.
38
+ *
39
+ * Keys are `SVENTERPRISE[-KIND]-<SLUG>-<SEATS>-<DATE>-<RANDOM>`, so the date is
40
+ * always the second-to-last segment regardless of whether a KIND is present.
41
+ * Two widths are accepted:
42
+ *
43
+ * YYYYMM - subscription granularity; expires at the end of that month.
44
+ * YYYYMMDD - trial granularity; expires at the end of that day.
45
+ *
46
+ * The 6-digit form came first, so every key issued before day-precision trials
47
+ * existed still parses. Anything else (including the older keys whose slug sits
48
+ * in that position) yields null, which reads as "no expiry encoded".
49
+ *
50
+ * Boundaries are UTC so a key does not lapse at a different instant per
51
+ * timezone. Kept in sync with `tools/issue-license.mjs`.
52
+ */
53
+ export function parseLicenseExpiry(key: string | null | undefined): Date | null {
54
+ if (!key) return null
55
+ const segments = key.split('-')
56
+ if (segments.length < 3) return null
57
+ const stamp = segments[segments.length - 2]
58
+ if (stamp == null) return null
59
+
60
+ if (/^\d{6}$/.test(stamp)) {
61
+ const year = Number(stamp.slice(0, 4))
62
+ const month = Number(stamp.slice(4, 6))
63
+ if (month < 1 || month > 12) return null
64
+ // Date.UTC's month is 0-based, so passing the 1-based month lands on the
65
+ // first of the FOLLOWING month; one millisecond earlier is the last instant
66
+ // of the month the key names.
67
+ return new Date(Date.UTC(year, month, 1) - 1)
68
+ }
69
+
70
+ if (/^\d{8}$/.test(stamp)) {
71
+ const year = Number(stamp.slice(0, 4))
72
+ const month = Number(stamp.slice(4, 6))
73
+ const day = Number(stamp.slice(6, 8))
74
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null
75
+ const end = new Date(Date.UTC(year, month - 1, day, 23, 59, 59, 999))
76
+ // Reject a date that rolled over (e.g. 20260231), which would otherwise
77
+ // silently grant a few extra days.
78
+ if (end.getUTCMonth() !== month - 1) return null
79
+ return end
80
+ }
81
+
82
+ return null
83
+ }
84
+
85
+ /** Classify a license key string. Pure and side-effect-free.
86
+ * `now` is injectable so expiry behaviour is testable without faking clocks. */
87
+ export function checkLicenseKey(
88
+ key: string | null | undefined,
89
+ now: Date = new Date(),
90
+ ): LicenseInfo {
29
91
  if (key == null || key === '') return { status: 'unset', valid: false }
30
92
  if (!key.startsWith(VALID_PREFIX)) return { status: 'invalid', valid: false }
31
93
  if (REVOKED_KEYS.has(key)) return { status: 'revoked', valid: false }
32
- if (key.startsWith('SVENTERPRISE-DEV')) return { status: 'dev', valid: true }
33
- if (key.startsWith('SVENTERPRISE-EVAL')) return { status: 'eval', valid: true }
34
- return { status: 'licensed', valid: true }
94
+
95
+ const expiresAt = parseLicenseExpiry(key) ?? undefined
96
+ // Left undefined rather than false when the key encodes no date, so callers
97
+ // can tell "not expired" apart from "no expiry to check".
98
+ const expired = expiresAt ? now.getTime() > expiresAt.getTime() : undefined
99
+
100
+ const status: LicenseStatus = key.startsWith('SVENTERPRISE-DEV')
101
+ ? 'dev'
102
+ : key.startsWith('SVENTERPRISE-EVAL')
103
+ ? 'eval'
104
+ : 'licensed'
105
+
106
+ return { status, valid: true, expiresAt, expired }
35
107
  }
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { checkLicenseKey, parseLicenseExpiry } from './license-core'
4
+ import {
5
+ assertEnterpriseLicensed,
6
+ clearLicenseKey,
7
+ getLicenseExpiry,
8
+ isLicenseExpired,
9
+ setLicenseKey,
10
+ } from './license'
11
+ import { dismissUpgradePrompt } from './upgrade-prompt'
12
+
13
+ const KEY = 'SVENTERPRISE-EVAL-GUNEI-5-20260904-RGY2TN'
14
+ const CARD = '[data-svgrid-enterprise-upgrade]'
15
+
16
+ describe('trial key expiry parsing', () => {
17
+ it('unlocks Enterprise today', () => {
18
+ const info = checkLicenseKey(KEY, new Date('2026-08-21T12:00:00Z'))
19
+ expect(info).toMatchObject({ status: 'eval', valid: true, expired: false })
20
+ })
21
+
22
+ it('expires end of 2026-09-04, not end of September', () => {
23
+ expect(parseLicenseExpiry(KEY)?.toISOString()).toBe('2026-09-04T23:59:59.999Z')
24
+ })
25
+
26
+ it('still valid on the final day', () => {
27
+ expect(checkLicenseKey(KEY, new Date('2026-09-04T23:00:00Z')).expired).toBe(false)
28
+ })
29
+
30
+ it('expired the next day, but still permitted', () => {
31
+ const info = checkLicenseKey(KEY, new Date('2026-09-05T00:30:00Z'))
32
+ expect(info.expired).toBe(true)
33
+ expect(info.valid).toBe(true)
34
+ })
35
+
36
+ it('keeps parsing month-granularity keys issued before day precision', () => {
37
+ expect(parseLicenseExpiry('SVENTERPRISE-ACME-5-202705-ABC123')?.toISOString()).toBe(
38
+ '2027-05-31T23:59:59.999Z',
39
+ )
40
+ })
41
+
42
+ it('a key with no encoded date has no expiry', () => {
43
+ const info = checkLicenseKey('SVENTERPRISE-EVAL-acme-2026')
44
+ expect(info.expiresAt).toBeUndefined()
45
+ expect(info.expired).toBeUndefined()
46
+ })
47
+ })
48
+
49
+ describe('what happens after the trial ends', () => {
50
+ beforeEach(() => {
51
+ vi.useRealTimers()
52
+ clearLicenseKey()
53
+ dismissUpgradePrompt()
54
+ document.body.innerHTML = ''
55
+ })
56
+
57
+ it('during the trial: no card, and nothing thrown', () => {
58
+ vi.useFakeTimers({ now: new Date('2026-08-25T10:00:00Z') })
59
+ setLicenseKey(KEY)
60
+ expect(isLicenseExpired()).toBe(false)
61
+ expect(() => assertEnterpriseLicensed('Export')).not.toThrow()
62
+ expect(document.querySelector(CARD)).toBeNull()
63
+ })
64
+
65
+ it('after the trial: pops the message, and STILL does not throw', () => {
66
+ vi.useFakeTimers({ now: new Date('2026-09-20T10:00:00Z') })
67
+ setLicenseKey(KEY)
68
+ expect(isLicenseExpired()).toBe(true)
69
+
70
+ expect(() => assertEnterpriseLicensed('Export')).not.toThrow()
71
+
72
+ const card = document.querySelector(CARD)
73
+ expect(card).not.toBeNull()
74
+ expect(card!.textContent).toContain('Your trial has ended')
75
+ expect(card!.textContent).toContain('everything still works')
76
+ })
77
+
78
+ it('exposes the expiry date so a host can render its own banner', () => {
79
+ vi.useFakeTimers({ now: new Date('2026-09-20T10:00:00Z') })
80
+ setLicenseKey(KEY)
81
+ expect(getLicenseExpiry()?.toISOString()).toBe('2026-09-04T23:59:59.999Z')
82
+ })
83
+
84
+ it('a paid key never shows the card', () => {
85
+ vi.useFakeTimers({ now: new Date('2026-09-20T10:00:00Z') })
86
+ setLicenseKey('SVENTERPRISE-ACME-5-202705-ABC123')
87
+ expect(isLicenseExpired()).toBe(false)
88
+ assertEnterpriseLicensed('Export')
89
+ expect(document.querySelector(CARD)).toBeNull()
90
+ })
91
+ })