@mhmo91/schmancy 0.10.19 → 0.10.20
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.
- package/custom-elements.json +0 -13
- package/dist/agent/{overlay.confirm-body-D3jQyXgA.js → overlay.confirm-body-mYm0zq4k.js} +1 -1
- package/dist/agent/{overlay.confirm-body-D3jQyXgA.js.map → overlay.confirm-body-mYm0zq4k.js.map} +1 -1
- package/dist/agent/schmancy.agent.js +808 -882
- package/dist/agent/schmancy.agent.js.map +1 -1
- package/dist/agent/schmancy.manifest.json +1 -9
- package/dist/area-C7MNn-3e.cjs +12 -0
- package/dist/area-C7MNn-3e.cjs.map +1 -0
- package/dist/{area-BIipuSyO.js → area-CRe41aIG.js} +91 -130
- package/dist/area-CRe41aIG.js.map +1 -0
- package/dist/area.cjs +1 -1
- package/dist/area.js +1 -1
- package/dist/{autocomplete-Mrb3koUN.js → autocomplete-CRDFL4Ul.js} +2 -2
- package/dist/{autocomplete-Mrb3koUN.js.map → autocomplete-CRDFL4Ul.js.map} +1 -1
- package/dist/{autocomplete-B8CE5vGw.cjs → autocomplete-CqUl7o0e.cjs} +1 -1
- package/dist/{autocomplete-B8CE5vGw.cjs.map → autocomplete-CqUl7o0e.cjs.map} +1 -1
- package/dist/autocomplete.cjs +1 -1
- package/dist/autocomplete.js +1 -1
- package/dist/avatar.cjs +1 -1
- package/dist/avatar.js +1 -1
- package/dist/badge.cjs +1 -1
- package/dist/badge.js +1 -1
- package/dist/{boat-OatK_MGh.cjs → boat-BHV5kOlN.cjs} +1 -1
- package/dist/{boat-OatK_MGh.cjs.map → boat-BHV5kOlN.cjs.map} +1 -1
- package/dist/{boat-CNWIQPA1.js → boat-XajM8A3M.js} +1 -1
- package/dist/{boat-CNWIQPA1.js.map → boat-XajM8A3M.js.map} +1 -1
- package/dist/boat.cjs +1 -1
- package/dist/boat.js +1 -1
- package/dist/breadcrumb.cjs +1 -1
- package/dist/breadcrumb.js +1 -1
- package/dist/{busy-CMKX4oQf.cjs → busy-BlBZ5ZOs.cjs} +1 -1
- package/dist/{busy-CMKX4oQf.cjs.map → busy-BlBZ5ZOs.cjs.map} +1 -1
- package/dist/{busy-Cetzws-m.js → busy-D8YsqVBf.js} +1 -1
- package/dist/{busy-Cetzws-m.js.map → busy-D8YsqVBf.js.map} +1 -1
- package/dist/busy.cjs +1 -1
- package/dist/busy.js +1 -1
- package/dist/button.cjs +15 -9
- package/dist/button.cjs.map +1 -1
- package/dist/button.js +15 -9
- package/dist/button.js.map +1 -1
- package/dist/{card-D2k3dRL0.js → card-C9TljY2Z.js} +1 -1
- package/dist/{card-D2k3dRL0.js.map → card-C9TljY2Z.js.map} +1 -1
- package/dist/{card-8VXoo2C_.cjs → card-yT_St83D.cjs} +1 -1
- package/dist/{card-8VXoo2C_.cjs.map → card-yT_St83D.cjs.map} +1 -1
- package/dist/card.cjs +1 -1
- package/dist/card.js +1 -1
- package/dist/{checkbox-8hNsBejz.js → checkbox-BDgh4rge.js} +1 -1
- package/dist/{checkbox-8hNsBejz.js.map → checkbox-BDgh4rge.js.map} +1 -1
- package/dist/{checkbox-Cq5wzeaY.cjs → checkbox-Dz2lkJs0.cjs} +1 -1
- package/dist/{checkbox-Cq5wzeaY.cjs.map → checkbox-Dz2lkJs0.cjs.map} +1 -1
- package/dist/checkbox.cjs +1 -1
- package/dist/checkbox.js +1 -1
- package/dist/{chips-Dx_WvOGk.cjs → chips-M7Dr2npv.cjs} +2 -4
- package/dist/chips-M7Dr2npv.cjs.map +1 -0
- package/dist/{chips-D1kJrbzo.js → chips-N7fu0hA4.js} +3 -5
- package/dist/chips-N7fu0hA4.js.map +1 -0
- package/dist/chips.cjs +1 -1
- package/dist/chips.js +2 -2
- package/dist/connectivity.cjs +1 -1
- package/dist/connectivity.js +1 -1
- package/dist/content-drawer.cjs +1 -1
- package/dist/content-drawer.js +1 -1
- package/dist/{date-range-H903Vt_r.cjs → date-range-D2vxD814.cjs} +1 -1
- package/dist/{date-range-H903Vt_r.cjs.map → date-range-D2vxD814.cjs.map} +1 -1
- package/dist/{date-range-Dv-DM6mB.js → date-range-DFWOMgI3.js} +2 -2
- package/dist/{date-range-Dv-DM6mB.js.map → date-range-DFWOMgI3.js.map} +1 -1
- package/dist/{date-range-inline-Bvs2ZvEY.cjs → date-range-inline-C5JuZ_Kw.cjs} +1 -1
- package/dist/{date-range-inline-Bvs2ZvEY.cjs.map → date-range-inline-C5JuZ_Kw.cjs.map} +1 -1
- package/dist/{date-range-inline-TWWnTZlw.js → date-range-inline-D3q1OoKk.js} +1 -1
- package/dist/{date-range-inline-TWWnTZlw.js.map → date-range-inline-D3q1OoKk.js.map} +1 -1
- package/dist/date-range-inline.cjs +1 -1
- package/dist/date-range-inline.js +1 -1
- package/dist/date-range.cjs +1 -1
- package/dist/date-range.js +1 -1
- package/dist/delay.cjs +1 -1
- package/dist/delay.js +1 -1
- package/dist/{details-Cpg8sH2F.js → details-BrUPmd92.js} +2 -2
- package/dist/details-BrUPmd92.js.map +1 -0
- package/dist/{details-CwSDur6j.cjs → details-DmDEInaL.cjs} +2 -2
- package/dist/details-DmDEInaL.cjs.map +1 -0
- package/dist/details.cjs +1 -1
- package/dist/details.js +1 -1
- package/dist/{divider-Be833gGZ.js → divider-BLijs8ba.js} +1 -1
- package/dist/{divider-Be833gGZ.js.map → divider-BLijs8ba.js.map} +1 -1
- package/dist/{divider-BNdVLE0H.cjs → divider-B_Ts_-qz.cjs} +1 -1
- package/dist/{divider-BNdVLE0H.cjs.map → divider-B_Ts_-qz.cjs.map} +1 -1
- package/dist/divider.cjs +1 -1
- package/dist/divider.js +1 -1
- package/dist/dropdown.cjs +1 -1
- package/dist/dropdown.js +1 -1
- package/dist/{expand-CtoffNNj.js → expand-C-xSpg7M.js} +2 -2
- package/dist/{expand-CtoffNNj.js.map → expand-C-xSpg7M.js.map} +1 -1
- package/dist/{expand-BP6RLzHw.cjs → expand-DV5sWUB6.cjs} +1 -1
- package/dist/{expand-BP6RLzHw.cjs.map → expand-DV5sWUB6.cjs.map} +1 -1
- package/dist/expand.cjs +1 -1
- package/dist/expand.js +1 -1
- package/dist/{float-CfbQM_2v.cjs → float-LyKef0LY.cjs} +1 -1
- package/dist/{float-CfbQM_2v.cjs.map → float-LyKef0LY.cjs.map} +1 -1
- package/dist/{float-KmbhaQHA.js → float-Y22yVBE2.js} +1 -1
- package/dist/{float-KmbhaQHA.js.map → float-Y22yVBE2.js.map} +1 -1
- package/dist/float.cjs +1 -1
- package/dist/float.js +1 -1
- package/dist/{form-CuBIrKOA.cjs → form-C_smXI2-.cjs} +1 -1
- package/dist/{form-CuBIrKOA.cjs.map → form-C_smXI2-.cjs.map} +1 -1
- package/dist/{form-8IcmP8uV.js → form-LFkEQkOX.js} +8 -8
- package/dist/{form-8IcmP8uV.js.map → form-LFkEQkOX.js.map} +1 -1
- package/dist/form.cjs +1 -1
- package/dist/form.js +6 -6
- package/dist/handover/agent-runtime-followups.md +1 -1
- package/dist/handover/agent-runtime-v1.md +3 -3
- package/dist/{icons-D7df1ysG.js → icons-B3pFrwKC.js} +1 -1
- package/dist/{icons-D7df1ysG.js.map → icons-B3pFrwKC.js.map} +1 -1
- package/dist/{icons-BJld4JHp.cjs → icons-CCNy4Egc.cjs} +1 -1
- package/dist/{icons-BJld4JHp.cjs.map → icons-CCNy4Egc.cjs.map} +1 -1
- package/dist/icons.cjs +1 -1
- package/dist/icons.js +1 -1
- package/dist/{iframe-DAbgW9tT.js → iframe-BbFlCEyP.js} +1 -1
- package/dist/{iframe-DAbgW9tT.js.map → iframe-BbFlCEyP.js.map} +1 -1
- package/dist/{iframe-GT6D8l5Z.cjs → iframe-CCcmLZ_K.cjs} +1 -1
- package/dist/{iframe-GT6D8l5Z.cjs.map → iframe-CCcmLZ_K.cjs.map} +1 -1
- package/dist/iframe.cjs +1 -1
- package/dist/iframe.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +27 -27
- package/dist/{input-DC6ap_uN.js → input-Dkneo4uA.js} +2 -2
- package/dist/{input-DC6ap_uN.js.map → input-Dkneo4uA.js.map} +1 -1
- package/dist/{input-chip-c5n547tg.js → input-chip-C1-TYu4v.js} +1 -1
- package/dist/{input-chip-c5n547tg.js.map → input-chip-C1-TYu4v.js.map} +1 -1
- package/dist/{input-chip-MsiMu-b5.cjs → input-chip-F5NEkkBU.cjs} +1 -1
- package/dist/{input-chip-MsiMu-b5.cjs.map → input-chip-F5NEkkBU.cjs.map} +1 -1
- package/dist/{input-BE9wEEw4.cjs → input-sBZ89wz1.cjs} +1 -1
- package/dist/{input-BE9wEEw4.cjs.map → input-sBZ89wz1.cjs.map} +1 -1
- package/dist/input.cjs +1 -1
- package/dist/input.js +1 -1
- package/dist/json.cjs +1 -1
- package/dist/json.js +2 -2
- package/dist/kbd.cjs +1 -1
- package/dist/kbd.js +1 -1
- package/dist/layout.cjs +1 -1
- package/dist/layout.js +1 -1
- package/dist/{lightbox-HqJBBjAT.cjs → lightbox-B4m5lxGs.cjs} +1 -1
- package/dist/{lightbox-HqJBBjAT.cjs.map → lightbox-B4m5lxGs.cjs.map} +1 -1
- package/dist/{lightbox-CNX9Eg3U.js → lightbox-D7hYFspE.js} +1 -1
- package/dist/{lightbox-CNX9Eg3U.js.map → lightbox-D7hYFspE.js.map} +1 -1
- package/dist/lightbox.cjs +1 -1
- package/dist/lightbox.js +1 -1
- package/dist/{list-bhyuQSyO.cjs → list-C2ycz-yr.cjs} +1 -1
- package/dist/{list-bhyuQSyO.cjs.map → list-C2ycz-yr.cjs.map} +1 -1
- package/dist/{list-C76Pb-c1.js → list-Ou72tSeq.js} +1 -1
- package/dist/{list-C76Pb-c1.js.map → list-Ou72tSeq.js.map} +1 -1
- package/dist/list.cjs +1 -1
- package/dist/list.js +1 -1
- package/dist/{menu-BqKQ-s0C.cjs → menu-ComSx-T0.cjs} +1 -1
- package/dist/{menu-BqKQ-s0C.cjs.map → menu-ComSx-T0.cjs.map} +1 -1
- package/dist/{menu-C5ksITpG.js → menu-YHbpRa7x.js} +2 -2
- package/dist/{menu-C5ksITpG.js.map → menu-YHbpRa7x.js.map} +1 -1
- package/dist/menu.cjs +1 -1
- package/dist/menu.js +1 -1
- package/dist/mixins-B9kY_60p.js +636 -0
- package/dist/{mixins-DCVXqL1Q.js.map → mixins-B9kY_60p.js.map} +1 -1
- package/dist/mixins-BwGJwK7X.cjs +254 -0
- package/dist/{mixins-Du9HMrIG.cjs.map → mixins-BwGJwK7X.cjs.map} +1 -1
- package/dist/mixins.cjs +1 -1
- package/dist/mixins.js +1 -1
- package/dist/nav-drawer.cjs +1 -1
- package/dist/nav-drawer.js +1 -1
- package/dist/navigation-bar.cjs +1 -1
- package/dist/navigation-bar.js +1 -1
- package/dist/navigation-rail.cjs +1 -1
- package/dist/navigation-rail.js +1 -1
- package/dist/{notification-DR3gvWt8.cjs → notification-DZhL0ZEg.cjs} +1 -1
- package/dist/{notification-DR3gvWt8.cjs.map → notification-DZhL0ZEg.cjs.map} +1 -1
- package/dist/{notification-eZxtr3NN.js → notification-O4Q5pyio.js} +2 -2
- package/dist/{notification-eZxtr3NN.js.map → notification-O4Q5pyio.js.map} +1 -1
- package/dist/notification.cjs +1 -1
- package/dist/notification.js +1 -1
- package/dist/{option-CBEHYG4U.js → option-BCks0a4i.js} +1 -1
- package/dist/{option-CBEHYG4U.js.map → option-BCks0a4i.js.map} +1 -1
- package/dist/{option-BDOKUqTy.cjs → option-C2VKw8Yt.cjs} +1 -1
- package/dist/{option-BDOKUqTy.cjs.map → option-C2VKw8Yt.cjs.map} +1 -1
- package/dist/option.cjs +1 -1
- package/dist/option.js +1 -1
- package/dist/{overlay-oxM9OLXP.js → overlay-C0YSnxoV.js} +8 -10
- package/dist/overlay-C0YSnxoV.js.map +1 -0
- package/dist/{overlay-DG6EeyKt.cjs → overlay-CG1gc1Jw.cjs} +2 -2
- package/dist/overlay-CG1gc1Jw.cjs.map +1 -0
- package/dist/overlay.cjs +1 -1
- package/dist/{overlay.confirm-body-78e1WrN9.cjs → overlay.confirm-body-B-Kmn7LF.cjs} +1 -1
- package/dist/{overlay.confirm-body-78e1WrN9.cjs.map → overlay.confirm-body-B-Kmn7LF.cjs.map} +1 -1
- package/dist/{overlay.confirm-body-D_P2e7l6.js → overlay.confirm-body-BmOnrKbF.js} +1 -1
- package/dist/{overlay.confirm-body-D_P2e7l6.js.map → overlay.confirm-body-BmOnrKbF.js.map} +1 -1
- package/dist/overlay.js +3 -3
- package/dist/{overlay.service-DQkGPUY7.cjs → overlay.service-BPKV2a8w.cjs} +1 -1
- package/dist/{overlay.service-DQkGPUY7.cjs.map → overlay.service-BPKV2a8w.cjs.map} +1 -1
- package/dist/{overlay.service-C8NwO4Bx.js → overlay.service-CRoq9Gu-.js} +2 -2
- package/dist/{overlay.service-C8NwO4Bx.js.map → overlay.service-CRoq9Gu-.js.map} +1 -1
- package/dist/page.cjs +1 -1
- package/dist/page.js +2 -2
- package/dist/{progress-CMSst_2U.cjs → progress-B9RWAFv5.cjs} +1 -1
- package/dist/{progress-CMSst_2U.cjs.map → progress-B9RWAFv5.cjs.map} +1 -1
- package/dist/{progress-C4kDZfb7.js → progress-CEEl7vdd.js} +1 -1
- package/dist/{progress-C4kDZfb7.js.map → progress-CEEl7vdd.js.map} +1 -1
- package/dist/progress.cjs +1 -1
- package/dist/progress.js +1 -1
- package/dist/radio-group-C2y6H5YY.cjs +19 -0
- package/dist/radio-group-C2y6H5YY.cjs.map +1 -0
- package/dist/radio-group-VERF_8rC.js +71 -0
- package/dist/radio-group-VERF_8rC.js.map +1 -0
- package/dist/radio-group.cjs +1 -1
- package/dist/radio-group.js +1 -1
- package/dist/range.cjs +1 -1
- package/dist/range.js +1 -1
- package/dist/{scroll-C1klVgSQ.js → scroll-Bj7FsS08.js} +1 -1
- package/dist/{scroll-C1klVgSQ.js.map → scroll-Bj7FsS08.js.map} +1 -1
- package/dist/{scroll-S-bXF2u6.cjs → scroll-Djz3pJfX.cjs} +1 -1
- package/dist/{scroll-S-bXF2u6.cjs.map → scroll-Djz3pJfX.cjs.map} +1 -1
- package/dist/{select-UU2pB67h.js → select-ClJj_2AP.js} +3 -3
- package/dist/select-ClJj_2AP.js.map +1 -0
- package/dist/select-CngphfDB.cjs +56 -0
- package/dist/select-CngphfDB.cjs.map +1 -0
- package/dist/select.cjs +1 -1
- package/dist/select.js +1 -1
- package/dist/skeleton.cjs +1 -1
- package/dist/skeleton.js +1 -1
- package/dist/skills/SKILL.md +12 -0
- package/dist/skills/schmancy/SKILL.md +12 -0
- package/dist/slider.cjs +1 -1
- package/dist/slider.js +1 -1
- package/dist/{splash-screen-ChMkAPLU.js → splash-screen-BQsBy3O1.js} +1 -1
- package/dist/{splash-screen-ChMkAPLU.js.map → splash-screen-BQsBy3O1.js.map} +1 -1
- package/dist/{splash-screen-BvaDkvJU.cjs → splash-screen-CntIFk2h.cjs} +1 -1
- package/dist/{splash-screen-BvaDkvJU.cjs.map → splash-screen-CntIFk2h.cjs.map} +1 -1
- package/dist/splash-screen.cjs +1 -1
- package/dist/splash-screen.js +1 -1
- package/dist/{src-DnunCC4X.js → src-BAXhEv8f.js} +32 -32
- package/dist/{src-DnunCC4X.js.map → src-BAXhEv8f.js.map} +1 -1
- package/dist/{src-BIlD63Cz.cjs → src-ChFa-FDD.cjs} +1 -1
- package/dist/{src-BIlD63Cz.cjs.map → src-ChFa-FDD.cjs.map} +1 -1
- package/dist/steps.cjs +1 -1
- package/dist/steps.js +1 -1
- package/dist/{surface-DCRy-EyT.js → surface-CHUJSY1o.js} +1 -1
- package/dist/{surface-DCRy-EyT.js.map → surface-CHUJSY1o.js.map} +1 -1
- package/dist/{surface-DWwQDX9r.cjs → surface-CXmQuXun.cjs} +1 -1
- package/dist/{surface-DWwQDX9r.cjs.map → surface-CXmQuXun.cjs.map} +1 -1
- package/dist/surface.cjs +1 -1
- package/dist/surface.js +1 -1
- package/dist/switch.cjs +1 -1
- package/dist/switch.js +1 -1
- package/dist/table.cjs +1 -1
- package/dist/table.js +1 -1
- package/dist/{tabs-lxQHWEb7.cjs → tabs-Bku0sC0p.cjs} +1 -1
- package/dist/{tabs-lxQHWEb7.cjs.map → tabs-Bku0sC0p.cjs.map} +1 -1
- package/dist/{tabs-CkDNLbiS.js → tabs-DPVX21WM.js} +1 -1
- package/dist/{tabs-CkDNLbiS.js.map → tabs-DPVX21WM.js.map} +1 -1
- package/dist/tabs.cjs +1 -1
- package/dist/tabs.js +1 -1
- package/dist/teleport.cjs +1 -1
- package/dist/teleport.js +1 -1
- package/dist/{textarea-CNa4dSvF.cjs → textarea-CqJNviYi.cjs} +1 -1
- package/dist/{textarea-CNa4dSvF.cjs.map → textarea-CqJNviYi.cjs.map} +1 -1
- package/dist/{textarea-DkfGmRSI.js → textarea-D6z1UZzs.js} +1 -1
- package/dist/{textarea-DkfGmRSI.js.map → textarea-D6z1UZzs.js.map} +1 -1
- package/dist/textarea.cjs +1 -1
- package/dist/textarea.js +1 -1
- package/dist/{theme-CMyXTDht.cjs → theme-BpKVBJCr.cjs} +1 -1
- package/dist/{theme-CMyXTDht.cjs.map → theme-BpKVBJCr.cjs.map} +1 -1
- package/dist/{theme-CNWRYdfn.js → theme-DbHfINBV.js} +1 -1
- package/dist/{theme-CNWRYdfn.js.map → theme-DbHfINBV.js.map} +1 -1
- package/dist/{theme-button-CixloLin.js → theme-button-BeU8Nbs2.js} +1 -1
- package/dist/{theme-button-CixloLin.js.map → theme-button-BeU8Nbs2.js.map} +1 -1
- package/dist/{theme-button-kMhsX5Oe.cjs → theme-button-Cof9I85G.cjs} +1 -1
- package/dist/{theme-button-kMhsX5Oe.cjs.map → theme-button-Cof9I85G.cjs.map} +1 -1
- package/dist/theme-button.cjs +1 -1
- package/dist/theme-button.js +1 -1
- package/dist/theme.cjs +1 -1
- package/dist/theme.js +2 -2
- package/dist/tree.cjs +1 -1
- package/dist/tree.js +1 -1
- package/dist/typography.cjs +1 -1
- package/dist/typography.js +1 -1
- package/dist/visually-hidden.cjs +1 -1
- package/dist/visually-hidden.js +1 -1
- package/dist/{window-qaGFMn_4.cjs → window-Cql1aIX2.cjs} +1 -1
- package/dist/{window-qaGFMn_4.cjs.map → window-Cql1aIX2.cjs.map} +1 -1
- package/dist/{window-BcvDNi9D.js → window-DmMNsos0.js} +1 -1
- package/dist/{window-BcvDNi9D.js.map → window-DmMNsos0.js.map} +1 -1
- package/dist/window.cjs +1 -1
- package/dist/window.js +1 -1
- package/package.json +1 -1
- package/skills/schmancy/SKILL.md +12 -0
- package/src/CLAUDE.md +20 -0
- package/src/area/area.component.ts +155 -342
- package/src/button/button.ts +8 -5
- package/src/button/icon-button.ts +8 -5
- package/src/chips/filter-chip.ts +1 -3
- package/src/details/details.ts +1 -1
- package/src/form/fields/radio-group/radio-button.ts +22 -44
- package/src/form/fields/radio-group/radio-group.ts +20 -75
- package/src/form/fields/select/select.ts +3 -2
- package/src/overlay/overlay.component.ts +29 -39
- package/src/overlay/overlay.positioning.ts +10 -2
- package/src/state/schmancy-state.html +897 -0
- package/src/state/schmancy-state.md +981 -0
- package/types/src/area/area.component.d.ts +0 -15
- package/types/src/button/icon-button.d.ts +1 -1
- package/types/src/form/fields/radio-group/radio-button.d.ts +2 -5
- package/types/src/form/fields/radio-group/radio-group.d.ts +2 -10
- package/types/src/overlay/overlay.positioning.d.ts +9 -1
- package/dist/area-BIipuSyO.js.map +0 -1
- package/dist/area-C-EMiNEE.cjs +0 -12
- package/dist/area-C-EMiNEE.cjs.map +0 -1
- package/dist/chips-D1kJrbzo.js.map +0 -1
- package/dist/chips-Dx_WvOGk.cjs.map +0 -1
- package/dist/details-Cpg8sH2F.js.map +0 -1
- package/dist/details-CwSDur6j.cjs.map +0 -1
- package/dist/mixins-DCVXqL1Q.js +0 -636
- package/dist/mixins-Du9HMrIG.cjs +0 -254
- package/dist/overlay-DG6EeyKt.cjs.map +0 -1
- package/dist/overlay-oxM9OLXP.js.map +0 -1
- package/dist/radio-group-DB9D2ZkA.js +0 -108
- package/dist/radio-group-DB9D2ZkA.js.map +0 -1
- package/dist/radio-group-dVUvYFq7.cjs +0 -40
- package/dist/radio-group-dVUvYFq7.cjs.map +0 -1
- package/dist/select-UU2pB67h.js.map +0 -1
- package/dist/select-fu_-rZyn.cjs +0 -56
- package/dist/select-fu_-rZyn.cjs.map +0 -1
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
# Schmancy State — Complete Developer Reference
|
|
2
|
+
|
|
3
|
+
> Source of truth. `@mhmo91/schmancy/state` · `packages/schmancy/src/state/` · 2026-05-08
|
|
4
|
+
> Covers: foundational libraries, design patterns with mental models, full technical system design.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## How to read this document
|
|
9
|
+
|
|
10
|
+
Read **Part 1** first — it explains the two external libraries schmancy state is built on. Without understanding TC39 Signals and `@lit/context`, the design patterns are mechanisms without motivation.
|
|
11
|
+
|
|
12
|
+
Read **Part 2** to understand *why* the code is shaped the way it is — each pattern is named, given a mental model, shown visually, and grounded in actual code.
|
|
13
|
+
|
|
14
|
+
Read **Part 3** as a reference — low-level technical detail for every file, every function, every invariant.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Part 1 — Foundational Libraries
|
|
19
|
+
|
|
20
|
+
## A. TC39 Signals — `signal-polyfill` · `@lit-labs/signals`
|
|
21
|
+
|
|
22
|
+
### The problem
|
|
23
|
+
|
|
24
|
+
A spreadsheet: cell `C1 = A1 + B1`. When `A1` changes, `C1` recomputes automatically — without you calling "recompute C1" explicitly. JavaScript has no built-in version of this. Every reactive system that ever existed (MobX, Vue reactivity, SolidJS signals, Svelte stores) solves the same problem with the same idea: *track which computations read which values, then invalidate those computations when the values change.*
|
|
25
|
+
|
|
26
|
+
TC39 Signals standardises this mechanism. `signal-polyfill` ships it today. `@lit-labs/signals` wraps it for Lit components.
|
|
27
|
+
|
|
28
|
+
### Three primitives
|
|
29
|
+
|
|
30
|
+
**`Signal.State<T>` — the mutable leaf**
|
|
31
|
+
|
|
32
|
+
Holds a value. Notifies dependents when replaced. This is the bottom of the graph.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const n = new Signal.State(0)
|
|
36
|
+
n.get() // → 0
|
|
37
|
+
n.set(1) // notifies all dependents
|
|
38
|
+
n.get() // → 1
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**`Signal.Computed<T>` — the derived cell**
|
|
42
|
+
|
|
43
|
+
Lazy. Runs a callback to produce a value. Caches the result until any dependency changes. Re-runs only when `.get()` is called after a dependency was set.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const doubled = new Signal.Computed(() => n.get() * 2)
|
|
47
|
+
doubled.get() // runs fn → 2
|
|
48
|
+
doubled.get() // cached — fn NOT run → 2
|
|
49
|
+
n.set(5)
|
|
50
|
+
doubled.get() // dependency changed — re-runs → 10
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**`Signal.subtle.Watcher` — the push hook**
|
|
54
|
+
|
|
55
|
+
Low-level. Fires a callback the moment any watched signal or computed *becomes dirty* (i.e. its value may have changed). This is what bridges the pull model to external push systems — RxJS observables, Lit's `requestUpdate()`.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const w = new Signal.subtle.Watcher(() => {
|
|
59
|
+
// fires synchronously when any watched signal is set
|
|
60
|
+
w.watch() // must re-arm — Watcher disarms itself after each notification
|
|
61
|
+
})
|
|
62
|
+
w.watch(n) // start watching
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### How auto-tracking works — the core mechanism
|
|
66
|
+
|
|
67
|
+
The polyfill maintains a single module-level variable: **`activeConsumer`**. When a `Computed`'s callback runs, `activeConsumer` is set to that computed. Every `signal.get()` call checks `activeConsumer` and registers itself as a dependency of the current consumer. No explicit subscribe call — **the read is the registration**.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
// Trace: what happens inside Computed.get()
|
|
71
|
+
|
|
72
|
+
activeConsumer = doubled_computed ← set before running the callback
|
|
73
|
+
|
|
74
|
+
doubled_computed.fn():
|
|
75
|
+
n.get() ← n checks activeConsumer
|
|
76
|
+
n._subscribers.add(doubled) ← n records doubled as a dependent
|
|
77
|
+
return n._value (0)
|
|
78
|
+
|
|
79
|
+
activeConsumer = null ← cleared after callback
|
|
80
|
+
|
|
81
|
+
// Later: n.set(5)
|
|
82
|
+
n._value = 5
|
|
83
|
+
doubled_computed.markDirty() ← n notifies all subscribers
|
|
84
|
+
doubled_computed._dirty = true
|
|
85
|
+
Watcher callback fires ← if a Watcher was watching doubled
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### SignalWatcher — how Lit plugs in
|
|
89
|
+
|
|
90
|
+
`SignalWatcher` (from `@lit-labs/signals`) is a class mixin that intercepts Lit's `performUpdate()` and wraps it in a `Signal.Computed`. A `Signal.subtle.Watcher` watches that computed. When any signal read during the last render changes, the Watcher fires and calls `requestUpdate()`.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// Reconstructed from minified source (signal-watcher.js)
|
|
94
|
+
class SignalWatcher extends LitElement {
|
|
95
|
+
_renderComputed = new Signal.Computed(() => {
|
|
96
|
+
super.performUpdate() // your render() runs inside here
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
_watcher = new Signal.subtle.Watcher(() => {
|
|
100
|
+
this.requestUpdate() // schedule Lit re-render
|
|
101
|
+
this._watcher.watch() // re-arm
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
performUpdate() {
|
|
105
|
+
this._watcher.watch(this._renderComputed)
|
|
106
|
+
this._renderComputed.get() // runs render(), auto-tracking all signal reads
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`SchmancyElement` already includes `SignalWatcher`. Reading `cart.value` in `render()` IS the subscription. No decorator, no setup.
|
|
112
|
+
|
|
113
|
+
### Full cycle — trace by hand
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
1. const cart = new Signal.State({ items: [], total: 0 })
|
|
117
|
+
|
|
118
|
+
2. render() runs (inside SignalWatcher's Computed)
|
|
119
|
+
activeConsumer = renderComputed
|
|
120
|
+
cart.get() → cart._subscribers.add(renderComputed)
|
|
121
|
+
renders "Total: 0"
|
|
122
|
+
activeConsumer = null
|
|
123
|
+
|
|
124
|
+
3. cart.set({ items: ['x'], total: 12 }) — called anywhere in the app
|
|
125
|
+
cart._value = { items: ['x'], total: 12 }
|
|
126
|
+
renderComputed.markDirty()
|
|
127
|
+
_watcher callback fires (synchronous)
|
|
128
|
+
queueMicrotask(() => component.requestUpdate())
|
|
129
|
+
|
|
130
|
+
4. microtask: Lit re-renders
|
|
131
|
+
renderComputed.get() → re-runs render(), re-tracks deps
|
|
132
|
+
renders "Total: 12" ✓
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## B. `@lit/context` — tree-scoped value injection
|
|
138
|
+
|
|
139
|
+
### The problem
|
|
140
|
+
|
|
141
|
+
You have a value many nested components need — a theme, a cart, a user session. Passing it as a property through every intermediate element ("prop drilling") is brittle and coupling. **Context** is the alternative: a provider at any tree position announces a value; any descendant requests it without any intermediate element knowing.
|
|
142
|
+
|
|
143
|
+
React has `createContext / useContext`. `@lit/context` is the same idea implemented with the DOM's own event system — no framework runtime required.
|
|
144
|
+
|
|
145
|
+
### The mechanism — one event
|
|
146
|
+
|
|
147
|
+
The entire protocol is a single custom DOM event: `"context-request"`. It bubbles up the DOM. A provider listens for it and calls the requester's callback with the value.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// The event — from actual source (context-request-event.js)
|
|
151
|
+
class ContextRequestEvent extends Event {
|
|
152
|
+
constructor(context, contextTarget, callback) {
|
|
153
|
+
super('context-request', { bubbles: true, composed: true })
|
|
154
|
+
this.context = context // identity key (a Symbol)
|
|
155
|
+
this.contextTarget = contextTarget
|
|
156
|
+
this.callback = callback // "call me with the value"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The provider — from context-provider.js
|
|
161
|
+
host.addEventListener('context-request', (e) => {
|
|
162
|
+
if (e.context !== this.context) return // wrong key — ignore
|
|
163
|
+
if (e.contextTarget === host) return // don't serve yourself
|
|
164
|
+
e.stopPropagation() // closest provider wins
|
|
165
|
+
e.callback(this.value) // deliver the value
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Why `Symbol.for()` is critical
|
|
170
|
+
|
|
171
|
+
Provider and consumer must use the *exact same key object*. `Symbol('x') !== Symbol('x')` — each call creates a unique symbol. `Symbol.for('x') === Symbol.for('x')` — the runtime-global registry returns the same symbol for the same string, even across separate bundle copies. Schmancy uses `Symbol.for('schmancy.state:' + namespace)` so Bundle A's provider matches Bundle B's request.
|
|
172
|
+
|
|
173
|
+
### Full resolution — trace by hand
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
DOM tree:
|
|
177
|
+
<app>
|
|
178
|
+
<schmancy-context .provides=${[cart]}> ← ContextProvider installed here
|
|
179
|
+
<checkout-page>
|
|
180
|
+
<cart-view> ← wants isolated copy
|
|
181
|
+
|
|
182
|
+
Step 1 — cart-view calls cart.value inside render()
|
|
183
|
+
resolveContextual('hannah/cart', globalFallback)
|
|
184
|
+
host = resolveActiveHost() → <cart-view> element
|
|
185
|
+
|
|
186
|
+
Step 2 — dispatch ContextRequestEvent from <cart-view>
|
|
187
|
+
let resolved
|
|
188
|
+
cartViewEl.dispatchEvent(new ContextRequestEvent(
|
|
189
|
+
Symbol.for('schmancy.state:hannah/cart'),
|
|
190
|
+
cartViewEl,
|
|
191
|
+
value => { resolved = value }
|
|
192
|
+
))
|
|
193
|
+
// bubbles: cart-view → checkout-page → schmancy-context
|
|
194
|
+
|
|
195
|
+
Step 3 — ContextProvider on <schmancy-context> intercepts
|
|
196
|
+
e.context === our key? yes
|
|
197
|
+
e.contextTarget === host? no (it's cart-view)
|
|
198
|
+
e.stopPropagation() ← outer providers never see this
|
|
199
|
+
e.callback(isolatedCopy) ← resolved = isolatedCopy ✓
|
|
200
|
+
|
|
201
|
+
Step 4 — resolveContextual caches and returns
|
|
202
|
+
hostResolverCache[cartViewEl]['hannah/cart'] = isolatedCopy
|
|
203
|
+
cart.value → isolatedCopy.value ✓
|
|
204
|
+
|
|
205
|
+
Step 5 — same render() again
|
|
206
|
+
cache hit → skip event entirely → O(1) ✓
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**`bubbles: true`** — event travels up the DOM so a nested consumer reaches a distant provider.
|
|
210
|
+
**`composed: true`** — event crosses shadow DOM boundaries. Without it, shadow roots silently block the event.
|
|
211
|
+
**`stopPropagation()`** — closest provider wins. Outer providers never see the event.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
# Part 2 — Design Patterns
|
|
216
|
+
|
|
217
|
+
## Pattern 01 — Module-Scoped Singleton
|
|
218
|
+
|
|
219
|
+
**Named pattern:** Singleton · Module system
|
|
220
|
+
|
|
221
|
+
> *"One name, one value, everywhere the module is imported."*
|
|
222
|
+
|
|
223
|
+
**Mental model:** A plain JavaScript `const` at the top of a file. ES module semantics guarantee it is evaluated once and the same object is returned to every importer. That is the singleton. The state factory makes this the *default* by producing an object that lives on the module's scope, never inside a class or function.
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
// cart.state.ts
|
|
227
|
+
export const cart = state<CartState>('hannah/cart').session(initial)
|
|
228
|
+
│
|
|
229
|
+
┌────────────────────┼────────────────────┐
|
|
230
|
+
▼ ▼ ▼
|
|
231
|
+
CartView.ts CartSummary.ts CheckoutPage.ts
|
|
232
|
+
import {cart} import {cart} import {cart}
|
|
233
|
+
← same object ─────────────────────────────────────────
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The singleton is not managed by a service locator or DI container. It is the JS module system itself. No registry to look up, no `getInstance()` to call — just an import.
|
|
237
|
+
|
|
238
|
+
**The one rule:** Never use `using cart = state(...)` at module scope. The `using` keyword disposes on scope exit — which for a module-level variable is never until the tab closes. Reserve `using` for test scope where you want explicit teardown.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Pattern 02 — Transparent Proxy
|
|
243
|
+
|
|
244
|
+
**Named pattern:** Proxy · Structural
|
|
245
|
+
|
|
246
|
+
> *"The same `cart.value` call routes to a different object depending on where you are in the DOM tree — and you never know."*
|
|
247
|
+
|
|
248
|
+
**Mental model:** A receptionist who receives every call. Most of the time she routes it to the main office (module-scoped signal). But if a call comes in while the caller is sitting inside a special meeting room (`<schmancy-context>`), she routes it to the room's private whiteboard. The caller never dials a different number — they always call the receptionist — but who actually picks up changes based on physical location.
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
DOM tree:
|
|
252
|
+
<app>
|
|
253
|
+
├── <schmancy-context .provides=${[cart]}> ← installs isolated copy A
|
|
254
|
+
│ ├── <cart-view>
|
|
255
|
+
│ │ cart.value → isolated copy A
|
|
256
|
+
│ │ cart.set() → isolated copy A
|
|
257
|
+
│ └── <cart-summary>
|
|
258
|
+
│ cart.value → isolated copy A
|
|
259
|
+
│
|
|
260
|
+
└── <sidebar>
|
|
261
|
+
cart.value → module-scoped global
|
|
262
|
+
cart.set() → module-scoped global
|
|
263
|
+
|
|
264
|
+
Same variable. Different target. No code change in consumers.
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**How the proxy is built — without ES `Proxy`:**
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// Every public accessor on the global instance is a live getter
|
|
271
|
+
Object.defineProperty(instance, 'value', {
|
|
272
|
+
get: () => {
|
|
273
|
+
const target = resolveContextual(namespace, isolatedTarget)
|
|
274
|
+
return target.value // reads from whichever target resolved
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// Every write method also routes through resolveContextual
|
|
279
|
+
instance['set'] = (...args) => {
|
|
280
|
+
const target = resolveContextual(namespace, isolatedTarget)
|
|
281
|
+
return target['set'](...args)
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`Object.defineProperty` live getters are used instead of `ES Proxy` because the interception needs to know the *caller's DOM position*, which requires the active-host tracking system (Pattern 06) as an orthogonal concern.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Pattern 03 — Adapter / Storage Port
|
|
290
|
+
|
|
291
|
+
**Named pattern:** Adapter · Ports & Adapters · Strategy
|
|
292
|
+
|
|
293
|
+
> *"The state logic never touches a storage API directly. It talks to a uniform interface, and the backend is swapped by configuration."*
|
|
294
|
+
|
|
295
|
+
**Mental model:** A document that needs to be filed. The document doesn't care whether the filing cabinet is in the same room (memory), the local office (sessionStorage), an office that survives overnight (localStorage), or a central warehouse that never forgets (IndexedDB). It hands the document to a filing clerk. The interface to the clerk is always the same: *file this, retrieve this, destroy this*.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
interface StorageAdapter<T> {
|
|
299
|
+
load(): Promise<T | null>
|
|
300
|
+
save(value: T): Promise<void>
|
|
301
|
+
clear(): Promise<void>
|
|
302
|
+
close?(): Promise<void> // IDB only
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Four implementations, one interface:
|
|
306
|
+
MemoryAdapter → .memory() JS heap, no I/O
|
|
307
|
+
WebStorageAdapter → .local() localStorage
|
|
308
|
+
WebStorageAdapter → .session() sessionStorage
|
|
309
|
+
IndexedDBAdapter → .idb() IndexedDB 'SchmancyState'/'states'
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**The Map/Set JSON tunnel** (a nested adapter inside `WebStorageAdapter`):
|
|
313
|
+
`localStorage` only stores strings. `Map` and `Set` aren't JSON-serialisable by default. A custom replacer/reviver pair handles the translation:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
// On save:
|
|
317
|
+
Map → { $kind: '__schmancy_state_Map', entries: [[k, v], …] }
|
|
318
|
+
Set → { $kind: '__schmancy_state_Set', values: [v, …] }
|
|
319
|
+
// On load: reviver reconstructs. IDB stores native objects — no tunnel needed.
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Adding a new backend (e.g. OPFS) requires one new class implementing `StorageAdapter<T>` and one case in `createAdapter()`. Nothing else changes.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Pattern 04 — Dual Observer (Signal + Observable)
|
|
327
|
+
|
|
328
|
+
**Named pattern:** Observer · Pull + Push · Bridge
|
|
329
|
+
|
|
330
|
+
> *"One value, two reactive surfaces — pull (signals) for the render loop, push (Observables) for pipelines."*
|
|
331
|
+
|
|
332
|
+
**Mental model:** Signals are a thermometer on the wall. You glance at it whenever you need the temperature — you *pull* the value. Observables are like weather alerts — the system *pushes* a notification at the moment something changes. Both surfaces sit over the same measurement.
|
|
333
|
+
|
|
334
|
+
```
|
|
335
|
+
Signal.State<T>
|
|
336
|
+
signal.set(next) ← writes
|
|
337
|
+
│
|
|
338
|
+
┌───────────────┼───────────────────────┐
|
|
339
|
+
▼ ▼
|
|
340
|
+
PULL SURFACE PUSH SURFACE
|
|
341
|
+
signal.get() / state.value Observable<T> / state.$
|
|
342
|
+
(TC39 signals) (RxJS)
|
|
343
|
+
│ │
|
|
344
|
+
SignalWatcher auto-tracks Signal.subtle.Watcher
|
|
345
|
+
reads inside render() + queueMicrotask bridge
|
|
346
|
+
│ │
|
|
347
|
+
component.requestUpdate() subscriber.next(signal.get())
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**The bridge — how Signal becomes Observable:**
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
function signalToObservable<T>(signal): Observable<T> {
|
|
354
|
+
return new Observable(subscriber => {
|
|
355
|
+
subscriber.next(signal.get()) // ① immediate initial emission
|
|
356
|
+
let scheduled = false
|
|
357
|
+
const watcher = new Signal.subtle.Watcher(() => { // ② fires on signal.set()
|
|
358
|
+
if (scheduled) return
|
|
359
|
+
scheduled = true
|
|
360
|
+
queueMicrotask(() => {
|
|
361
|
+
scheduled = false
|
|
362
|
+
if (subscriber.closed) return
|
|
363
|
+
subscriber.next(signal.get()) // ③ push latest, coalesced
|
|
364
|
+
watcher.watch(signal) // ④ re-arm
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
watcher.watch(signal)
|
|
368
|
+
return () => watcher.unwatch(signal) // ⑤ cleanup on unsubscribe
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use **signals / `.value`** when: reading inside `render()`, feeding `computed()`, running `effect()`, needing synchronous access.
|
|
374
|
+
Use **Observable / `.$`** when: composing with other streams (`combineLatest`, `switchMap`), debouncing/throttling, piping with `takeUntil(this.disconnecting)`.
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Pattern 05 — Type-Driven Command Dispatch
|
|
379
|
+
|
|
380
|
+
**Named pattern:** Command · Strategy · Discriminated Union
|
|
381
|
+
|
|
382
|
+
> *"The shape of your data determines which write commands exist. TypeScript's type system makes the wrong operation inexpressible."*
|
|
383
|
+
|
|
384
|
+
**Mental model:** A chef's knife block. The shape of the slot tells you exactly which knife fits. If you have a `Map`, you get `set(k,v)`, `delete(k)`, `clear()`. You cannot accidentally call `push()` on a Map — the type system prevents it.
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
T = Map<K,V> → Kind = 'map' → MapAPI<T>
|
|
388
|
+
T = Set<U> → Kind = 'set' → SetAPI<T>
|
|
389
|
+
T = U[] → Kind = 'array' → ArrayAPI<T>
|
|
390
|
+
T = string|number|… → Kind = 'prim' → ScalarAPI<T>
|
|
391
|
+
T = Foo | null → Kind = 'null' → ScalarAPI<T>
|
|
392
|
+
T = { … } → Kind = 'obj' → ObjectAPI<T>
|
|
393
|
+
|
|
394
|
+
const sel = state<Set<string>>('ui/sel').memory(new Set())
|
|
395
|
+
sel.add('id') ✓
|
|
396
|
+
sel.toggle('id') ✓
|
|
397
|
+
sel.push('id') ✗ TypeScript error — push does not exist on SetAPI
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**All writes funnel into one `commit()` function:**
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const commit = (next: unknown): void => {
|
|
404
|
+
internal.signal.set(next) // synchronous — visible immediately
|
|
405
|
+
scheduleWrite(internal) // persist via adapter, microtask-debounced
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Every write method is syntactic sugar over commit():
|
|
409
|
+
// ObjectAPI:
|
|
410
|
+
set(patch, merge = true) { commit(merge ? {...current, ...patch} : patch) }
|
|
411
|
+
update(recipe) { commit(produce(current, recipe)) } // immer
|
|
412
|
+
|
|
413
|
+
// SetAPI:
|
|
414
|
+
toggle(value) {
|
|
415
|
+
const next = new Set(current)
|
|
416
|
+
next.has(value) ? next.delete(value) : next.add(value)
|
|
417
|
+
commit(next)
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
**Immutability by construction:** every write produces a new value — spread for objects, `new Map(current)` for maps, `new Set(current)` for sets, spread for arrays, or immer's `produce()` for recipes. Nothing mutates in place. Reference inequality = change.
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Pattern 06 — Zone / Execution Context Propagation
|
|
426
|
+
|
|
427
|
+
**Named pattern:** Zone · AsyncContext polyfill · Ambient state
|
|
428
|
+
|
|
429
|
+
> *"Knowing which DOM element is 'currently running code' — even across async boundaries — without passing it as a parameter."*
|
|
430
|
+
|
|
431
|
+
**Mental model:** A call centre where every agent wears a badge saying which client file they are working on. The badge tells the system which client's records to look up. Zone.js is the classic implementation. Schmancy implements a minimal version — a per-call-stack "badge" identifying which DOM element is currently executing — using a global stack and a Promise patch.
|
|
432
|
+
|
|
433
|
+
**Four-tier fallback chain:**
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
Tier 1: Explicit stack
|
|
437
|
+
_activeHost.run(host, fn): pushes host, calls fn, pops on exit
|
|
438
|
+
SchmancyElement prototype-wrap calls .run(this, fn) around every
|
|
439
|
+
concrete method: render(), connectedCallback(), firstUpdated(),
|
|
440
|
+
class methods called from any of those.
|
|
441
|
+
|
|
442
|
+
Tier 2: Promise.prototype.then patch
|
|
443
|
+
Captures stack head at .then() chain-time; restores when callback fires.
|
|
444
|
+
fetch('/api').then(data => { cart.update(…) }) ← works ✓
|
|
445
|
+
async method: const data = await fetch()
|
|
446
|
+
cart.update(…) ← does NOT work (V8 opt) ✗
|
|
447
|
+
|
|
448
|
+
Tier 3: Event-host slot
|
|
449
|
+
<schmancy-context> installs capture-phase listeners for 16 event types.
|
|
450
|
+
When a DOM event fires, it publishes the deepest HTMLElement as host.
|
|
451
|
+
Slot self-clears via queueMicrotask.
|
|
452
|
+
<button @click=${() => cart.set(…)}> ← works ✓
|
|
453
|
+
|
|
454
|
+
Tier 4: document.activeElement
|
|
455
|
+
Keyboard / focus handlers.
|
|
456
|
+
|
|
457
|
+
Tier 5 (implicit): undefined → module-scoped global
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
**The Promise patch:**
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const _origThen = Promise.prototype.then
|
|
464
|
+
Promise.prototype.then = function(onfulfilled, onrejected) {
|
|
465
|
+
const captured = _stack[_stack.length - 1] // capture NOW at chain time
|
|
466
|
+
const wrapFulfilled = v => {
|
|
467
|
+
_stack.push(captured) // restore at CALLBACK time
|
|
468
|
+
try { return onfulfilled(v) }
|
|
469
|
+
finally { _stack.pop() }
|
|
470
|
+
}
|
|
471
|
+
return _origThen.call(this, wrapFulfilled, wrapRejected)
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**Known limitation:** V8's native `await` optimization (since v7.x) bypasses `Promise.resolve(x).then(continuation)` — the patch never fires for `await`-resumed continuations. Mutations after the first `await` fall back to the module-scoped global. Fix: keep mutations before the `await`, or chain explicitly with `.then()`.
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Pattern 07 — Provider / Consumer (Context Protocol)
|
|
480
|
+
|
|
481
|
+
**Named pattern:** Context · @lit/context · Tree-scoped DI
|
|
482
|
+
|
|
483
|
+
> *"A provider in the DOM tree answers requests from consumers below it, without knowing who they are."*
|
|
484
|
+
|
|
485
|
+
**Mental model:** React Context. A `<Provider value={x}>` wraps a subtree; any `useContext()` inside it receives `x`. `<schmancy-context>` is the same idea for Web Components, implemented with DOM events instead of a VDOM tree walk.
|
|
486
|
+
|
|
487
|
+
```
|
|
488
|
+
Consumer dispatches:
|
|
489
|
+
new ContextRequestEvent(
|
|
490
|
+
Symbol.for('schmancy.state:hannah/cart'),
|
|
491
|
+
consumerElement,
|
|
492
|
+
value => { resolved = value }
|
|
493
|
+
)
|
|
494
|
+
// bubbles up the DOM
|
|
495
|
+
|
|
496
|
+
Provider (<schmancy-context>) intercepts:
|
|
497
|
+
ContextProvider listens for 'context-request'
|
|
498
|
+
checks: event.context === our key?
|
|
499
|
+
yes → event.callback(isolated_copy)
|
|
500
|
+
event.stopPropagation() ← closest provider wins
|
|
501
|
+
|
|
502
|
+
Nested:
|
|
503
|
+
<schmancy-context .provides=${[cart]}> ← outer
|
|
504
|
+
<schmancy-context .provides=${[cart]}> ← inner
|
|
505
|
+
<cart-view>
|
|
506
|
+
cart.value → inner's copy ✓ closest wins
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
**`<schmancy-context>` on connect:**
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
for (const tmpl of this.provides) {
|
|
513
|
+
const isolated = tmpl._isolatedInstance()
|
|
514
|
+
// ^ createInstance({ storage: 'memory', initial: tmpl.value }, { isolated: true })
|
|
515
|
+
// ^ seeded from global's current value; always memory-backed
|
|
516
|
+
const ctx = createContext(Symbol.for('schmancy.state:' + tmpl.namespace))
|
|
517
|
+
const provider = new ContextProvider(this, { context: ctx, initialValue: isolated })
|
|
518
|
+
this._scoped.push({ isolated, provider })
|
|
519
|
+
}
|
|
520
|
+
// + capture-phase listeners for 16 event types → Tier 3 of active-host chain
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**On disconnect:** `isolated.destroy()` for each — flushes pending writes, releases namespace claim.
|
|
524
|
+
|
|
525
|
+
Isolated copies are **always memory-backed** — they share the element's lifetime, never persist to localStorage/sessionStorage/IDB.
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Pattern 08 — Prototype Decoration (ReactiveController)
|
|
530
|
+
|
|
531
|
+
**Named pattern:** Decorator · ReactiveController · Template Method
|
|
532
|
+
|
|
533
|
+
> *"Attach subscription lifecycle to a component's class without modifying the class body."*
|
|
534
|
+
|
|
535
|
+
**Mental model:** A Decorator Pattern attaches new behaviour to an object without changing its source. Lit's `addInitializer` is the hook. The `@observe` decorator and `bindState` both use `ReactiveController` as the attachment — a standardised plugin interface: *tell me when the host connects and disconnects, and I'll wire subscriptions.*
|
|
536
|
+
|
|
537
|
+
**Three binding options — decreasing magic:**
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
// Option 1: Direct render() read — SignalWatcher auto-tracks (zero code)
|
|
541
|
+
class CartView extends SchmancyElement {
|
|
542
|
+
render() { return html`${cart.value.items.length}` }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Option 2: @observe — field updated on each emission
|
|
546
|
+
class CartView extends SchmancyElement {
|
|
547
|
+
@observe(cart) cart!: CartState
|
|
548
|
+
onClick() { console.log(this.cart) } // safe in event handlers
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Option 3: bindState — same controller, no decorator
|
|
552
|
+
class CustomHost extends LitElement { // not a SchmancyElement
|
|
553
|
+
cart = bindState(this, cart)
|
|
554
|
+
render() { return html`${this.cart.value.items.length}` }
|
|
555
|
+
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
**How `@observe` works — two hooks:**
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
export function observe<T>(source) {
|
|
562
|
+
return function(proto, propertyKey) {
|
|
563
|
+
const storageKey = Symbol(`__observe_${propertyKey}`)
|
|
564
|
+
|
|
565
|
+
// Hook 1: per-PROTOTYPE accessor (runs on every instance via prototype chain)
|
|
566
|
+
Object.defineProperty(proto, propertyKey, {
|
|
567
|
+
get(this) { return this[storageKey] ?? source.value }, // latest or fallback
|
|
568
|
+
set(_) { console.warn('@observe: read-only') },
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// Hook 2: per-INSTANCE subscription (runs at construction via addInitializer)
|
|
572
|
+
proto.constructor.addInitializer(host => {
|
|
573
|
+
let sub
|
|
574
|
+
host.addController({
|
|
575
|
+
hostConnected() { sub = source.$.subscribe(v => { host[storageKey] = v; host.requestUpdate() }) },
|
|
576
|
+
hostDisconnected() { sub?.unsubscribe() },
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`ReactiveController` is Lit's **Template Method** pattern: the framework defines *when* (`hostConnected`, `hostDisconnected`); the controller fills in the *what*.
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## Pattern 09 — Microtask Debounce Gate
|
|
588
|
+
|
|
589
|
+
**Named pattern:** Debounce · Event coalescing
|
|
590
|
+
|
|
591
|
+
> *"Many writes in one synchronous task collapse to one storage flush — the gate only lets the last one through."*
|
|
592
|
+
|
|
593
|
+
**Mental model:** A postal worker who collects letters all day but makes one trip to the post office at day's end. Ten letters in the outbox → one trip carries all ten. The debounce works at JS-microtask granularity: many `set()`/`update()` calls in the same synchronous task collapse to one `adapter.save()`, which always carries the *latest* value.
|
|
594
|
+
|
|
595
|
+
```
|
|
596
|
+
cart.set({ total: 10 }) → signal updated immediately ✓
|
|
597
|
+
cart.set({ total: 20 }) → signal updated immediately ✓
|
|
598
|
+
cart.set({ total: 30 }) → signal updated immediately ✓
|
|
599
|
+
|
|
600
|
+
scheduleWrite():
|
|
601
|
+
if (scheduledWrite) return ← 2nd and 3rd calls exit here
|
|
602
|
+
scheduledWrite = true
|
|
603
|
+
queueMicrotask(() => {
|
|
604
|
+
scheduledWrite = false
|
|
605
|
+
adapter.save(signal.get()) ← reads 30, the latest value
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
Result: one adapter.save(30) — not three.
|
|
609
|
+
|
|
610
|
+
Timeline:
|
|
611
|
+
sync task │ set(10) set(20) set(30) │microtask│ save(30) │
|
|
612
|
+
│ signal always current │boundary │ one I/O │
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**Two microtask drains on dispose** (to guarantee the latest value is flushed):
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
async flushAndClose() {
|
|
619
|
+
if (internal.pendingWrite) await internal.pendingWrite // ① in-flight save
|
|
620
|
+
await new Promise(resolve => queueMicrotask(resolve)) // ② let scheduled task run
|
|
621
|
+
if (internal.pendingWrite) await internal.pendingWrite // ③ catch write from ②
|
|
622
|
+
if (adapter.close) await adapter.close() // ④ IDB close
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## Pattern 10 — RAII Lifecycle
|
|
629
|
+
|
|
630
|
+
**Named pattern:** RAII · Symbol.dispose · TC39 Explicit Resource Management
|
|
631
|
+
|
|
632
|
+
> *"Acquire the resource when you create the object; release it automatically when the object goes out of scope — even if an exception is thrown."*
|
|
633
|
+
|
|
634
|
+
**Mental model:** C++ RAII, now in JavaScript via TC39. `using x = acquireResource()` guarantees `x[Symbol.dispose]()` at block exit — even on thrown exceptions. No `try/finally`, no `afterEach(() => cleanup())`.
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
// WITHOUT RAII — easy to forget cleanup
|
|
638
|
+
it('test', () => {
|
|
639
|
+
const cart = state('test/cart').memory({}) // plain const
|
|
640
|
+
cart.set({ total: 10 })
|
|
641
|
+
expect(cart.value.total).toBe(10)
|
|
642
|
+
// forgot to call cart.destroy()
|
|
643
|
+
// namespace 'test/cart' is permanently claimed
|
|
644
|
+
// next test: state('test/cart') throws or returns stale instance
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
// WITH RAII — cleanup is structural
|
|
648
|
+
it('test', () => {
|
|
649
|
+
using cart = state('test/cart').memory({})
|
|
650
|
+
cart.set({ total: 10 })
|
|
651
|
+
expect(cart.value.total).toBe(10)
|
|
652
|
+
// [Symbol.dispose]() called automatically here — even if expect() threw
|
|
653
|
+
}) // namespace released, next test starts clean
|
|
654
|
+
|
|
655
|
+
// IDB — async variant
|
|
656
|
+
it('test', async () => {
|
|
657
|
+
await using cart = state('test/cart').idb({})
|
|
658
|
+
// [Symbol.asyncDispose]() awaited on exit — IDB fully closed before next test
|
|
659
|
+
})
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**What dispose does:**
|
|
663
|
+
|
|
664
|
+
```ts
|
|
665
|
+
[Symbol.dispose]():
|
|
666
|
+
if (disposed) return // idempotent
|
|
667
|
+
disposed = true
|
|
668
|
+
void flushAndClose() // fire-and-forget (sync backends)
|
|
669
|
+
claimed.delete(namespace) // releases namespace → re-registrable
|
|
670
|
+
|
|
671
|
+
[Symbol.asyncDispose]() (IDB only):
|
|
672
|
+
if (disposed) return
|
|
673
|
+
disposed = true
|
|
674
|
+
await flushAndClose() // fully awaited
|
|
675
|
+
claimed.delete(namespace)
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
**Module-scope footgun:** `using` at module scope disposes when the module record is GC'd — effectively never. Module-scope state uses plain `const`.
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## Pattern 11 — Flyweight / Global Registry
|
|
683
|
+
|
|
684
|
+
**Named pattern:** Flyweight · Global registry · Module Federation
|
|
685
|
+
|
|
686
|
+
> *"Share one instance across all importers — even across separate bundle copies — by parking it in a key-value store keyed off a process-global Symbol."*
|
|
687
|
+
|
|
688
|
+
**Mental model:** The Flyweight pattern avoids creating many copies of the same object by sharing one canonical instance. In a browser app with Module Federation, two separate bundles (host and remote) each import `schmancy/state`. Without coordination, each bundle creates its own `claimed` Set and `instances` Map. They produce different `cart` objects — writing through Bundle A doesn't update Bundle B's signal. Fix: park everything on `globalThis` under `Symbol.for()` keys so all bundles share the same store.
|
|
689
|
+
|
|
690
|
+
```
|
|
691
|
+
// WITHOUT globalThis registry (broken)
|
|
692
|
+
Bundle A: claimed: Set, instances: Map, cart₁ (signal A)
|
|
693
|
+
Bundle B: claimed: Set, instances: Map, cart₂ (signal B) ← different!
|
|
694
|
+
cart₁.set({x:1}) → cart₂.value === {} ← B never saw the write
|
|
695
|
+
|
|
696
|
+
// WITH globalThis registry (correct)
|
|
697
|
+
globalThis[Symbol.for('schmancy.state.claimed')] = Set<string>
|
|
698
|
+
globalThis[Symbol.for('schmancy.state.instances')] = Map<'ns@storage', instance>
|
|
699
|
+
globalThis[Symbol.for('schmancy.state.hostResolverCache')]= WeakMap
|
|
700
|
+
globalThis[Symbol.for('schmancy.state.activeHost.stack')] = Array
|
|
701
|
+
globalThis[Symbol.for('schmancy.state.activeHost.eventHost')] = slot
|
|
702
|
+
|
|
703
|
+
Bundle A loads first → creates all five structures
|
|
704
|
+
Bundle B loads later → finds existing structures, reuses them
|
|
705
|
+
cart₁.set({x:1}) → cart₂.value === {x:1} ✓
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**The idempotent make pattern:**
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
// ??= operator: "assign only if not already set"
|
|
712
|
+
__claimedSlot[CLAIMED_KEY] ??= new Set<string>()
|
|
713
|
+
|
|
714
|
+
// makeHandle: create-or-return
|
|
715
|
+
const ensure = (storage) => (initial) => {
|
|
716
|
+
const key = `${namespace}@${storage}`
|
|
717
|
+
const cached = instances.get(key)
|
|
718
|
+
if (cached !== undefined) return cached // second caller gets same instance
|
|
719
|
+
const instance = createInstance({ namespace, initial, storage })
|
|
720
|
+
instances.set(key, instance)
|
|
721
|
+
return instance
|
|
722
|
+
}
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
---
|
|
726
|
+
|
|
727
|
+
## Pattern 12 — How the Patterns Assemble
|
|
728
|
+
|
|
729
|
+
Each pattern solves a specific problem. They compose so each layer's output is the next layer's input.
|
|
730
|
+
|
|
731
|
+
```
|
|
732
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
733
|
+
│ Consumer layer (what you write) │
|
|
734
|
+
│ cart.value / cart.set() / cart.$ / await cart.ready │
|
|
735
|
+
│ @observe(cart) / bindState(this, cart) / computed(...) │
|
|
736
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
737
|
+
│ Pattern 08 Prototype Decoration │
|
|
738
|
+
│ @observe and bindState attach ReactiveControllers to Lit hosts │
|
|
739
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
740
|
+
│ Pattern 02 Transparent Proxy │
|
|
741
|
+
│ Every read/write on the global routes through resolveContextual │
|
|
742
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
743
|
+
│ Pattern 06 Zone / Execution Context │
|
|
744
|
+
│ resolveActiveHost() answers "who is calling right now?" │
|
|
745
|
+
│ stack / Promise.then patch / event-host slot / activeElement │
|
|
746
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
747
|
+
│ Pattern 07 Provider / Consumer (Context Protocol) │
|
|
748
|
+
│ ContextRequestEvent dispatched from host — nearest wins │
|
|
749
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
750
|
+
│ Pattern 01 Module-Scoped Singleton (the fallback) │
|
|
751
|
+
│ If no context provider found — use the module-scoped global │
|
|
752
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
753
|
+
│ Pattern 04 Dual Observer (Signal + Observable) │
|
|
754
|
+
│ Signal for pull (Lit render), Observable for push (RxJS) │
|
|
755
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
756
|
+
│ Pattern 05 Type-Driven Command Dispatch │
|
|
757
|
+
│ Kind<T> routes to MapAPI / SetAPI / ArrayAPI / ObjectAPI │
|
|
758
|
+
│ All commits go through one commit(next) function │
|
|
759
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
760
|
+
│ Pattern 09 Microtask Debounce Gate │
|
|
761
|
+
│ Many writes collapse to one adapter.save() per microtask │
|
|
762
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
763
|
+
│ Pattern 03 Adapter / Storage Port │
|
|
764
|
+
│ Memory / WebStorage / IndexedDB behind uniform load/save/clear │
|
|
765
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
766
|
+
│ Pattern 10 RAII Lifecycle │
|
|
767
|
+
│ Symbol.dispose / Symbol.asyncDispose — flush + release + close │
|
|
768
|
+
├──────────────────────────────────────────────────────────────────┤
|
|
769
|
+
│ Pattern 11 Flyweight / Global Registry │
|
|
770
|
+
│ globalThis[Symbol.for(...)] shares instances across bundles │
|
|
771
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
| Pattern | Problem it solves | Alternative rejected |
|
|
775
|
+
|---|---|---|
|
|
776
|
+
| 01 Singleton | State survives component remounts | Class-instance state, Redux |
|
|
777
|
+
| 02 Transparent Proxy | Consumer code unchanged whether scoped or global | Props, different variable name per scope |
|
|
778
|
+
| 03 Adapter | Swap storage backend without touching state logic | Hardcoded localStorage calls |
|
|
779
|
+
| 04 Dual Observer | Both Lit (pull) and RxJS (push) need reactivity | RxJS-only, signals-only |
|
|
780
|
+
| 05 Command Dispatch | Ergonomic writes per shape, no type casting | One generic `setState()` |
|
|
781
|
+
| 06 Zone | Know which DOM element is executing, across async | Pass host as parameter, Zone.js (~50 KB) |
|
|
782
|
+
| 07 Provider/Consumer | Subtree-scoped state, no consumer code changes | React context, custom event protocol |
|
|
783
|
+
| 08 Prototype Decoration | State bound to field with lifecycle guarantees | Manual subscribe/unsubscribe per component |
|
|
784
|
+
| 09 Debounce Gate | Burst writes → one I/O call | Write on every set(), batch manually |
|
|
785
|
+
| 10 RAII | Guaranteed cleanup even on test failure | afterEach(), try/finally |
|
|
786
|
+
| 11 Flyweight | One instance across bundle copies | Module-level singleton (breaks MF) |
|
|
787
|
+
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
# Part 3 — Technical Reference
|
|
791
|
+
|
|
792
|
+
## File map
|
|
793
|
+
|
|
794
|
+
```
|
|
795
|
+
packages/schmancy/src/state/
|
|
796
|
+
index.ts — factory, types, write APIs, context resolution,
|
|
797
|
+
observe decorator, bindState, stateFromObservable, effect
|
|
798
|
+
persist.ts — StorageAdapter interface + four implementations
|
|
799
|
+
active-host.ts — AsyncContext polyfill (stack + Promise.then patch + event-host slot)
|
|
800
|
+
schmancy-context.ts — <schmancy-context> element (scoping primitive)
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
## Factory — three TypeScript overloads
|
|
804
|
+
|
|
805
|
+
```ts
|
|
806
|
+
// Overload A — registry augmentation (typo-safe, autocomplete)
|
|
807
|
+
declare module '@mhmo91/schmancy/state' {
|
|
808
|
+
interface SchmancyStateRegistry { 'hannah/cart': CartState }
|
|
809
|
+
}
|
|
810
|
+
const cart = state('hannah/cart').session({ items: [], total: 0 })
|
|
811
|
+
|
|
812
|
+
// Overload B — explicit T arg (inline literals)
|
|
813
|
+
const cart = state<CartState>('hannah/cart').session({ items: [], total: 0 })
|
|
814
|
+
|
|
815
|
+
// Overload C — typed const (T inferred, no cast needed)
|
|
816
|
+
const initial: CartState = { items: [], total: 0 }
|
|
817
|
+
const cart = state('hannah/cart').session(initial)
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
All return a `NamespaceHandle` with four backend methods. Each returns `State<NS, T, StorageBackend>` composed of `BaseAPI + WriteAPI<T>`.
|
|
821
|
+
|
|
822
|
+
**Namespace constraint:** `FeatureNamespace = \`${string}/${string}\`` — enforced at compile time and runtime.
|
|
823
|
+
|
|
824
|
+
## Instance construction (`createInstance`)
|
|
825
|
+
|
|
826
|
+
```
|
|
827
|
+
createInstance({ namespace, initial, storage }, { isolated? })
|
|
828
|
+
│
|
|
829
|
+
├─ createAdapter(storage, namespace) → StorageAdapter
|
|
830
|
+
├─ new Signal.State<T>(initial) → TC39 signal
|
|
831
|
+
├─ adapter.load() → signal.set(stored) if not null
|
|
832
|
+
│ .then(markLoaded) → loaded = true; ready resolves
|
|
833
|
+
├─ signalToObservable(signal) → Observable via Signal.subtle.Watcher
|
|
834
|
+
└─ buildWriteApi(internal, detectKind(initial))→ variant write methods
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Global instances route all reads/writes through `resolveContextual`. Isolated instances (`{ isolated: true }`) read/write their own signal directly — no context resolution, no recursion.
|
|
838
|
+
|
|
839
|
+
## Storage backends
|
|
840
|
+
|
|
841
|
+
| Method | Backing | Survives refresh | Survives close |
|
|
842
|
+
|---|---|---|---|
|
|
843
|
+
| `.memory(initial)` | JS heap | ❌ | ❌ |
|
|
844
|
+
| `.session(initial)` | `sessionStorage` | ✅ | ❌ (per-tab) |
|
|
845
|
+
| `.local(initial)` | `localStorage` | ✅ | ✅ |
|
|
846
|
+
| `.idb(initial)` | IndexedDB | ✅ | ✅ |
|
|
847
|
+
|
|
848
|
+
IDB additionally implements `AsyncDisposable` (`[Symbol.asyncDispose]`).
|
|
849
|
+
|
|
850
|
+
## Context resolution (`resolveContextual`)
|
|
851
|
+
|
|
852
|
+
```
|
|
853
|
+
resolveContextual(namespace, fallback):
|
|
854
|
+
1. host = resolveActiveHost() — may return undefined
|
|
855
|
+
2. if undefined → return fallback — module-scoped global
|
|
856
|
+
3. check hostResolverCache[host][namespace]
|
|
857
|
+
→ cache hit → return immediately (O(1))
|
|
858
|
+
4. dispatch ContextRequestEvent from host
|
|
859
|
+
→ provider responds → resolved = isolated copy
|
|
860
|
+
→ no response → resolved = fallback
|
|
861
|
+
5. cache result in hostResolverCache[host][namespace]
|
|
862
|
+
6. return resolved
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
`hostResolverCache` is a `WeakMap<HTMLElement, Map<string, unknown>>` on `globalThis`.
|
|
866
|
+
|
|
867
|
+
## Active-host resolution chain
|
|
868
|
+
|
|
869
|
+
```
|
|
870
|
+
resolveActiveHost():
|
|
871
|
+
1. _activeHost.get() ← explicit stack (SchmancyElement prototype-wrap)
|
|
872
|
+
2. _eventHostSlot.host ← capture-phase event on <schmancy-context>
|
|
873
|
+
3. document.activeElement ← keyboard / focus
|
|
874
|
+
4. undefined ← caller uses module-scoped global
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
## Component binding options
|
|
878
|
+
|
|
879
|
+
| Option | When to use | Mechanism |
|
|
880
|
+
|---|---|---|
|
|
881
|
+
| `cart.value` in `render()` | Default — 80% of cases | SignalWatcher auto-tracking |
|
|
882
|
+
| `@observe(cart) field!: T` | Need value as class field (handlers, devtools) | addInitializer + ReactiveController |
|
|
883
|
+
| `bindState(this, cart)` | Non-SchmancyElement Lit host | ReactiveController directly |
|
|
884
|
+
|
|
885
|
+
## `computed()`, `effect()`, `stateFromObservable()`
|
|
886
|
+
|
|
887
|
+
**`computed(fn)`** — re-export of `@lit-labs/signals/computed`. Reading `state.value` inside the callback auto-tracks it. Reference-equality dedup by default.
|
|
888
|
+
|
|
889
|
+
**`effect(fn)`** — runs `fn` immediately (registers deps), then re-runs (microtask-coalesced) whenever any read signal changes. Returns `Disposable`.
|
|
890
|
+
|
|
891
|
+
**`stateFromObservable(obs$, namespace, initial)`** — creates a state, subscribes to the observable, calls `signal.set(value)` directly on each emission (bypasses `resolveContextual`). Wraps `[Symbol.dispose]` to also unsubscribe.
|
|
892
|
+
|
|
893
|
+
## Lifecycle — disposal sequence
|
|
894
|
+
|
|
895
|
+
```
|
|
896
|
+
using cart = state('test/x').memory(initial)
|
|
897
|
+
│
|
|
898
|
+
└── [Symbol.dispose]() at block exit
|
|
899
|
+
if (disposed) return
|
|
900
|
+
disposed = true
|
|
901
|
+
void flushAndClose()
|
|
902
|
+
await pendingWrite
|
|
903
|
+
await queueMicrotask ← drain scheduled write
|
|
904
|
+
await pendingWrite ← catch write from microtask
|
|
905
|
+
await adapter.close() ← IDB only
|
|
906
|
+
claimed.delete(namespace) ← namespace re-registrable
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
## Key invariants
|
|
910
|
+
|
|
911
|
+
| Invariant | Where enforced |
|
|
912
|
+
|---|---|
|
|
913
|
+
| Namespace must contain `/` | TypeScript template literal + runtime throw |
|
|
914
|
+
| One global per namespace | `claimed` Set + `instances` Map on `globalThis` |
|
|
915
|
+
| Context dispatch is O(1) after first | `hostResolverCache` WeakMap |
|
|
916
|
+
| Isolated copy never calls `resolveContextual` | `{ isolated: true }` branch |
|
|
917
|
+
| Write persists async, never blocks signal | `scheduleWrite` → `queueMicrotask` |
|
|
918
|
+
| `Signal.State.set()` is synchronous | TC39 signal polyfill contract |
|
|
919
|
+
| Multiple bundle copies share one registry | All global state under `Symbol.for(...)` |
|
|
920
|
+
| No in-place mutation | Spread / `new Map()` / `new Set()` / immer `produce()` |
|
|
921
|
+
|
|
922
|
+
## Full data-flow diagram
|
|
923
|
+
|
|
924
|
+
```
|
|
925
|
+
User calls cart.set({ total: 12 })
|
|
926
|
+
│
|
|
927
|
+
▼
|
|
928
|
+
resolveContextual(namespace, isolatedTarget)
|
|
929
|
+
│
|
|
930
|
+
├─ resolveActiveHost()
|
|
931
|
+
│ ├─ 1. _activeHost stack (SchmancyElement prototype-wrap)
|
|
932
|
+
│ ├─ 2. _eventHostSlot (capture-phase event)
|
|
933
|
+
│ ├─ 3. document.activeElement
|
|
934
|
+
│ └─ 4. undefined → use isolatedTarget (module global)
|
|
935
|
+
│
|
|
936
|
+
├─ cache hit? → return cached target
|
|
937
|
+
│
|
|
938
|
+
└─ dispatch ContextRequestEvent from host
|
|
939
|
+
├─ <schmancy-context> responds → isolated copy
|
|
940
|
+
└─ no response → module-scoped global
|
|
941
|
+
│
|
|
942
|
+
▼
|
|
943
|
+
target.set({ total: 12 })
|
|
944
|
+
│
|
|
945
|
+
ObjectAPI.set(patch, merge=true)
|
|
946
|
+
commit({ ...current, ...patch })
|
|
947
|
+
│
|
|
948
|
+
┌─────────────┴──────────────────────┐
|
|
949
|
+
▼ ▼
|
|
950
|
+
signal.set(next) scheduleWrite(internal)
|
|
951
|
+
(synchronous) │
|
|
952
|
+
│ queueMicrotask
|
|
953
|
+
│ │
|
|
954
|
+
▼ ▼
|
|
955
|
+
Signal.subtle.Watcher fires adapter.save(signal.get())
|
|
956
|
+
│ (localStorage / IDB / …)
|
|
957
|
+
▼
|
|
958
|
+
signalToObservable: queueMicrotask → emit
|
|
959
|
+
│
|
|
960
|
+
┌──────┴────────────────────────┐
|
|
961
|
+
▼ ▼
|
|
962
|
+
SignalWatcher (Lit) RxJS subscribers
|
|
963
|
+
→ requestUpdate() → re-render pipe(takeUntil(disconnecting))
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
## Quick-decision guide
|
|
967
|
+
|
|
968
|
+
| I need… | Use |
|
|
969
|
+
|---|---|
|
|
970
|
+
| State in a Lit component template | `cart.value` in `render()` |
|
|
971
|
+
| State as a class field (event handlers) | `@observe(cart) cart!: CartState` |
|
|
972
|
+
| State in a non-SchmancyElement Lit host | `bindState(this, cart)` |
|
|
973
|
+
| Derived / computed value | `computed(() => cart.value.items.length)` |
|
|
974
|
+
| Side effect that reruns on change | `effect(() => { … cart.value … })` |
|
|
975
|
+
| Lift an Observable into state | `stateFromObservable(obs$, 'ns/key', initial)` |
|
|
976
|
+
| Isolated subtree copy | `<schmancy-context .provides=${[cart]}>` |
|
|
977
|
+
| Per-test isolation | `using cart = state('test/x').memory(initial)` |
|
|
978
|
+
| Persist across page refresh (same tab) | `.session(initial)` |
|
|
979
|
+
| Persist across tab close | `.local(initial)` or `.idb(initial)` |
|
|
980
|
+
| Large collections (>100 entries) | `.idb(initial)` |
|
|
981
|
+
| Wait for async hydration | `await cart.ready` / `if (cart.loaded)` |
|