snice 7.3.0 → 7.4.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 (209) hide show
  1. package/.agents/skills/snice/SKILL.md +12 -16
  2. package/README.md +5 -1
  3. package/adapters/react/SniceProvider.js +1 -1
  4. package/adapters/react/SniceRouter.js +1 -1
  5. package/adapters/react/matchRoute.js +1 -1
  6. package/adapters/react/useRequestHandler.js +1 -1
  7. package/bin/analyzer-contracts.js +3 -1
  8. package/bin/project-analyzer.js +219 -6
  9. package/bin/snice.js +77 -13
  10. package/dist/app-context.d.ts +10 -10
  11. package/dist/cdn/accordion/snice-accordion.js +1 -1
  12. package/dist/cdn/action-bar/snice-action-bar.js +1 -1
  13. package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
  14. package/dist/cdn/alert/snice-alert.js +1 -1
  15. package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
  16. package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
  17. package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
  18. package/dist/cdn/availability/snice-availability.js +1 -1
  19. package/dist/cdn/avatar/snice-avatar.js +1 -1
  20. package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
  21. package/dist/cdn/badge/snice-badge.js +1 -1
  22. package/dist/cdn/banner/snice-banner.js +1 -1
  23. package/dist/cdn/binpack/snice-binpack.js +1 -1
  24. package/dist/cdn/book/snice-book.js +1 -1
  25. package/dist/cdn/booking/snice-booking.js +1 -1
  26. package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
  27. package/dist/cdn/button/snice-button.js +1 -1
  28. package/dist/cdn/calendar/snice-calendar.js +1 -1
  29. package/dist/cdn/camera/snice-camera.js +1 -1
  30. package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
  31. package/dist/cdn/candlestick/snice-candlestick.js +1 -1
  32. package/dist/cdn/card/snice-card.js +1 -1
  33. package/dist/cdn/carousel/snice-carousel.js +1 -1
  34. package/dist/cdn/cart/snice-cart.js +1 -1
  35. package/dist/cdn/chart/snice-chart.js +1 -1
  36. package/dist/cdn/chat/snice-chat.js +1 -1
  37. package/dist/cdn/checkbox/snice-checkbox.js +1 -1
  38. package/dist/cdn/chip/snice-chip.js +1 -1
  39. package/dist/cdn/code-block/snice-code-block.js +1 -1
  40. package/dist/cdn/color-display/snice-color-display.js +1 -1
  41. package/dist/cdn/color-picker/snice-color-picker.js +1 -1
  42. package/dist/cdn/command-palette/snice-command-palette.js +1 -1
  43. package/dist/cdn/comments/snice-comments.js +1 -1
  44. package/dist/cdn/countdown/snice-countdown.js +1 -1
  45. package/dist/cdn/cropper/snice-cropper.js +1 -1
  46. package/dist/cdn/data-card/snice-data-card.js +1 -1
  47. package/dist/cdn/date-picker/snice-date-picker.js +1 -1
  48. package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
  49. package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
  50. package/dist/cdn/diff/snice-diff.js +1 -1
  51. package/dist/cdn/divider/snice-divider.js +1 -1
  52. package/dist/cdn/doc/snice-doc.js +1 -1
  53. package/dist/cdn/draw/snice-draw.js +1 -1
  54. package/dist/cdn/drawer/snice-drawer.js +1 -1
  55. package/dist/cdn/empty-state/snice-empty-state.js +1 -1
  56. package/dist/cdn/estimate/snice-estimate.js +1 -1
  57. package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
  58. package/dist/cdn/file-upload/snice-file-upload.js +1 -1
  59. package/dist/cdn/flip-card/snice-flip-card.js +1 -1
  60. package/dist/cdn/flow/snice-flow.js +1 -1
  61. package/dist/cdn/form-layout/snice-form-layout.js +1 -1
  62. package/dist/cdn/funnel/snice-funnel.js +1 -1
  63. package/dist/cdn/gantt/snice-gantt.js +1 -1
  64. package/dist/cdn/gauge/snice-gauge.js +1 -1
  65. package/dist/cdn/grid/snice-grid.js +1 -1
  66. package/dist/cdn/heatmap/snice-heatmap.js +1 -1
  67. package/dist/cdn/image/snice-image.js +1 -1
  68. package/dist/cdn/input/snice-input.js +1 -1
  69. package/dist/cdn/invoice/snice-invoice.js +1 -1
  70. package/dist/cdn/kanban/snice-kanban.js +1 -1
  71. package/dist/cdn/key-value/snice-key-value.js +1 -1
  72. package/dist/cdn/kpi/snice-kpi.js +1 -1
  73. package/dist/cdn/layout/snice-layout.js +1 -1
  74. package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
  75. package/dist/cdn/link/snice-link.js +1 -1
  76. package/dist/cdn/link-preview/snice-link-preview.js +1 -1
  77. package/dist/cdn/list/snice-list.js +1 -1
  78. package/dist/cdn/location/snice-location.js +1 -1
  79. package/dist/cdn/login/snice-login.js +1 -1
  80. package/dist/cdn/map/snice-map.js +1 -1
  81. package/dist/cdn/markdown/snice-markdown.js +1 -1
  82. package/dist/cdn/masonry/snice-masonry.js +1 -1
  83. package/dist/cdn/menu/snice-menu.js +1 -1
  84. package/dist/cdn/message-strip/snice-message-strip.js +1 -1
  85. package/dist/cdn/modal/snice-modal.js +1 -1
  86. package/dist/cdn/music-player/snice-music-player.js +1 -1
  87. package/dist/cdn/nav/snice-nav.js +1 -1
  88. package/dist/cdn/network-graph/snice-network-graph.js +1 -1
  89. package/dist/cdn/notification-center/snice-notification-center.js +1 -1
  90. package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
  91. package/dist/cdn/org-chart/snice-org-chart.js +1 -1
  92. package/dist/cdn/pagination/snice-pagination.js +1 -1
  93. package/dist/cdn/paint/snice-paint.js +1 -1
  94. package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
  95. package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
  96. package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
  97. package/dist/cdn/popover/snice-popover.js +1 -1
  98. package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
  99. package/dist/cdn/product-card/snice-product-card.js +1 -1
  100. package/dist/cdn/progress/snice-progress.js +1 -1
  101. package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
  102. package/dist/cdn/qr-code/snice-qr-code.js +1 -1
  103. package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
  104. package/dist/cdn/radio/snice-radio.js +1 -1
  105. package/dist/cdn/range-slider/snice-range-slider.js +1 -1
  106. package/dist/cdn/rating/snice-rating.js +1 -1
  107. package/dist/cdn/receipt/snice-receipt.js +1 -1
  108. package/dist/cdn/recipe/snice-recipe.js +1 -1
  109. package/dist/cdn/runtime/README.md +2 -2
  110. package/dist/cdn/runtime/snice-runtime.esm.js +309 -188
  111. package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
  112. package/dist/cdn/runtime/snice-runtime.esm.min.js +1 -1
  113. package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
  114. package/dist/cdn/runtime/snice-runtime.js +309 -187
  115. package/dist/cdn/runtime/snice-runtime.js.map +1 -1
  116. package/dist/cdn/runtime/snice-runtime.min.js +1 -1
  117. package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
  118. package/dist/cdn/sankey/snice-sankey.js +1 -1
  119. package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
  120. package/dist/cdn/select/snice-select.js +1 -1
  121. package/dist/cdn/skeleton/snice-skeleton.js +1 -1
  122. package/dist/cdn/slider/snice-slider.js +1 -1
  123. package/dist/cdn/sortable/snice-sortable.js +1 -1
  124. package/dist/cdn/sparkline/snice-sparkline.js +1 -1
  125. package/dist/cdn/spinner/snice-spinner.js +1 -1
  126. package/dist/cdn/split-button/snice-split-button.js +1 -1
  127. package/dist/cdn/split-pane/snice-split-pane.js +1 -1
  128. package/dist/cdn/spotlight/snice-spotlight.js +1 -1
  129. package/dist/cdn/stat-group/snice-stat-group.js +1 -1
  130. package/dist/cdn/step-input/snice-step-input.js +1 -1
  131. package/dist/cdn/stepper/snice-stepper.js +1 -1
  132. package/dist/cdn/switch/snice-switch.js +1 -1
  133. package/dist/cdn/table/snice-table.esm.js +1 -1
  134. package/dist/cdn/table/snice-table.js +1 -1
  135. package/dist/cdn/tabs/snice-tabs.js +1 -1
  136. package/dist/cdn/tag/snice-tag.js +1 -1
  137. package/dist/cdn/tag-input/snice-tag-input.js +1 -1
  138. package/dist/cdn/terminal/snice-terminal.js +1 -1
  139. package/dist/cdn/testimonial/snice-testimonial.js +1 -1
  140. package/dist/cdn/textarea/snice-textarea.js +1 -1
  141. package/dist/cdn/time-picker/snice-time-picker.js +1 -1
  142. package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
  143. package/dist/cdn/timeline/snice-timeline.js +1 -1
  144. package/dist/cdn/timer/snice-timer.js +1 -1
  145. package/dist/cdn/toast/snice-toast.js +1 -1
  146. package/dist/cdn/tooltip/snice-tooltip.js +1 -1
  147. package/dist/cdn/tree/snice-tree.js +1 -1
  148. package/dist/cdn/treemap/snice-treemap.js +1 -1
  149. package/dist/cdn/user-card/snice-user-card.js +1 -1
  150. package/dist/cdn/video-player/snice-video-player.js +1 -1
  151. package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
  152. package/dist/cdn/waterfall/snice-waterfall.js +1 -1
  153. package/dist/cdn/weather/snice-weather.js +1 -1
  154. package/dist/cdn/work-order/snice-work-order.js +1 -1
  155. package/dist/components/pagination/snice-pagination.types.d.ts +5 -0
  156. package/dist/context-provider.d.ts +11 -1
  157. package/dist/context.d.ts +5 -6
  158. package/dist/index.cjs +306 -184
  159. package/dist/index.cjs.map +1 -1
  160. package/dist/index.d.ts +2 -1
  161. package/dist/index.esm.js +306 -185
  162. package/dist/index.esm.js.map +1 -1
  163. package/dist/index.iife.js +306 -184
  164. package/dist/index.iife.js.map +1 -1
  165. package/dist/react/SniceProvider.js +1 -1
  166. package/dist/react/SniceRouter.js +1 -1
  167. package/dist/react/index.js +1 -1
  168. package/dist/react/matchRoute.js +1 -1
  169. package/dist/react/useRequestHandler.js +1 -1
  170. package/dist/symbols.cjs +1 -1
  171. package/dist/symbols.esm.js +1 -1
  172. package/dist/testing-dom.cjs +48 -0
  173. package/dist/testing-dom.cjs.map +1 -0
  174. package/dist/testing-dom.d.ts +23 -0
  175. package/dist/testing-dom.esm.js +45 -0
  176. package/dist/testing-dom.esm.js.map +1 -0
  177. package/dist/transitions.cjs +1 -1
  178. package/dist/transitions.esm.js +1 -1
  179. package/dist/types/context.d.ts +8 -8
  180. package/dist/utils.d.ts +3 -1
  181. package/docs/ai/api.md +12 -1
  182. package/docs/ai/architecture.md +7 -3
  183. package/docs/ai/bindings.md +1 -1
  184. package/docs/ai/cli.md +22 -0
  185. package/docs/ai/components/badge.md +4 -0
  186. package/docs/ai/components/pagination.md +4 -2
  187. package/docs/ai/components/segmented-control.md +5 -1
  188. package/docs/ai/controllers.md +30 -11
  189. package/docs/ai/decorators.md +2 -2
  190. package/docs/ai/events.md +8 -2
  191. package/docs/ai/fetcher.md +32 -1
  192. package/docs/ai/lifecycle.md +4 -1
  193. package/docs/ai/patterns.md +9 -4
  194. package/docs/ai/request-response.md +20 -6
  195. package/docs/ai/routing.md +8 -1
  196. package/docs/ai/testing.md +71 -0
  197. package/docs/bindings.md +1 -1
  198. package/docs/cli.md +23 -0
  199. package/docs/components/badge.md +4 -0
  200. package/docs/components/pagination.md +8 -5
  201. package/docs/components/segmented-control.md +5 -1
  202. package/docs/controllers.md +30 -11
  203. package/docs/events.md +11 -2
  204. package/docs/fetcher.md +32 -1
  205. package/docs/lifecycle.md +5 -1
  206. package/docs/request-response.md +25 -7
  207. package/docs/routing.md +10 -1
  208. package/docs/testing.md +71 -0
  209. package/package.json +6 -1
