unspaghettit 0.2.0 → 0.3.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 (270) hide show
  1. package/build/client/_app/immutable/assets/0.DSctqr5I.css +1 -0
  2. package/build/client/_app/immutable/assets/0.DSctqr5I.css.br +0 -0
  3. package/build/client/_app/immutable/assets/0.DSctqr5I.css.gz +0 -0
  4. package/build/client/_app/immutable/assets/BehaviorGraph.Bk0xQRZk.css +1 -0
  5. package/build/client/_app/immutable/assets/BehaviorGraph.Bk0xQRZk.css.br +0 -0
  6. package/build/client/_app/immutable/assets/BehaviorGraph.Bk0xQRZk.css.gz +0 -0
  7. package/build/client/_app/immutable/chunks/9nXQ5qrY2.js +1 -0
  8. package/build/client/_app/immutable/chunks/9nXQ5qrY2.js.br +0 -0
  9. package/build/client/_app/immutable/chunks/9nXQ5qrY2.js.gz +0 -0
  10. package/build/client/_app/immutable/chunks/B439_FLv.js +1 -0
  11. package/build/client/_app/immutable/chunks/B439_FLv.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/B439_FLv.js.gz +0 -0
  13. package/build/client/_app/immutable/chunks/BCEY79Dw.js +1 -0
  14. package/build/client/_app/immutable/chunks/BCEY79Dw.js.br +2 -0
  15. package/build/client/_app/immutable/chunks/BCEY79Dw.js.gz +0 -0
  16. package/build/client/_app/immutable/chunks/{DBJWcC6Y.js → BYIrIC5L.js} +1 -1
  17. package/build/client/_app/immutable/chunks/BYIrIC5L.js.br +0 -0
  18. package/build/client/_app/immutable/chunks/BYIrIC5L.js.gz +0 -0
  19. package/build/client/_app/immutable/chunks/B_9TWPrx2.js +1 -0
  20. package/build/client/_app/immutable/chunks/B_9TWPrx2.js.br +0 -0
  21. package/build/client/_app/immutable/chunks/B_9TWPrx2.js.gz +0 -0
  22. package/build/client/_app/immutable/chunks/BvOhVtZg.js +1 -0
  23. package/build/client/_app/immutable/chunks/BvOhVtZg.js.br +1 -0
  24. package/build/client/_app/immutable/chunks/BvOhVtZg.js.gz +0 -0
  25. package/build/client/_app/immutable/chunks/CY3em1ma2.js +1 -0
  26. package/build/client/_app/immutable/chunks/CY3em1ma2.js.br +0 -0
  27. package/build/client/_app/immutable/chunks/CY3em1ma2.js.gz +0 -0
  28. package/build/client/_app/immutable/chunks/CgdRZPgI.js +1 -0
  29. package/build/client/_app/immutable/chunks/CgdRZPgI.js.br +0 -0
  30. package/build/client/_app/immutable/chunks/CgdRZPgI.js.gz +0 -0
  31. package/build/client/_app/immutable/chunks/D5speDV82.js +908 -0
  32. package/build/client/_app/immutable/chunks/D5speDV82.js.br +0 -0
  33. package/build/client/_app/immutable/chunks/D5speDV82.js.gz +0 -0
  34. package/build/client/_app/immutable/chunks/DkxwAIfJ2.js +1 -0
  35. package/build/client/_app/immutable/chunks/DkxwAIfJ2.js.br +0 -0
  36. package/build/client/_app/immutable/chunks/DkxwAIfJ2.js.gz +0 -0
  37. package/build/client/_app/immutable/chunks/{DatGSObE.js → OJscNS3T.js} +1 -1
  38. package/build/client/_app/immutable/chunks/OJscNS3T.js.br +0 -0
  39. package/build/client/_app/immutable/chunks/OJscNS3T.js.gz +0 -0
  40. package/build/client/_app/immutable/chunks/U9p9CtKG2.js +2 -0
  41. package/build/client/_app/immutable/chunks/U9p9CtKG2.js.br +0 -0
  42. package/build/client/_app/immutable/chunks/U9p9CtKG2.js.gz +0 -0
  43. package/build/client/_app/immutable/entry/app.Cd4S3giu.js +2 -0
  44. package/build/client/_app/immutable/entry/app.Cd4S3giu.js.br +0 -0
  45. package/build/client/_app/immutable/entry/app.Cd4S3giu.js.gz +0 -0
  46. package/build/client/_app/immutable/entry/start.C3xXQVkq.js +1 -0
  47. package/build/client/_app/immutable/entry/start.C3xXQVkq.js.br +0 -0
  48. package/build/client/_app/immutable/entry/start.C3xXQVkq.js.gz +0 -0
  49. package/build/client/_app/immutable/nodes/0.dIOlQ-0y.js +4 -0
  50. package/build/client/_app/immutable/nodes/0.dIOlQ-0y.js.br +0 -0
  51. package/build/client/_app/immutable/nodes/0.dIOlQ-0y.js.gz +0 -0
  52. package/build/client/_app/immutable/nodes/{1.KYdA6ppX.js → 1.Dyte3Ggf.js} +1 -1
  53. package/build/client/_app/immutable/nodes/1.Dyte3Ggf.js.br +2 -0
  54. package/build/client/_app/immutable/nodes/1.Dyte3Ggf.js.gz +0 -0
  55. package/build/client/_app/immutable/nodes/10.ivxAosDg.js +2 -0
  56. package/build/client/_app/immutable/nodes/10.ivxAosDg.js.br +0 -0
  57. package/build/client/_app/immutable/nodes/10.ivxAosDg.js.gz +0 -0
  58. package/build/client/_app/immutable/nodes/{9.CMW6a2Lg.js → 11.wvMfJKC2.js} +1 -1
  59. package/build/client/_app/immutable/nodes/11.wvMfJKC2.js.br +0 -0
  60. package/build/client/_app/immutable/nodes/11.wvMfJKC2.js.gz +0 -0
  61. package/build/client/_app/immutable/nodes/{2.DBz20KgG.js → 2.CmPPom9Z.js} +1 -1
  62. package/build/client/_app/immutable/nodes/2.CmPPom9Z.js.br +0 -0
  63. package/build/client/_app/immutable/nodes/2.CmPPom9Z.js.gz +0 -0
  64. package/build/client/_app/immutable/nodes/3.D-iCGCEx.js +1 -0
  65. package/build/client/_app/immutable/nodes/3.D-iCGCEx.js.br +0 -0
  66. package/build/client/_app/immutable/nodes/3.D-iCGCEx.js.gz +0 -0
  67. package/build/client/_app/immutable/nodes/{4.BvMzqBJj.js → 4.DbfAvO8Z.js} +1 -1
  68. package/build/client/_app/immutable/nodes/4.DbfAvO8Z.js.br +0 -0
  69. package/build/client/_app/immutable/nodes/4.DbfAvO8Z.js.gz +0 -0
  70. package/build/client/_app/immutable/nodes/5.CC5Q7lVw.js +42 -0
  71. package/build/client/_app/immutable/nodes/5.CC5Q7lVw.js.br +0 -0
  72. package/build/client/_app/immutable/nodes/5.CC5Q7lVw.js.gz +0 -0
  73. package/build/client/_app/immutable/nodes/6.CHIjlzpO.js +1 -0
  74. package/build/client/_app/immutable/nodes/6.CHIjlzpO.js.br +0 -0
  75. package/build/client/_app/immutable/nodes/6.CHIjlzpO.js.gz +0 -0
  76. package/build/client/_app/immutable/nodes/{6.BOHISqs-.js → 7.Ejs18ZUc.js} +1 -1
  77. package/build/client/_app/immutable/nodes/7.Ejs18ZUc.js.br +0 -0
  78. package/build/client/_app/immutable/nodes/7.Ejs18ZUc.js.gz +0 -0
  79. package/build/client/_app/immutable/nodes/{7.CemgNJfw.js → 8.B-HweAc8.js} +1 -1
  80. package/build/client/_app/immutable/nodes/8.B-HweAc8.js.br +0 -0
  81. package/build/client/_app/immutable/nodes/8.B-HweAc8.js.gz +0 -0
  82. package/build/client/_app/immutable/nodes/9.CKPeM6tx.js +5 -0
  83. package/build/client/_app/immutable/nodes/9.CKPeM6tx.js.br +0 -0
  84. package/build/client/_app/immutable/nodes/9.CKPeM6tx.js.gz +0 -0
  85. package/build/client/_app/version.json +1 -1
  86. package/build/client/_app/version.json.br +0 -0
  87. package/build/client/_app/version.json.gz +0 -0
  88. package/build/client/lyriks_logo.svg +148 -0
  89. package/build/client/lyriks_logo.svg.br +0 -0
  90. package/build/client/lyriks_logo.svg.gz +0 -0
  91. package/build/server/chunks/{0-C_o0oz-N.js → 0-Co8kcANG.js} +4 -4
  92. package/build/server/chunks/{0-C_o0oz-N.js.map → 0-Co8kcANG.js.map} +1 -1
  93. package/build/server/chunks/1-BSUItTig.js +9 -0
  94. package/build/server/chunks/{1-DPpKAKXV.js.map → 1-BSUItTig.js.map} +1 -1
  95. package/build/server/chunks/10-BygvxrZp.js +9 -0
  96. package/build/server/chunks/10-BygvxrZp.js.map +1 -0
  97. package/build/server/chunks/11-DRx0tRx2.js +9 -0
  98. package/build/server/chunks/11-DRx0tRx2.js.map +1 -0
  99. package/build/server/chunks/{2-AlfFqtL1.js → 2-BQT3m1vc.js} +3 -3
  100. package/build/server/chunks/{2-AlfFqtL1.js.map → 2-BQT3m1vc.js.map} +1 -1
  101. package/build/server/chunks/{3-vbjUt_51.js → 3-DPZ9BquJ.js} +3 -3
  102. package/build/server/chunks/{3-vbjUt_51.js.map → 3-DPZ9BquJ.js.map} +1 -1
  103. package/build/server/chunks/{4-BNow4x6D.js → 4-DHo47YX6.js} +3 -3
  104. package/build/server/chunks/{4-BNow4x6D.js.map → 4-DHo47YX6.js.map} +1 -1
  105. package/build/server/chunks/5-Cp9evBAG.js +9 -0
  106. package/build/server/chunks/5-Cp9evBAG.js.map +1 -0
  107. package/build/server/chunks/6-DiBq3bOV.js +9 -0
  108. package/build/server/chunks/6-DiBq3bOV.js.map +1 -0
  109. package/build/server/chunks/{6-QQ7r8Rd5.js → 7-C4hmS0dG.js} +3 -3
  110. package/build/server/chunks/{6-QQ7r8Rd5.js.map → 7-C4hmS0dG.js.map} +1 -1
  111. package/build/server/chunks/{7-CbPLGaIG.js → 8-CFFuDzBC.js} +4 -4
  112. package/build/server/chunks/{7-CbPLGaIG.js.map → 8-CFFuDzBC.js.map} +1 -1
  113. package/build/server/chunks/9-nhhKZJrs.js +9 -0
  114. package/build/server/chunks/9-nhhKZJrs.js.map +1 -0
  115. package/build/server/chunks/BehaviorGraph-m5kYj5HH.js +757 -0
  116. package/build/server/chunks/BehaviorGraph-m5kYj5HH.js.map +1 -0
  117. package/build/server/chunks/{FeatureCard-BQOY6gJQ.js → FeatureCard-CfbXNYe8.js} +2 -2
  118. package/build/server/chunks/{FeatureCard-BQOY6gJQ.js.map → FeatureCard-CfbXNYe8.js.map} +1 -1
  119. package/build/server/chunks/{ProgressBar-CfhccQ83.js → ProgressBar-DDoQJ_C9.js} +2 -2
  120. package/build/server/chunks/ProgressBar-DDoQJ_C9.js.map +1 -0
  121. package/build/server/chunks/{ProjectsIndex-CoDrvRya.js → ProjectsIndex-DUVJ3hyL.js} +2 -2
  122. package/build/server/chunks/{ProjectsIndex-CoDrvRya.js.map → ProjectsIndex-DUVJ3hyL.js.map} +1 -1
  123. package/build/server/chunks/TransitionCatalog-B8zHs-2E.js +271 -0
  124. package/build/server/chunks/TransitionCatalog-B8zHs-2E.js.map +1 -0
  125. package/build/server/chunks/{_layout.svelte-BREws55o.js → _layout.svelte-CLTmk0xU.js} +66 -15
  126. package/build/server/chunks/_layout.svelte-CLTmk0xU.js.map +1 -0
  127. package/build/server/chunks/{_page.svelte-De508ek8.js → _page.svelte-B1nG3PKn.js} +4 -4
  128. package/build/server/chunks/{_page.svelte-De508ek8.js.map → _page.svelte-B1nG3PKn.js.map} +1 -1
  129. package/build/server/chunks/{_page.svelte-Zf9H4KOP.js → _page.svelte-BW_nbAAH.js} +11 -315
  130. package/build/server/chunks/_page.svelte-BW_nbAAH.js.map +1 -0
  131. package/build/server/chunks/{_page.svelte-BqSC-1vK.js → _page.svelte-Caq7J0jU.js} +91 -47
  132. package/build/server/chunks/_page.svelte-Caq7J0jU.js.map +1 -0
  133. package/build/server/chunks/{_page.svelte-B7hT3P8E.js → _page.svelte-Cham-dsM.js} +4 -4
  134. package/build/server/chunks/{_page.svelte-B7hT3P8E.js.map → _page.svelte-Cham-dsM.js.map} +1 -1
  135. package/build/server/chunks/_page.svelte-Dhwjwph_.js +41 -0
  136. package/build/server/chunks/_page.svelte-Dhwjwph_.js.map +1 -0
  137. package/build/server/chunks/{_page.svelte-BKKCa9H5.js → _page.svelte-DlFVT40-.js} +3 -3
  138. package/build/server/chunks/{_page.svelte-BKKCa9H5.js.map → _page.svelte-DlFVT40-.js.map} +1 -1
  139. package/build/server/chunks/{_page.svelte-BtI2zZ_Z.js → _page.svelte-NVT2dzpG.js} +176 -327
  140. package/build/server/chunks/_page.svelte-NVT2dzpG.js.map +1 -0
  141. package/build/server/chunks/{_page.svelte-BKTveFAj.js → _page.svelte-Oj-W7G5s.js} +5 -5
  142. package/build/server/chunks/{_page.svelte-BKTveFAj.js.map → _page.svelte-Oj-W7G5s.js.map} +1 -1
  143. package/build/server/chunks/_page.svelte-Z_kK2lHY.js +68 -0
  144. package/build/server/chunks/_page.svelte-Z_kK2lHY.js.map +1 -0
  145. package/build/server/chunks/{builderModeStore.svelte-ihupr-3p.js → builderModeStore.svelte-BpRIU_zP.js} +217 -6
  146. package/build/server/chunks/builderModeStore.svelte-BpRIU_zP.js.map +1 -0
  147. package/build/server/chunks/client-DeX3TC3s.js +51 -0
  148. package/build/server/chunks/{client-DfpLcAZ9.js.map → client-DeX3TC3s.js.map} +1 -1
  149. package/build/server/chunks/{error.svelte-C35KOpru.js → error.svelte-Cdjeq3L2.js} +4 -4
  150. package/build/server/chunks/{error.svelte-C35KOpru.js.map → error.svelte-Cdjeq3L2.js.map} +1 -1
  151. package/build/server/chunks/featureStore.svelte-DIYgPBVm.js +161 -0
  152. package/build/server/chunks/featureStore.svelte-DIYgPBVm.js.map +1 -0
  153. package/build/server/chunks/{hooks.server-Rv301GTB.js → hooks.server-y3jdg_sB.js} +6 -2
  154. package/build/server/chunks/hooks.server-y3jdg_sB.js.map +1 -0
  155. package/build/server/chunks/{internal-BPKrFkK1.js → internal-KYK0WpL7.js} +4 -4
  156. package/build/server/chunks/{internal-BPKrFkK1.js.map → internal-KYK0WpL7.js.map} +1 -1
  157. package/build/server/chunks/projectFeaturesStore.svelte-2o-72_vr.js +313 -0
  158. package/build/server/chunks/projectFeaturesStore.svelte-2o-72_vr.js.map +1 -0
  159. package/build/server/chunks/{reconcile-Dv7jS3C8.js → reconcile-B5xqb6-s.js} +3 -272
  160. package/build/server/chunks/reconcile-B5xqb6-s.js.map +1 -0
  161. package/build/server/chunks/registry-DqAn_hVE.js +21 -0
  162. package/build/server/chunks/registry-DqAn_hVE.js.map +1 -0
  163. package/build/server/chunks/{state-CpLVNZq7.js → state-DBjl9lhV.js} +2 -2
  164. package/build/server/chunks/{state-CpLVNZq7.js.map → state-DBjl9lhV.js.map} +1 -1
  165. package/build/server/index.js +1 -1
  166. package/build/server/index.js.map +1 -1
  167. package/build/server/manifest.js +33 -17
  168. package/build/server/manifest.js.map +1 -1
  169. package/cli/commands/dashboard.ts +14 -0
  170. package/cli/commands/init.ts +26 -0
  171. package/cli/commands/theme.ts +62 -0
  172. package/cli/unspa.ts +38 -2
  173. package/cli/util/context-files.ts +5 -0
  174. package/cli/util/theme.ts +34 -0
  175. package/mcp-server/sync-notifier.ts +88 -35
  176. package/package.json +2 -1
  177. package/src/app.css +187 -0
  178. package/src/app.html +15 -1
  179. package/src/features/behavior-model/domain/services/BehaviorGraphModel.ts +531 -0
  180. package/src/features/behavior-model/presentation/adapters/VisBehaviorGraphRenderer.ts +492 -0
  181. package/src/features/behavior-model/presentation/components/BehaviorGraph.svelte +370 -0
  182. package/src/features/behavior-model/presentation/components/FeatureHeader.svelte +13 -5
  183. package/src/features/behavior-model/presentation/view-models/BehaviorGraphTheme.ts +43 -0
  184. package/src/features/builder-mode/domain/BuilderModeDashboard.ts +7 -1
  185. package/src/features/builder-mode/presentation/components/BuilderModeDashboard.svelte +78 -16
  186. package/src/features/builder-mode/presentation/components/BuilderTagChips.svelte +25 -0
  187. package/src/features/builder-mode/presentation/stores/builderModeStore.svelte.ts +247 -3
  188. package/src/features/projects/presentation/components/ProjectEditor.svelte +7 -0
  189. package/src/features/simulator/application/use-cases/RunScenarios.ts +15 -1
  190. package/src/hooks.server.ts +11 -1
  191. package/src/lib/theme/registry.ts +77 -0
  192. package/src/lib/theme/themeStore.svelte.ts +64 -0
  193. package/src/routes/+layout.svelte +184 -30
  194. package/src/routes/features/[id]/graph/+page.svelte +34 -0
  195. package/src/routes/projects/[id]/graph/+page.svelte +79 -0
  196. package/src/shared/presentation/components/ProgressBar.svelte +1 -1
  197. package/src/shared/presentation/toast/SyncToast.svelte +14 -3
  198. package/src/shared/presentation/toast/viewLinkResolver.ts +32 -0
  199. package/static/lyriks_logo.svg +148 -0
  200. package/build/client/_app/immutable/assets/0.DFMDYAU9.css +0 -1
  201. package/build/client/_app/immutable/assets/0.DFMDYAU9.css.br +0 -0
  202. package/build/client/_app/immutable/assets/0.DFMDYAU9.css.gz +0 -0
  203. package/build/client/_app/immutable/chunks/BO66rBOa2.js +0 -1
  204. package/build/client/_app/immutable/chunks/BO66rBOa2.js.br +0 -0
  205. package/build/client/_app/immutable/chunks/BO66rBOa2.js.gz +0 -0
  206. package/build/client/_app/immutable/chunks/DBJWcC6Y.js.br +0 -0
  207. package/build/client/_app/immutable/chunks/DBJWcC6Y.js.gz +0 -0
  208. package/build/client/_app/immutable/chunks/DHoA038D.js +0 -1
  209. package/build/client/_app/immutable/chunks/DHoA038D.js.br +0 -2
  210. package/build/client/_app/immutable/chunks/DHoA038D.js.gz +0 -0
  211. package/build/client/_app/immutable/chunks/DatGSObE.js.br +0 -0
  212. package/build/client/_app/immutable/chunks/DatGSObE.js.gz +0 -0
  213. package/build/client/_app/immutable/chunks/DjWKKtqp.js +0 -1
  214. package/build/client/_app/immutable/chunks/DjWKKtqp.js.br +0 -1
  215. package/build/client/_app/immutable/chunks/DjWKKtqp.js.gz +0 -0
  216. package/build/client/_app/immutable/chunks/Dq0DUAz1.js +0 -1
  217. package/build/client/_app/immutable/chunks/Dq0DUAz1.js.br +0 -0
  218. package/build/client/_app/immutable/chunks/Dq0DUAz1.js.gz +0 -0
  219. package/build/client/_app/immutable/chunks/iQu0D9Ux.js +0 -1
  220. package/build/client/_app/immutable/chunks/iQu0D9Ux.js.br +0 -0
  221. package/build/client/_app/immutable/chunks/iQu0D9Ux.js.gz +0 -0
  222. package/build/client/_app/immutable/entry/app.CLgh6Mx_.js +0 -2
  223. package/build/client/_app/immutable/entry/app.CLgh6Mx_.js.br +0 -0
  224. package/build/client/_app/immutable/entry/app.CLgh6Mx_.js.gz +0 -0
  225. package/build/client/_app/immutable/entry/start.D5GPCQZD.js +0 -1
  226. package/build/client/_app/immutable/entry/start.D5GPCQZD.js.br +0 -0
  227. package/build/client/_app/immutable/entry/start.D5GPCQZD.js.gz +0 -0
  228. package/build/client/_app/immutable/nodes/0.BOoI-hsu.js +0 -4
  229. package/build/client/_app/immutable/nodes/0.BOoI-hsu.js.br +0 -0
  230. package/build/client/_app/immutable/nodes/0.BOoI-hsu.js.gz +0 -0
  231. package/build/client/_app/immutable/nodes/1.KYdA6ppX.js.br +0 -2
  232. package/build/client/_app/immutable/nodes/1.KYdA6ppX.js.gz +0 -0
  233. package/build/client/_app/immutable/nodes/2.DBz20KgG.js.br +0 -0
  234. package/build/client/_app/immutable/nodes/2.DBz20KgG.js.gz +0 -0
  235. package/build/client/_app/immutable/nodes/3.19DIoFtw.js +0 -1
  236. package/build/client/_app/immutable/nodes/3.19DIoFtw.js.br +0 -0
  237. package/build/client/_app/immutable/nodes/3.19DIoFtw.js.gz +0 -0
  238. package/build/client/_app/immutable/nodes/4.BvMzqBJj.js.br +0 -0
  239. package/build/client/_app/immutable/nodes/4.BvMzqBJj.js.gz +0 -0
  240. package/build/client/_app/immutable/nodes/5.Dq6obSGG.js +0 -42
  241. package/build/client/_app/immutable/nodes/5.Dq6obSGG.js.br +0 -0
  242. package/build/client/_app/immutable/nodes/5.Dq6obSGG.js.gz +0 -0
  243. package/build/client/_app/immutable/nodes/6.BOHISqs-.js.br +0 -0
  244. package/build/client/_app/immutable/nodes/6.BOHISqs-.js.gz +0 -0
  245. package/build/client/_app/immutable/nodes/7.CemgNJfw.js.br +0 -0
  246. package/build/client/_app/immutable/nodes/7.CemgNJfw.js.gz +0 -0
  247. package/build/client/_app/immutable/nodes/8.DejSfIYh.js +0 -5
  248. package/build/client/_app/immutable/nodes/8.DejSfIYh.js.br +0 -0
  249. package/build/client/_app/immutable/nodes/8.DejSfIYh.js.gz +0 -0
  250. package/build/client/_app/immutable/nodes/9.CMW6a2Lg.js.br +0 -0
  251. package/build/client/_app/immutable/nodes/9.CMW6a2Lg.js.gz +0 -0
  252. package/build/server/chunks/1-DPpKAKXV.js +0 -9
  253. package/build/server/chunks/5-D7OCJpxD.js +0 -9
  254. package/build/server/chunks/5-D7OCJpxD.js.map +0 -1
  255. package/build/server/chunks/8-CVO-E-sf.js +0 -9
  256. package/build/server/chunks/8-CVO-E-sf.js.map +0 -1
  257. package/build/server/chunks/9-DxT1baO5.js +0 -9
  258. package/build/server/chunks/9-DxT1baO5.js.map +0 -1
  259. package/build/server/chunks/ProgressBar-CfhccQ83.js.map +0 -1
  260. package/build/server/chunks/_layout.svelte-BREws55o.js.map +0 -1
  261. package/build/server/chunks/_page.svelte-BqSC-1vK.js.map +0 -1
  262. package/build/server/chunks/_page.svelte-BtI2zZ_Z.js.map +0 -1
  263. package/build/server/chunks/_page.svelte-Zf9H4KOP.js.map +0 -1
  264. package/build/server/chunks/builderModeStore.svelte-ihupr-3p.js.map +0 -1
  265. package/build/server/chunks/client-DfpLcAZ9.js +0 -24
  266. package/build/server/chunks/hooks.server-Rv301GTB.js.map +0 -1
  267. package/build/server/chunks/reconcile-Dv7jS3C8.js.map +0 -1
  268. /package/build/client/_app/immutable/assets/{9.nv0I59TU.css → 11.nv0I59TU.css} +0 -0
  269. /package/build/client/_app/immutable/assets/{9.nv0I59TU.css.br → 11.nv0I59TU.css.br} +0 -0
  270. /package/build/client/_app/immutable/assets/{9.nv0I59TU.css.gz → 11.nv0I59TU.css.gz} +0 -0