@@ -57,17 +57,21 @@ returns a promise for `T`.
57
57
  @controller('processing-controller')
58
58
  class ProcessingController implements IController {
59
59
  element: HTMLElement | null = null;
60
+ private ctx!: Context;
60
61
  async attach() {}
61
62
  async detach() {}
62
63
 
64
+ @context()
65
+ receiveContext(ctx: Context) { this.ctx = ctx; }
66
+
63
67
  @respond('search', { debounce: 300 })
64
68
  async handleSearch(query: { term: string }) {
65
- return await fetch(`/api/search?q=${encodeURIComponent(query.term)}`).then(r => r.json());
69
+ return await this.ctx.fetch(`/api/search?q=${encodeURIComponent(query.term)}`).then(r => r.json());
66
70
  }
67
71
 
68
72
  @respond('analytics', { throttle: 1000 })
69
73
  async handleAnalytics(event: any) {
70
- return await fetch('/api/track', { method: 'POST', body: JSON.stringify(event) });
74
+ return await this.ctx.fetch('/api/track', { method: 'POST', body: JSON.stringify(event) });
71
75
  }
72
76
  }
73
77
  ```
@@ -117,17 +121,23 @@ How it works:
117
121
  @controller('product-controller')
118
122
  class ProductController implements IController {
119
123
  element: HTMLElement | null = null;
124
+ private ctx!: Context;
120
125
  async attach() {}
121
126
  async detach() {}
122
127
 
128
+ @context()
129
+ receiveContext(ctx: Context) { this.ctx = ctx; }
130
+
123
131
  @respond('fetch-product')
124
132
  async handleFetchProduct(request: { id: string }) {
125
- return await fetch(`/api/products/${request.id}`).then(r => r.json());
133
+ return await this.ctx.fetch(`/api/products/${request.id}`).then(r => r.json());
126
134
  }
127
135
  }
128
136
  ```