@@ -99,6 +99,7 @@
99
99
  onRemove,
100
100
  onRename,
101
101
  typeOptions = [],
102
+ collapsible = false,
102
103
  class: className = ''
103
104
  }: {
104
105
  readonly tags: readonly Tag[];
@@ -108,9 +109,15 @@
108
109
  readonly onRemove?: (tag: Tag) => void | Promise<void>;
109
110
  readonly onRename?: (from: Tag, to: Tag) => void | Promise<void>;
110
111
  readonly typeOptions?: readonly string[];
112
+ // When true (the Builder cards), tags collapse to a compact tag-icon
113
+ // toggle by default and only reveal the chips + add/edit affordances on
114
+ // click — they were eating too much vertical space on every card.
115
+ readonly collapsible?: boolean;
111
116
  readonly class?: string;
112
117
  } = $props();
113
118
 
119
+ let expanded = $state(false);
120
+
114
121
  const datalistId = `buildertags-types-${++nextDatalistSerial}`;
115
122
 
116
123
  // Mix the palette color toward a deep slate for the dark builder plate (the
@@ -185,6 +192,23 @@
185
192
  {#if tags.length > 0 || onAdd}
186
193
  <div class={className} use:animateHeight={adding || editingKey !== null}>
187
194
  <div class="flex flex-wrap items-center gap-1.5">
195
+ {#if collapsible}
196
+ <button
197
+ type="button"
198
+ class="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium text-slate-400 transition hover:text-brand-300 {expanded ? 'bg-slate-800/60 text-slate-200' : ''}"
199
+ onclick={() => (expanded = !expanded)}
200
+ aria-expanded={expanded}
201
+ aria-label={expanded ? 'Hide tags' : 'Show tags'}
202
+ title={expanded ? 'Hide tags' : tags.length > 0 ? `Show ${tags.length} tag${tags.length === 1 ? '' : 's'}` : 'Add a tag'}
203
+ >
204
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
205
+ <path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.58 9.59a2 2 0 0 0 2.83 0l4.59-4.59a2 2 0 0 0 0-2.83z" />
206
+ <circle cx="7.5" cy="7.5" r="1.1" fill="currentColor" stroke="none" />
207
+ </svg>
208
+ {#if !expanded && tags.length > 0}<span>{tags.length}</span>{/if}
209
+ </button>
210
+ {/if}
211
+ {#if !collapsible || expanded}
188
212
  {#each tags as tag (tagKey(tag))}
189
213
  {#if editingKey === tagKey(tag)}
190
214
  <form class="inline-flex items-center gap-1" onsubmit={(e) => submitEdit(e, tag)}>
@@ -276,6 +300,7 @@
276
300
  >+</button>
277
301
  {/if}
278
302
  {/if}
303
+ {/if}
279
304
 
280
305
  {#if typeOptions.length > 0}
281
306
  <datalist id={datalistId}>
@@ -55,6 +55,25 @@ class BuilderModeStore {
55
55
 
56
56
  loading = $state(true);
57
57
  dashboard = $state<BuilderModeDashboard>({ projects: [] });
58
+ // True once a full read of every project has happened. A deep-linked open
59
+ // (`?project=<id>`) loads ONLY that project (see `loadProjectOnly`), leaving
60
+ // this false until the user returns to the all-projects view, which fills in
61
+ // the rest via `ensureFullyLoaded`. Avoids reading + scoring the whole hub
62
+ // just to render one deep-linked project.
63
+ fullyLoaded = $state(false);
64
+ // Total projects in the hub, tracked apart from `dashboard.projects` because
65
+ // a deep-linked open only loads one. Cheap to fetch (project docs, no feature
66
+ // scoring), so the "All projects (N)" label shows the true total even while
67
+ // only one project is loaded.
68
+ totalProjectCount = $state(0);
69
+ // The count to show: the true total once known, else however many are loaded.
70
+ projectCount = $derived(Math.max(this.totalProjectCount, this.dashboard.projects.length));
71
+ // How many project cards are still streaming in — drives placeholder
72
+ // skeletons in the all-projects grid while the progressive load runs. Zero
73
+ // once fully loaded.
74
+ pendingProjectCount = $derived(
75
+ this.fullyLoaded ? 0 : Math.max(0, this.totalProjectCount - this.dashboard.projects.length)
76
+ );
58
77
  // Explicit selection model: `null` means "no filter" (show the whole list,
59
78
  // detail pane prompts the user to pick). Selecting a project/core feature
60
79
  // filters to it; the UI floats it to the top of its list and pins it sticky.
@@ -311,6 +330,35 @@ class BuilderModeStore {
311
330
  return byRelevance(coreFeature.features, (feature) => this.featureScore(feature, q));
312
331
  });
313
332
 
333
+ /**
334
+ * The selected core feature's features grouped by their surface, in surface
335
+ * (declaration) order. A surface is the higher-level grouping a feature lives
336
+ * on (Feature › Surface › action-level feature); the Builder lists features
337
+ * under a surface heading rather than one flat list. Honors the same search
338
+ * filter as `visibleSelectedFeatures`; empty surfaces are dropped.
339
+ */
340
+ visibleSelectedSurfaces = $derived.by<
341
+ readonly { surfaceId: string; surfaceName: string; features: readonly BuilderModeFeature[] }[]
342
+ >(() => {
343
+ const coreFeature = this.selectedCoreFeature;
344
+ if (!coreFeature) return [];
345
+ const q = this.searchQuery;
346
+ const showAll = !q || this.matchesText(q, coreFeature.name, coreFeature.description);
347
+ const groups: { surfaceId: string; surfaceName: string; features: BuilderModeFeature[] }[] = [];
348
+ const byId = new Map<string, (typeof groups)[number]>();
349
+ for (const feature of coreFeature.features) {
350
+ if (!showAll && this.featureScore(feature, q) === 0) continue;
351
+ let group = byId.get(feature.surfaceId);
352
+ if (!group) {
353
+ group = { surfaceId: feature.surfaceId, surfaceName: feature.surfaceName, features: [] };
354
+ byId.set(feature.surfaceId, group);
355
+ groups.push(group);
356
+ }
357
+ group.features.push(feature);
358
+ }
359
+ return groups;
360
+ });
361
+
314
362
  setSearch(query: string): void {
315
363
  this.search = query;
316
364
  }
@@ -368,10 +416,60 @@ class BuilderModeStore {
368
416
  );
369
417
  }
370
418
 
419
+ /**
420
+ * Load the full project list PROGRESSIVELY: read the cheap project list
421
+ * first, then load + score each project independently and stream it into the
422
+ * dashboard as it resolves — so cards appear one-by-one instead of the whole
423
+ * grid blocking on the slowest project. Cards are ordered by the project
424
+ * list regardless of which finishes first. The skeleton shows until the
425
+ * first card is ready (so the empty-state never flashes mid-load).
426
+ */
371
427
  async refresh(): Promise<void> {
372
428
  this.loading = true;
373
- await this.refreshSilent();
374
- this.loading = false;
429
+ this.error = null;
430
+ try {
431
+ const container = await this.host.data.getContainer();
432
+ const summaries = await container.useCases.listProjects();
433
+ this.totalProjectCount = summaries.length;
434
+ if (summaries.length === 0) {
435
+ this.dashboard = { projects: [] };
436
+ this.fullyLoaded = true;
437
+ this.loading = false;
438
+ return;
439
+ }
440
+ const order = new Map(summaries.map((summary, index) => [String(summary.id), index]));
441
+ const loadProject = getBuilderModeProjectUseCase({
442
+ projects: container.projectRepository,
443
+ features: container.repository,
444
+ statuses: container.statusRepository
445
+ });
446
+ const loaded: BuilderModeProject[] = [];
447
+ await Promise.all(
448
+ summaries.map(async (summary) => {
449
+ const project = await loadProject(asProjectId(String(summary.id)));
450
+ if (!project) return;
451
+ loaded.push(project);
452
+ // Keep the visible order stable (project-list order), independent of
453
+ // which load finishes first.
454
+ loaded.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
455
+ this.dashboard = { projects: [...loaded] };
456
+ this.loading = false; // reveal the grid as soon as one card is ready
457
+ })
458
+ );
459
+ this.fullyLoaded = true;
460
+ this.loading = false;
461
+ if (
462
+ this.selectedProjectId &&
463
+ !this.dashboard.projects.some((project) => project.id === this.selectedProjectId)
464
+ ) {
465
+ this.selectedProjectId = null;
466
+ this.selectedCoreFeatureId = null;
467
+ }
468
+ await this.refreshQueue();
469
+ } catch (e) {
470
+ this.error = (e as Error).message;
471
+ this.loading = false;
472
+ }
375
473
  }
376
474
 
377
475
  async refreshSilent(): Promise<void> {
@@ -383,6 +481,8 @@ class BuilderModeStore {
383
481
  statuses: container.statusRepository
384
482
  });
385
483
  this.dashboard = await loadDashboard();
484
+ this.fullyLoaded = true;
485
+ this.totalProjectCount = this.dashboard.projects.length;
386
486
  if (
387
487
  this.selectedProjectId &&
388
488
  !this.dashboard.projects.some((project) => project.id === this.selectedProjectId)
@@ -397,17 +497,74 @@ class BuilderModeStore {
397
497
  }
398
498
  }
399
499
 
500
+ /**
501
+ * Load ONLY the given project (its features, scores, queue) into the
502
+ * dashboard — used on a deep-linked open so we don't read and score every
503
+ * project in the hub just to show one. The rest of the list stays unloaded
504
+ * (`fullyLoaded=false`) until `ensureFullyLoaded` runs. Falls back to a full
505
+ * read when the id can't be resolved alone, so an unknown/stale link still
506
+ * lands the user on a usable list.
507
+ */
508
+ async loadProjectOnly(projectId: string): Promise<void> {
509
+ this.loading = true;
510
+ // Fetch the real total in parallel so "All projects (N)" is right.
511
+ void this.refreshProjectCount();
512
+ try {
513
+ const container = await this.host.data.getContainer();
514
+ const loadProject = getBuilderModeProjectUseCase({
515
+ projects: container.projectRepository,
516
+ features: container.repository,
517
+ statuses: container.statusRepository
518
+ });
519
+ const project = await loadProject(asProjectId(projectId));
520
+ if (!project) {
521
+ await this.refreshSilent();
522
+ } else {
523
+ this.dashboard = { projects: [project] };
524
+ this.fullyLoaded = false;
525
+ this.error = null;
526
+ await this.refreshQueue();
527
+ }
528
+ } catch (e) {
529
+ this.error = (e as Error).message;
530
+ } finally {
531
+ this.loading = false;
532
+ }
533
+ }
534
+
535
+ /** Ensure the whole project list is loaded (for the all-projects view). A
536
+ * no-op once any full read has happened, so returning from a deep-linked
537
+ * project triggers exactly one full load. */
538
+ async ensureFullyLoaded(): Promise<void> {
539
+ if (this.fullyLoaded) return;
540
+ await this.refresh();
541
+ }
542
+
543
+ /** Fetch the true total project count (cheap — project docs only, no feature
544
+ * scoring) so the "All projects (N)" label is correct even when a single
545
+ * project was deep-loaded. Decorative: failures are ignored. */
546
+ async refreshProjectCount(): Promise<void> {
547
+ try {
548
+ const container = await this.host.data.getContainer();
549
+ this.totalProjectCount = (await container.useCases.listProjects()).length;
550
+ } catch {
551
+ // Count is decorative; leave the previous value.
552
+ }
553
+ }
554
+
400
555
  selectProject(id: string): void {
401
556
  this.selectedProjectId = id;
402
557
  this.selectedCoreFeatureId = null;
403
558
  void this.refreshQueue();
404
559
  }
405
560
 
406
- /** Cancel the project filter. */
561
+ /** Cancel the project filter. Returning to the all-projects view fills the
562
+ * list if a deep-linked open only loaded one project. */
407
563
  deselectProject(): void {
408
564
  this.selectedProjectId = null;
409
565
  this.selectedCoreFeatureId = null;
410
566
  this.queue = [];
567
+ void this.ensureFullyLoaded();
411
568
  }
412
569
 
413
570
  /** Toggle a core feature filter (click the selected one again to clear). */
@@ -415,6 +572,27 @@ class BuilderModeStore {
415
572
  this.selectedCoreFeatureId = this.selectedCoreFeatureId === id ? null : id;
416
573
  }
417
574
 
575
+ /**
576
+ * Set the full project + core-feature selection in ONE synchronous pass.
577
+ * Used when restoring from the URL (deep-link / refresh-persist, and the
578
+ * activity-toast "View" links that target the Builder while it's already
579
+ * open). Doing both at once avoids the project change transiently clearing
580
+ * the core and the URL writer racing to drop the `core` param before it's
581
+ * applied. Idempotent and order-independent for the URL ↔ store sync.
582
+ */
583
+ applySelection(projectId: string | null, coreFeatureId: string | null): void {
584
+ if (this.selectedProjectId !== projectId) {
585
+ this.selectedProjectId = projectId;
586
+ this.selectedCoreFeatureId = null;
587
+ this.queue = [];
588
+ void this.refreshQueue();
589
+ // Clearing the project (back to the all-projects view) needs the full
590
+ // list if we only deep-loaded one project.
591
+ if (!projectId) void this.ensureFullyLoaded();
592
+ }
593
+ this.selectedCoreFeatureId = coreFeatureId;
594
+ }
595
+
418
596
  deselectCoreFeature(): void {
419
597
  this.selectedCoreFeatureId = null;
420
598
  }
@@ -651,6 +829,72 @@ class BuilderModeStore {
651
829
  if (this.selectedProjectId) await this.refreshProject(this.selectedProjectId);
652
830
  }
653
831
 
832
+ private hasProject(projectId: string): boolean {
833
+ return this.dashboard.projects.some((project) => project.id === projectId);
834
+ }
835
+
836
+ /** The project whose core features include `featureId`, or null. A model
837
+ * Feature maps to one Builder "core feature", so a feature/status event
838
+ * touches exactly one project's card. */
839
+ private projectIdOwningFeature(featureId: string): string | null {
840
+ for (const project of this.dashboard.projects) {
841
+ if (project.coreFeatures.some((coreFeature) => coreFeature.id === featureId)) {
842
+ return project.id;
843
+ }
844
+ }
845
+ return null;
846
+ }
847
+
848
+ /**
849
+ * Apply one out-of-band sync event (MCP write) with the SMALLEST refresh
850
+ * that keeps the view correct — the Builder's mirror of the Expert view's
851
+ * refetchOne/syncWith. A `project` event rebuilds just that project's card
852
+ * in place; a `feature`/`implementation-status` event rebuilds only the
853
+ * owning project. Both reuse the keyed lists, so scroll, selection, queue
854
+ * widget, and expanded descriptions survive instead of the whole dashboard
855
+ * blanking and re-running its entrance animations. We fall back to a full
856
+ * silent refresh ONLY when the event names something we don't have yet (a
857
+ * brand-new project, or a feature not attached to any loaded project) —
858
+ * that's the one case a scoped rebuild can't surface.
859
+ */
860
+ async applySyncEvent(event: SyncEvent): Promise<void> {
861
+ if (event.kind === 'project') {
862
+ if (this.hasProject(event.id)) {
863
+ await this.refreshProject(event.id);
864
+ // Queue lives on the project; an enqueue/reorder/target write rides
865
+ // the same 'project' event, so refresh it when the open project moved.
866
+ if (event.id === this.selectedProjectId) await this.refreshQueue();
867
+ } else {
868
+ await this.refreshSilent();
869
+ }
870
+ return;
871
+ }
872
+ // 'feature' and 'implementation-status' both carry a feature id.
873
+ const projectId = this.projectIdOwningFeature(event.id);
874
+ if (projectId) {
875
+ await this.refreshProject(projectId);
876
+ if (projectId === this.selectedProjectId) await this.refreshQueue();
877
+ } else {
878
+ await this.refreshSilent();
879
+ }
880
+ }
881
+
882
+ /**
883
+ * A Builder deep-link for an entity touched by a sync event, or null when it
884
+ * isn't shown in the Builder (so the activity-toast falls back to the Expert
885
+ * route). A `project` event targets the project card; `feature` /
886
+ * `implementation-status` (both carry a feature id) target that feature's
887
+ * core card via `?project=&core=`.
888
+ */
889
+ deepLinkFor(kind: SyncEvent['kind'], id: string): string | null {
890
+ if (kind === 'project') {
891
+ return this.hasProject(id) ? `/builder-mode?project=${encodeURIComponent(id)}` : null;
892
+ }
893
+ const projectId = this.projectIdOwningFeature(id);
894
+ if (!projectId) return null;
895
+ return `/builder-mode?project=${encodeURIComponent(projectId)}&core=${encodeURIComponent(id)}`;
896
+ }
897
+
654
898
  /** Drag-and-drop reorder: drop `itemId` at absolute position `targetIndex`. */
655
899
  async moveQueueTo(itemId: QueueItemId, targetIndex: number): Promise<void> {
656
900
  const projectId = this.selectedProjectId;
@@ -173,6 +173,13 @@
173
173
  <span aria-hidden="true">←</span>
174
174
  Back to projects
175
175
  </a>
176
+ <a
177
+ href={`/projects/${project.id}/graph`}
178
+ class="inline-flex items-center gap-1.5 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-brand-300 hover:bg-cyan-50 hover:text-brand-800"
179
+ title="Open the whole project behavior graph"
180
+ >
181
+ Graph
182
+ </a>
176
183
  <button
177
184
  type="button"
178
185
  class="inline-flex items-center gap-1.5 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-brand-300 hover:bg-cyan-50 hover:text-brand-800 disabled:opacity-50"
@@ -158,13 +158,27 @@ const evaluateAssertions = (
158
158
  ...(a.description ? { description: a.description } : {})
159
159
  };
160
160
  }
161
+ // Guard a malformed assertion that carries no state path (untyped scenario
162
+ // data can deserialize with `path: undefined`). Evaluating it would call
163
+ // `readPath(undefined)` → `undefined.split('.')` and crash the WHOLE
164
+ // feature's run. Report it as not-held with a clear path string instead, so
165
+ // one bad assertion fails just its own scenario rather than the run.
166
+ const rawPath: unknown = a.path;
167
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
168
+ return {
169
+ path: String(a.path),
170
+ operator: a.operator,
171
+ held: false,
172
+ ...(a.description ? { description: a.description } : {})
173
+ };
174
+ }
161
175
  const held = evaluateCondition(
162
176
  { left: a.path, operator: a.operator, right: a.value },
163
177
  snapshot,
164
178
  params
165
179
  );
166
180
  return {
167
- path: String(a.path),
181
+ path: rawPath,
168
182
  operator: a.operator,
169
183
  held,
170
184
  ...(a.description ? { description: a.description } : {})
@@ -1,10 +1,12 @@
1
1
  import type { Handle } from '@sveltejs/kit';
2
+ import { env } from '$env/dynamic/public';
2
3
  import {
3
4
  checkOrigin,
4
5
  checkRequestAuth,
5
6
  isAuthEnabled,
6
7
  isOriginCheckEnabled
7
8
  } from '$lib/server/security/auth';
9
+ import { parseThemeId } from '$lib/theme/registry';
8
10
 
9
11
  /**
10
12
  * Global request gate for the dashboard. When `UNSPA_AUTH_TOKEN` is
@@ -35,5 +37,13 @@ export const handle: Handle = async ({ event, resolve }) => {
35
37
  });
36
38
  }
37
39
  }
38
- return resolve(event);
40
+ // Seed the initial colour theme into the server-rendered <html data-theme>
41
+ // so the CLI default (PUBLIC_UNSPA_THEME) paints with no flash. The inline
42
+ // head script in app.html may still override this from localStorage when the
43
+ // user picked a theme via the in-app switcher. Inert for HTML responses that
44
+ // don't carry the placeholder (e.g. the /api JSON above already returned).
45
+ const theme = parseThemeId(env.PUBLIC_UNSPA_THEME);
46
+ return resolve(event, {
47
+ transformPageChunk: ({ html }) => html.replace('%unspa.theme%', theme)
48
+ });
39
49
  };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Dashboard theme registry.
3
+ *
4
+ * A "theme" is a purely cosmetic skin over the design system — it swaps the
5
+ * `@theme` color-token *values* (the brand ramp, the canvas, the hairline) and
6
+ * a little chrome (the header/shell background). It never adds, removes, or
7
+ * moves a feature: every surface, action, and control is identical between
8
+ * themes; only the colours change.
9
+ *
10
+ * Default is the always-present base look (the teal/cyan brand). Every other
11
+ * theme is opt-in, chosen at runtime by `PUBLIC_UNSPA_THEME` (the CLI default,
12
+ * via `unspa theme set` / `unspa dashboard --theme`) and overridable live in
13
+ * the browser by the header switcher (persisted in localStorage).
14
+ *
15
+ * This module is pure (no `$env`, no Svelte, no DOM) so it's unit-testable; the
16
+ * runtime wiring lives in `themeStore.svelte.ts` (browser) and `hooks.server.ts`
17
+ * (initial server-rendered attribute).
18
+ */
19
+ export type DashboardThemeId = 'default' | 'lyriks';
20
+
21
+ export interface DashboardTheme {
22
+ readonly id: DashboardThemeId;
23
+ readonly label: string;
24
+ readonly description: string;
25
+ /** A representative CSS `background` value, shown as a swatch in the switcher. */
26
+ readonly swatch: string;
27
+ }
28
+
29
+ /** The base theme. Always available; the look you get with no theme set. */
30
+ const DEFAULT: DashboardTheme = {
31
+ id: 'default',
32
+ label: 'Default',
33
+ description: 'The standard Unspaghettit look — teal/cyan brand on a soft canvas.',
34
+ swatch: 'linear-gradient(135deg,#22d3ee,#06b6d4,#0e7490)'
35
+ };
36
+
37
+ /** Opt-in themes, keyed by the id used in `PUBLIC_UNSPA_THEME`. */
38
+ const OPTIONAL_THEMES: Readonly<Record<string, DashboardTheme>> = {
39
+ lyriks: {
40
+ id: 'lyriks',
41
+ label: 'Lyriks',
42
+ description: 'Lyriks.io brand skin — a violet→fuchsia gradient header over a cool canvas.',
43
+ swatch: 'linear-gradient(90deg,#6d28d9,#a21caf,#db2777)'
44
+ }
45
+ };
46
+
47
+ /** Default first, then the opt-in themes, in registration order. */
48
+ export const ALL_THEMES: readonly DashboardTheme[] = [DEFAULT, ...Object.values(OPTIONAL_THEMES)];
49
+
50
+ /**
51
+ * Parse a raw `PUBLIC_UNSPA_THEME` (or localStorage / CLI) value into a known
52
+ * theme id. Trims + lowercases; anything unrecognised (including the literal
53
+ * "default" and the unreplaced `%unspa.theme%` placeholder) falls back to the
54
+ * default theme, so a bad value never blanks the UI.
55
+ */
56
+ export const parseThemeId = (raw: string | undefined): DashboardThemeId => {
57
+ const id = (raw ?? '').trim().toLowerCase();
58
+ return id in OPTIONAL_THEMES ? (id as DashboardThemeId) : 'default';
59
+ };
60
+
61
+ /** Resolve a raw value into its full theme definition. */
62
+ export const resolveTheme = (raw: string | undefined): DashboardTheme => {
63
+ const id = parseThemeId(raw);
64
+ return id === 'default' ? DEFAULT : OPTIONAL_THEMES[id]!;
65
+ };
66
+
67
+ /** The opt-in themes (everything except the always-on default base). */
68
+ export const optionalThemes = (): readonly DashboardTheme[] => Object.values(OPTIONAL_THEMES);
69
+
70
+ /**
71
+ * Whether `id` names a known theme — including the always-valid "default", so
72
+ * the CLI accepts `unspa theme set default` as an explicit revert.
73
+ */
74
+ export const isThemeId = (id: string): boolean => {
75
+ const normalized = id.trim().toLowerCase();
76
+ return normalized === 'default' || Object.prototype.hasOwnProperty.call(OPTIONAL_THEMES, normalized);
77
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Active dashboard theme. Purely cosmetic — it only decides which colour skin
3
+ * the design system renders with (see `registry.ts`); no feature ever depends
4
+ * on it.
5
+ *
6
+ * Resolution order, highest priority first:
7
+ * 1. The browser choice made via the header switcher (localStorage).
8
+ * 2. The CLI default (`PUBLIC_UNSPA_THEME`, set by `unspa theme set` /
9
+ * `unspa dashboard --theme`).
10
+ * 3. The default theme.
11
+ *
12
+ * The initial value is seeded from `PUBLIC_UNSPA_THEME` so server-side
13
+ * rendering paints the CLI-default chrome with no flash. On the client,
14
+ * `init()` re-reads the `data-theme` attribute on <html> — which the inline
15
+ * head script in app.html has already overridden from localStorage when the
16
+ * user has a saved choice — and mirrors it into reactive state so the chrome in
17
+ * the layout (header gradient, canvas) tracks it. `setTheme` updates the
18
+ * attribute (re-skinning every token-driven colour) and persists to
19
+ * localStorage, all live, no reload.
20
+ *
21
+ * Browser-only side effects are guarded so the store is SSR-safe.
22
+ */
23
+ import { env } from '$env/dynamic/public';
24
+ import { parseThemeId, type DashboardThemeId } from './registry';
25
+
26
+ const STORAGE_KEY = 'unspa.theme';
27
+
28
+ class ThemeStore {
29
+ /** The currently applied theme id. */
30
+ current = $state<DashboardThemeId>(parseThemeId(env.PUBLIC_UNSPA_THEME));
31
+ private initialized = false;
32
+
33
+ /**
34
+ * Hydrate from the `data-theme` attribute already on <html> (server default,
35
+ * possibly overridden by the inline head script from localStorage). Idempotent.
36
+ */
37
+ init(): void {
38
+ if (this.initialized) return;
39
+ this.initialized = true;
40
+ if (typeof document === 'undefined') return;
41
+ this.current = parseThemeId(document.documentElement.dataset.theme);
42
+ }
43
+
44
+ /** Apply a theme live: reactive state + the <html> attribute + localStorage. */
45
+ setTheme(next: DashboardThemeId): void {
46
+ const id = parseThemeId(next);
47
+ this.current = id;
48
+ if (typeof document !== 'undefined') {
49
+ document.documentElement.dataset.theme = id;
50
+ }
51
+ if (typeof localStorage === 'undefined') return;
52
+ // Only persist a non-default choice; clearing the key lets the CLI default
53
+ // (PUBLIC_UNSPA_THEME) win again on the next load.
54
+ if (id === 'default') localStorage.removeItem(STORAGE_KEY);
55
+ else localStorage.setItem(STORAGE_KEY, id);
56
+ }
57
+
58
+ /** Convenience flag for the layout's chrome conditionals. */
59
+ get isLyriks(): boolean {
60
+ return this.current === 'lyriks';
61
+ }
62
+ }
63
+
64
+ export const themeStore = new ThemeStore();