129
137
 
130
- Elements never call `fetch()` or manage data directly — they yield requests upward and render whatever comes back. Controllers own the data layer.
138
+ Elements stay visual and yield requests upward. A controller owns application
139
+ behavior specific to the elements it controls; the page still owns element
140
+ orchestration.
131
141
 
132
142
  ## Options
133
143
 
@@ -175,7 +185,7 @@ async *loadData(): Response<void> {
175
185
  @respond('load-data')
176
186
  async handleLoadData(request: { id: string }) {
177
187
  if (!request.id) throw new Error('ID is required');
178
- const response = await fetch(`/api/data/${request.id}`);
188
+ const response = await this.ctx.fetch(`/api/data/${request.id}`);
179
189
  if (!response.ok) throw new Error(`API error: ${response.status}`);
180
190
  return await response.json();
181
191
  }
@@ -189,18 +199,22 @@ async handleLoadData(request: { id: string }) {
189
199
  @controller('cached-controller')
190
200
  class CachedController implements IController {
191
201
  element: HTMLElement | null = null;
202
+ private ctx!: Context;
192
203
  private cache = new Map<string, { data: any; timestamp: number }>();
193
204
  private ttl = 60000; // 1 minute
194
205
  async attach() {}
195
206
  async detach() {}
196
207
 
208
+ @context()
209
+ receiveContext(ctx: Context) { this.ctx = ctx; }
210
+
197
211
  @respond('fetch-cached')
198
212
  async handleFetch(request: { key: string; forceRefresh?: boolean }) {
199
213
  const cached = this.cache.get(request.key);
200
214
  if (!request.forceRefresh && cached && Date.now() - cached.timestamp < this.ttl) {
201
215
  return { data: cached.data, fromCache: true };
202
216
  }
203
- const data = await fetch(`/api/${request.key}`).then(r => r.json());
217
+ const data = await this.ctx.fetch(`/api/${request.key}`).then(r => r.json());
204
218
  this.cache.set(request.key, { data, timestamp: Date.now() });
205
219
  return { data, fromCache: false };
206
220
  }
@@ -58,6 +58,12 @@ Router({
58
58
  });
59
59
  ```
60
60
 
61
+ The same boundary supplies the full Router `Context` to `@context()` methods on
62
+ descendant elements and attached controllers. They use `ctx.fetch` with the
63
+ configured request/response middleware without importing the router or adding
64
+ a reserved field to the application context. `getContextFetch()` is the
65
+ lower-level transport-only lookup for explicit non-router providers.
66
+
61
67
  ### Module structure (avoids circular imports)
62
68
 
63
69
  ```typescript
@@ -88,7 +94,8 @@ class HomePage extends HTMLElement {
88
94
 
89
95
  ### `@context()` — method decorator
90
96
 
91
- Receives context updates from the router; called on navigation and on `ctx.update()`.
97
+ Receives context updates from the router on pages, descendant elements, and
98
+ attached controllers; called on navigation and on `ctx.update()`.
92
99
 
93
100
  ```typescript
94
101
  @context(options?: { debounce?: number; throttle?: number; once?: boolean })
@@ -42,6 +42,27 @@ Gotcha: awaiting `ready` on an element that never connects hangs — append it t
42
42
  - `ready` rejects with the first thrown/rejected `@ready()` failure; always await it.
43
43
  - jsdom's partial `ElementInternals` is supported; form controls keep native-input/proxy fallback behavior when `setFormValue`/`setValidity` are absent.
44
44
 
45
+ ## Partial DOM compatibility
46
+
47
+ In a simulated DOM, opt into Snice's standards compatibility layer from the
48
+ test setup file:
49
+
50
+ ```typescript
51
+ import 'snice/testing/dom';
52
+ ```
53
+
54
+ It capability-tests the current DOM and fills only missing IDL behavior. For
55
+ example, jsdom and some happy-dom versions omit the browser's reflective
56
+ `HTMLElement.autofocus` property for generic custom-element hosts; the adapter
57
+ adds that property without replacing implementations already supplied by the
58
+ runner. Application tests can then use `element.autofocus = true` exactly as
59
+ browser code does.
60
+
61
+ This adapter does not simulate layout, paint, or real focus navigation. Keep a
62
+ browser-test lane for behavior that depends on those capabilities. For a DOM
63
+ created after module evaluation, call `installDOMTestingCompatibility(scope)`
64
+ from the same module explicitly.
65
+
45
66
  ## Reading the DOM
46
67
 
47
68
  ```typescript
@@ -80,6 +101,56 @@ await attachController(el, UserController);
80
101
 
81
102
  To test an element in isolation, swap in a fixture controller that responds on the same channel the production controller uses; the element itself never changes. See request-response.md, controllers.md.
82
103
 
104
+ If the controller calls `getContext(this)`, provide its host (or an ancestor)
105
+ with the same application-context shape before attaching it. Release both
106
+ resources after the assertion:
107
+
108
+ ```typescript
109
+ import { attachController, detachController, provideContext } from 'snice';
110
+
111
+ const host = document.createElement('user-list');
112
+ document.body.append(host);
113
+ const releaseContext = provideContext(host, {
114
+ api: { listUsers: async () => [{ id: 'u1' }] }
115
+ }, {
116
+ fetch: async () => new Response(JSON.stringify([{ id: 'u1' }]))
117
+ });
118
+
119
+ try {
120
+ await attachController(host, UserController);
121
+ // assert controller effects
122
+ } finally {
123
+ await detachController(host);
124
+ releaseContext();
125
+ host.remove();
126
+ }
127
+ ```
128
+
129
+ Assigning a value to the host's own `@context` field is not a provider;
130
+ `getContext()` resolves the nearest `provideContext()` boundary.
131
+ When production code uses `getContextFetch()`, pass the test fetch function in
132
+ the same provider's third argument as shown above; `Router` supplies its
133
+ middleware-bound fetch function there in an application.
134
+
135
+ If the controller uses `@context()`, test it beneath a real `Router` target.
136
+ `provideContext()` intentionally provides application state and transport, not
137
+ navigation notifications; using `Router` exercises the same registration,
138
+ initial catch-up, middleware-aware `ctx.fetch`, updates, and detach cleanup as
139
+ production:
140
+
141
+ ```typescript
142
+ const router = Router({ target: '#fixture', context: { accountId: 'a1' }, fetcher });
143
+
144
+ @router.page({ tag: 'fixture-page', routes: ['/fixture'] })
145
+ class FixturePage extends HTMLElement {}
146
+
147
+ router.initialize();
148
+ await router.navigate('/fixture');
149
+ const host = document.createElement('user-list');
150
+ document.querySelector('fixture-page')!.append(host);
151
+ await attachController(host, UserController);
152
+ ```
153
+
83
154
  ## Validating source
84
155
 
85
156
  ```bash
package/docs/bindings.md CHANGED
@@ -128,7 +128,7 @@ Property bindings preserve type and identity. They are the correct channel for a
128
128
 
129
129
  ### Live property values
130
130
 
131
- Native controls can mutate their own properties after Snice writes them. Normally, if the bound value has not changed, a later render leaves that user-edited DOM value alone. Wrap a property value in `live()` when the component state must be reasserted even if the bound value itself is unchanged:
131
+ Native controls and stateful custom elements can mutate their own properties after Snice writes them. Normally, if the bound value has not changed, a later render leaves that user- or element-edited DOM value alone. Wrap a property value in `live()` when the owner state must be reasserted even if the bound value itself is unchanged:
132
132
 
133
133
  ```typescript
134
134
  html`<input .value=${live(this.canonicalValue)}>`;
package/docs/cli.md CHANGED
@@ -62,6 +62,29 @@ wherever the project convention places it.
62
62
 
63
63
  Both accept `--json` for CI.
64
64
 
65
+ ### Diagnostic codes and `.sniceignore`
66
+
67
+ Every error, warning, and suggestion has a stable code in human and JSON
68
+ output. When a project intentionally accepts a finding, create `.sniceignore`
69
+ in the project root. Prefer the narrowest entry:
70
+
71
+ ```text
72
+ # One exact diagnostic
73
+ snice/prefer-dispatch-decorator src/components/legacy-filter.ts:42:5
74
+
75
+ # Every instance in one file
76
+ snice/prefer-dispatch-decorator src/components/legacy-filter.ts
77
+
78
+ # Every instance in the project
79
+ snice/prefer-dispatch-decorator
80
+ ```
81
+
82
+ `code path:line` is also accepted. Paths are project-relative and entries are
83
+ exact rather than glob patterns. Suppressed diagnostics are omitted from both
84
+ text and JSON output and do not affect the exit status. The file applies to
85
+ `check`, `doctor`, and `validate`, including doctor codes such as
86
+ `snice-skill`.
87
+
65
88
  ## AI Setup
66
89
 
67
90
  ```bash
@@ -31,6 +31,10 @@ The badge component displays notification indicators, status markers, and counts
31
31
  | `showZero` (attr: `show-zero`) | `boolean` | `false` | Render a zero count instead of hiding the badge |
32
32
  | `offset` | `number` | `0` | Offset in pixels from default position |
33
33
 
34
+ The indicator is hidden when there is no dot, non-empty `content`, positive
35
+ `count`, or `showZero`. Slotted content remains available as the element the
36
+ badge would overlay.
37
+
34
38
  ## Methods
35
39
 
36
40
  | Method | Arguments | Description |
@@ -81,16 +81,19 @@ snice-pagination::part(button) {
81
81
 
82
82
  ```typescript
83
83
  import 'snice/components/pagination/snice-pagination';
84
+ import type { PaginationChangeDetail } from 'snice/components/pagination/snice-pagination.types';
84
85
  ```
85
86
 
86
87
  ```html
87
88
  <snice-pagination current="1" total="10"></snice-pagination>
89
+ ```
88
90
 
89
- <script type="module">
90
- document.querySelector('snice-pagination').addEventListener('pagination-change', (e) => {
91
- console.log('Page:', e.detail.page, 'Previous:', e.detail.previousPage);
92
- });
93
- </script>
91
+ ```typescript
92
+ const pagination = document.querySelector('snice-pagination')!;
93
+ pagination.addEventListener('pagination-change', (event) => {
94
+ const { detail } = event as CustomEvent<PaginationChangeDetail>;
95
+ console.log('Page:', detail.page, 'Previous:', detail.previousPage);
96
+ });
94
97
  ```
95
98
 
96
99
  ## Examples
@@ -17,7 +17,7 @@ A multi-option switcher with a sliding indicator. One option is selected at a ti
17
17
 
18
18
  | Property | Type | Default | Description |
19
19
  |----------|------|---------|-------------|
20
- | `value` | `string` | `''` | Selected option value |
20
+ | `value` | `string` | `''` | Selected option value; user selection updates it before `value-change` |
21
21
  | `options` | `SegmentedControlOption[]` | `[]` | Array of `{ value, label, icon?, disabled? }` (JS only) |
22
22
  | `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Control size |
23
23
  | `disabled` | `boolean` | `false` | Disables the entire control |
@@ -39,6 +39,10 @@ interface SegmentedControlOption {
39
39
  |-------|--------|-------------|
40
40
  | `value-change` | `{ value: string, previousValue: string, option: SegmentedControlOption, control }` | Selection changed |
41
41
 
42
+ `value` is self-mutating. If an owning template must reassert the same
43
+ controlled value after user selection, bind it as `.value=${live(value)}` so
44
+ the next owner render compares against the control's current property.
45
+
42
46
  ## CSS Parts
43
47
 
44
48
  | Part | Description |
@@ -119,13 +119,14 @@ interface IController<T extends HTMLElement = HTMLElement> {
119
119
 
120
120
  1. Controller instance is created
121
121
  2. `element` property is set
122
- 3. Router context is passed (if available)
122
+ 3. Router application context is passed (if available)
123
123
  4. Element's `ready` promise is awaited
124
124
  5. `attach()` method is called
125
- 6. Observers are set up
126
- 7. Channel/response handlers are set up
127
- 8. Event handlers are set up
128
- 9. `controller-attached` event is dispatched
125
+ 6. `@context` handlers are registered and caught up with the current Router context
126
+ 7. Observers are set up
127
+ 8. Channel/response handlers are set up
128
+ 9. Event handlers are set up
129
+ 10. `controller-attached` event is dispatched
129
130
 
130
131
  The step-4 wait has one safe exception: when an element calls
131
132
  `await attachController(this, ControllerClass)` from its own `@ready` handler,
@@ -143,8 +144,9 @@ good architecture; pages should orchestrate directly.
143
144
  3. Observers are cleaned up
144
145
  4. Channel/response handlers are cleaned up
145
146
  5. Event handlers are cleaned up
146
- 6. Controller scope is cleaned up
147
- 7. `controller-detached` event is dispatched
147
+ 6. `@context` handlers are cleaned up
148
+ 7. Controller scope is cleaned up
149
+ 8. `controller-detached` event is dispatched
148
150
 
149
151
  ### Example with Lifecycle Logging
150
152
 
@@ -240,7 +242,7 @@ class TableController implements IController<HTMLTableElement> {
240
242
 
241
243
  ## Resource Cleanup
242
244
 
243
- The framework auto-cleans `@on`, `@observe`, and `@respond` handlers. Clean up your own resources (WebSockets, timers, manual listeners) in `detach`:
245
+ The framework auto-cleans `@on`, `@observe`, `@respond`, and `@context` handlers. Clean up your own resources (WebSockets, timers, manual listeners) in `detach`:
244
246
 
245
247
  ```typescript
246
248
  import { controller, IController } from 'snice';
@@ -372,6 +374,8 @@ production controller should also prevent an older response from overwriting a
372
374
  newer one:
373
375
 
374
376
  ```typescript
377
+ import { context, type Context } from 'snice';
378
+
375
379
  interface Order { id: string; total: number }
376
380
 
377
381
  interface OrdersView extends HTMLElement {
@@ -384,12 +388,18 @@ interface OrdersView extends HTMLElement {
384
388
  @controller('orders-data')
385
389
  export class OrdersDataController implements IController<OrdersView> {
386
390
  element: OrdersView | null = null;
391
+ private ctx?: Context;
387
392
  private abortController?: AbortController;
388
393
  private requestVersion = 0;
389
394
 
390
- async attach(element: OrdersView) {
395
+ attach(element: OrdersView) {
391
396
  this.element = element;
392
- await this.reload();
397
+ }
398
+
399
+ @context()
400
+ receiveContext(ctx: Context) {
401
+ this.ctx = ctx;
402
+ void this.reload();
393
403
  }
394
404
 
395
405
  async detach() {
@@ -397,6 +407,7 @@ export class OrdersDataController implements IController<OrdersView> {
397
407
  this.requestVersion++;
398
408
  this.abortController?.abort();
399
409
  this.abortController = undefined;
410
+ this.ctx = undefined;
400
411
  }
401
412
 
402
413
  async reload() {
@@ -413,7 +424,9 @@ export class OrdersDataController implements IController<OrdersView> {
413
424
  host.empty = false;
414
425
 
415
426
  try {
416
- const response = await fetch('/api/orders', {
427
+ const ctx = this.ctx;
428
+ if (!ctx) throw new Error('OrdersDataController requires Router context');
429
+ const response = await ctx.fetch('/api/orders', {
417
430
  signal: abortController.signal
418
431
  });
419
432
  if (!response.ok) throw new Error(`Orders request failed (${response.status})`);
@@ -451,6 +464,12 @@ export class OrdersDataController implements IController<OrdersView> {
451
464
  }
452
465
  ```
453
466
 
467
+ `@context()` works on controllers as well as elements. It receives the same
468
+ long-lived `Context` instance, including `application`, navigation state, and
469
+ the Router's middleware-aware `fetch`. Managed decorators are activated after
470
+ `attach()`, so start context-dependent work in the `@context()` handler (or in
471
+ an event handled later), not in `attach()`.
472
+
454
473
  The element owns presentation for every state:
455
474
 
456
475
  ```typescript
package/docs/events.md CHANGED
@@ -610,7 +610,7 @@ class StatusIndicator extends HTMLElement {
610
610
 
611
611
  ```typescript
612
612
  interface DispatchOptions extends EventInit {
613
- dispatchOnUndefined?: boolean; // Skip dispatch when return is undefined (default: true)
613
+ dispatchOnUndefined?: boolean; // Undefined return still dispatches unless false (default: true)
614
614
  debounce?: number; // Debounce dispatch by ms
615
615
  throttle?: number; // Throttle dispatch by ms
616
616
  // Where to dispatch the event (see scope section below)
@@ -807,7 +807,16 @@ See [Daemons](./daemons.md).
807
807
 
808
808
  ## Custom Events
809
809
 
810
- ### Dispatching Manually
810
+ Prefer `@dispatch` for a static custom event emitted from a Snice element or a
811
+ controller's host. The decorator supplies the standard bubbling and composed
812
+ behavior, uses the method return value as `detail`, and keeps debounce,
813
+ throttle, and scope declarative.
814
+
815
+ Manual dispatch remains valid when code needs direct access to the Event
816
+ object, a dynamic event name, the cancellation boolean returned by
817
+ `dispatchEvent()`, or a target other than the Snice host.
818
+
819
+ ### Manual Escape Hatch
811
820
 
812
821
  ```typescript
813
822
  @element('manual-dispatcher')
package/docs/fetcher.md CHANGED
@@ -51,7 +51,13 @@ const router = Router({
51
51
  router.initialize();
52
52
  ```
53
53
 
54
- ## Using Context.fetch in Pages
54
+ Pages, descendant elements, and attached controllers receive the Router's
55
+ long-lived `Context` through `@context()`; its `ctx.fetch` is the bound,
56
+ middleware-aware function. `getContextFetch(this)` remains the lower-level
57
+ lookup for code using an explicit non-router
58
+ `provideContext(root, appContext, { fetch })` boundary.
59
+
60
+ ## Using Context.fetch in Pages, Elements, and Controllers
55
61
 
56
62
  Once configured, `ctx.fetch` is available in all pages and components that use the `@context` decorator:
57
63
 
@@ -80,6 +86,31 @@ class UserPage extends HTMLElement {
80
86
  }
81
87
  ```
82
88
 
89
+ Controllers use the same decorator. Managed decorators are activated after
90
+ `attach()`, so start context-dependent work from the handler:
91
+
92
+ ```typescript
93
+ @controller('user-data')
94
+ class UserDataController implements IController<HTMLElement> {
95
+ element: HTMLElement | null = null;
96
+ private ctx?: Context;
97
+
98
+ attach(element: HTMLElement) { this.element = element; }
99
+ detach() { this.ctx = undefined; }
100
+
101
+ @context()
102
+ receiveContext(ctx: Context) {
103
+ this.ctx = ctx;
104
+ void this.load();
105
+ }
106
+
107
+ async load() {
108
+ const response = await this.ctx!.fetch('/api/users');
109
+ // Commit through the host's public API.
110
+ }
111
+ }
112
+ ```
113
+
83
114
  ## Middleware Types
84
115
 
85
116
  ### Request Middleware
package/docs/lifecycle.md CHANGED
@@ -198,7 +198,11 @@ The options object always comes after the property names, so it works with multi
198
198
 
199
199
  ## @context() Decorator
200
200
 
201
- Receive router context updates. The decorated method is called whenever the router context changes (navigation, app context update, etc.):
201
+ Receive router context updates on pages, descendant elements, and attached
202
+ controllers. The decorated method is called whenever the router context
203
+ changes (navigation, app context update, etc.). A controller's managed
204
+ decorators activate after `attach()`, so context-dependent startup belongs in
205
+ this handler:
202
206
 
203
207
  ```typescript
204
208
  import { element, context, property, render, html } from 'snice';
@@ -79,17 +79,21 @@ Response handlers can be debounced or throttled:
79
79
  @controller('processing-controller')
80
80
  class ProcessingController implements IController {
81
81
  element: HTMLElement | null = null;
82
+ private ctx!: Context;
82
83
  async attach() {}
83
84
  async detach() {}
84
85
 
86
+ @context()
87
+ receiveContext(ctx: Context) { this.ctx = ctx; }
88
+
85
89
  @respond('search', { debounce: 300 })
86
90
  async handleSearch(query: { term: string }) {
87
- return await fetch(`/api/search?q=${encodeURIComponent(query.term)}`).then(r => r.json());
91
+ return await this.ctx.fetch(`/api/search?q=${encodeURIComponent(query.term)}`).then(r => r.json());
88
92
  }
89
93
 
90
94
  @respond('analytics', { throttle: 1000 })
91
95
  async handleAnalytics(event: any) {
92
- return await fetch('/api/track', { method: 'POST', body: JSON.stringify(event) });
96
+ return await this.ctx.fetch('/api/track', { method: 'POST', body: JSON.stringify(event) });
93
97
  }
94
98
  }
95
99
  ```
@@ -139,23 +143,29 @@ class ProductCard extends HTMLElement {
139
143
  Controllers handle requests — this is where business logic, API calls, and data management belong:
140
144
 
141
145
  ```typescript
142
- import { controller, respond, IController } from 'snice';
146
+ import { context, controller, respond, IController, type Context } from 'snice';
143
147
 
144
148
  @controller('product-controller')
145
149
  class ProductController implements IController {
146
150
  element: HTMLElement | null = null;
151
+ private ctx!: Context;
147
152
  async attach() {}
148
153
  async detach() {}
149
154
 
155
+ @context()
156
+ receiveContext(ctx: Context) { this.ctx = ctx; }
157
+
150
158
  @respond('fetch-product')
151
159
  async handleFetchProduct(request: { id: string }) {
152
- const response = await fetch(`/api/products/${request.id}`);
160
+ const response = await this.ctx.fetch(`/api/products/${request.id}`);
153
161
  return await response.json();
154
162
  }
155
163
  }
156
164
  ```
157
165
 
158
- **Architecture:** Elements never call `fetch()` or manage data directly. They yield requests upward and render whatever comes back. Controllers own the data layer.
166
+ **Architecture:** Elements stay visual and yield requests upward. A controller
167
+ owns application behavior specific to the elements it controls; the page still
168
+ owns element orchestration.
159
169
 
160
170
  ## Request/Response Options
161
171
 
@@ -238,16 +248,20 @@ class SafeLoader extends HTMLElement {
238
248
  @controller('resilient-controller')
239
249
  class ResilientController implements IController {
240
250
  element: HTMLElement | null = null;
251
+ private ctx!: Context;
241
252
  async attach() {}
242
253
  async detach() {}
243
254
 
255
+ @context()
256
+ receiveContext(ctx: Context) { this.ctx = ctx; }
257
+
244
258
  @respond('load-data')
245
259
  async handleLoadData(request: { id: string }) {
246
260
  if (!request.id) {
247
261
  throw new Error('ID is required');
248
262
  }
249
263
 
250
- const response = await fetch(`/api/data/${request.id}`);
264
+ const response = await this.ctx.fetch(`/api/data/${request.id}`);
251
265
  if (!response.ok) {
252
266
  throw new Error(`API error: ${response.status}`);
253
267
  }
@@ -265,12 +279,16 @@ class ResilientController implements IController {
265
279
  @controller('cached-controller')
266
280
  class CachedController implements IController {
267
281
  element: HTMLElement | null = null;
282
+ private ctx!: Context;
268
283
  private cache = new Map<string, { data: any; timestamp: number }>();
269
284
  private ttl = 60000; // 1 minute
270
285
 
271
286
  async attach() {}
272
287
  async detach() {}
273
288
 
289
+ @context()
290
+ receiveContext(ctx: Context) { this.ctx = ctx; }
291
+
274
292
  @respond('fetch-cached')
275
293
  async handleFetch(request: { key: string; forceRefresh?: boolean }) {
276
294
  const cached = this.cache.get(request.key);
@@ -278,7 +296,7 @@ class CachedController implements IController {
278
296
  return { data: cached.data, fromCache: true };
279
297
  }
280
298
 
281
- const data = await fetch(`/api/${request.key}`).then(r => r.json());
299
+ const data = await this.ctx.fetch(`/api/${request.key}`).then(r => r.json());
282
300
  this.cache.set(request.key, { data, timestamp: Date.now() });
283
301
  return { data, fromCache: false };
284
302
  }
package/docs/routing.md CHANGED
@@ -88,6 +88,12 @@ without Router. Descendant elements and attached controllers address the instanc
88
88
  as `{ daemon: 'session' }`; they do not import `SessionDaemon`. See
89
89
  [Daemons](./daemons.md).
90
90
 
91
+ That provider boundary supplies the full Router `Context` to `@context()`
92
+ methods on descendant elements and attached controllers. They use `ctx.fetch`
93
+ with the configured request/response middleware without importing the router
94
+ or adding a reserved field to the application context. `getContextFetch()` is
95
+ the lower-level transport-only lookup for explicit non-router providers.
96
+
91
97
  ## Page Components
92
98
 
93
99
  ### Basic Page
@@ -135,7 +141,10 @@ class HomePage extends HTMLElement {
135
141
 
136
142
  ### Page with Context
137
143
 
138
- The `@context()` decorator is a **method decorator** that receives context updates from the router. The method is called whenever navigation occurs, with a Context object containing application state and navigation data.
144
+ The `@context()` decorator is a **method decorator** that receives context
145
+ updates from the router on pages, descendant elements, and attached
146
+ controllers. The method is called whenever navigation occurs, with a Context
147
+ object containing application state and navigation data.
139
148
 
140
149
  ```typescript
141
150
  import { context, render, html, Context } from 'snice';