snice 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/.agents/skills/snice/SKILL.md +40 -13
  2. package/README.md +6 -5
  3. package/adapters/react/SniceProvider.js +1 -1
  4. package/adapters/react/SniceRouter.d.ts +2 -0
  5. package/adapters/react/SniceRouter.js +3 -2
  6. package/adapters/react/SniceRouter.js.map +1 -1
  7. package/adapters/react/matchRoute.d.ts +4 -1
  8. package/adapters/react/matchRoute.js +13 -3
  9. package/adapters/react/matchRoute.js.map +1 -1
  10. package/adapters/react/useRequestHandler.js +1 -1
  11. package/bin/analyzer-contracts.js +2 -1
  12. package/bin/project-analyzer.js +302 -14
  13. package/bin/templates/AI_GUIDANCE.md +6 -1
  14. package/bin/templates/default/README.md +1 -1
  15. package/bin/templates/default/src/components/app-header.ts +11 -16
  16. package/bin/templates/default/src/components/notification-badge.ts +5 -14
  17. package/bin/templates/default/src/components/search-bar.ts +21 -65
  18. package/bin/templates/default/src/pages/dashboard.ts +15 -11
  19. package/bin/templates/default/src/pages/data.ts +10 -13
  20. package/bin/templates/default/src/pages/notifications.ts +4 -9
  21. package/bin/templates/default/src/pages/profile.ts +2 -8
  22. package/bin/templates/default/src/pages/settings.ts +16 -16
  23. package/dist/cdn/accordion/snice-accordion.js +1 -1
  24. package/dist/cdn/action-bar/snice-action-bar.js +1 -1
  25. package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
  26. package/dist/cdn/alert/snice-alert.js +1 -1
  27. package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
  28. package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
  29. package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
  30. package/dist/cdn/availability/snice-availability.js +1 -1
  31. package/dist/cdn/avatar/snice-avatar.js +1 -1
  32. package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
  33. package/dist/cdn/badge/snice-badge.js +1 -1
  34. package/dist/cdn/banner/snice-banner.js +1 -1
  35. package/dist/cdn/binpack/snice-binpack.js +1 -1
  36. package/dist/cdn/book/snice-book.js +1 -1
  37. package/dist/cdn/booking/snice-booking.js +1 -1
  38. package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
  39. package/dist/cdn/button/snice-button.js +1 -1
  40. package/dist/cdn/calendar/snice-calendar.js +1 -1
  41. package/dist/cdn/camera/snice-camera.js +1 -1
  42. package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
  43. package/dist/cdn/candlestick/snice-candlestick.js +1 -1
  44. package/dist/cdn/card/snice-card.js +1 -1
  45. package/dist/cdn/carousel/snice-carousel.js +1 -1
  46. package/dist/cdn/cart/snice-cart.js +1 -1
  47. package/dist/cdn/chart/snice-chart.js +1 -1
  48. package/dist/cdn/chat/snice-chat.js +1 -1
  49. package/dist/cdn/checkbox/snice-checkbox.js +1 -1
  50. package/dist/cdn/chip/snice-chip.js +1 -1
  51. package/dist/cdn/code-block/snice-code-block.js +1 -1
  52. package/dist/cdn/color-display/snice-color-display.js +1 -1
  53. package/dist/cdn/color-picker/snice-color-picker.js +1 -1
  54. package/dist/cdn/command-palette/snice-command-palette.js +1 -1
  55. package/dist/cdn/comments/snice-comments.js +1 -1
  56. package/dist/cdn/countdown/snice-countdown.js +1 -1
  57. package/dist/cdn/cropper/snice-cropper.js +1 -1
  58. package/dist/cdn/data-card/snice-data-card.js +1 -1
  59. package/dist/cdn/date-picker/snice-date-picker.js +1 -1
  60. package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
  61. package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
  62. package/dist/cdn/diff/snice-diff.js +1 -1
  63. package/dist/cdn/divider/snice-divider.js +1 -1
  64. package/dist/cdn/doc/snice-doc.js +1 -1
  65. package/dist/cdn/draw/snice-draw.js +1 -1
  66. package/dist/cdn/drawer/snice-drawer.js +1 -1
  67. package/dist/cdn/empty-state/snice-empty-state.js +1 -1
  68. package/dist/cdn/estimate/snice-estimate.js +1 -1
  69. package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
  70. package/dist/cdn/file-upload/snice-file-upload.js +1 -1
  71. package/dist/cdn/flip-card/snice-flip-card.js +1 -1
  72. package/dist/cdn/flow/snice-flow.js +1 -1
  73. package/dist/cdn/form-layout/snice-form-layout.js +1 -1
  74. package/dist/cdn/funnel/snice-funnel.js +1 -1
  75. package/dist/cdn/gantt/snice-gantt.js +1 -1
  76. package/dist/cdn/gauge/snice-gauge.js +1 -1
  77. package/dist/cdn/grid/snice-grid.js +1 -1
  78. package/dist/cdn/heatmap/snice-heatmap.js +1 -1
  79. package/dist/cdn/image/snice-image.js +1 -1
  80. package/dist/cdn/input/snice-input.js +1 -1
  81. package/dist/cdn/invoice/snice-invoice.js +1 -1
  82. package/dist/cdn/kanban/snice-kanban.js +1 -1
  83. package/dist/cdn/key-value/snice-key-value.js +1 -1
  84. package/dist/cdn/kpi/snice-kpi.js +1 -1
  85. package/dist/cdn/layout/snice-layout.js +1 -1
  86. package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
  87. package/dist/cdn/link/snice-link.js +1 -1
  88. package/dist/cdn/link-preview/snice-link-preview.js +1 -1
  89. package/dist/cdn/list/snice-list.js +1 -1
  90. package/dist/cdn/location/snice-location.js +1 -1
  91. package/dist/cdn/login/snice-login.js +1 -1
  92. package/dist/cdn/map/snice-map.js +1 -1
  93. package/dist/cdn/markdown/snice-markdown.js +1 -1
  94. package/dist/cdn/masonry/snice-masonry.js +1 -1
  95. package/dist/cdn/menu/snice-menu.js +1 -1
  96. package/dist/cdn/message-strip/snice-message-strip.js +1 -1
  97. package/dist/cdn/modal/snice-modal.js +1 -1
  98. package/dist/cdn/music-player/snice-music-player.js +1 -1
  99. package/dist/cdn/nav/snice-nav.js +1 -1
  100. package/dist/cdn/network-graph/snice-network-graph.js +1 -1
  101. package/dist/cdn/notification-center/snice-notification-center.js +1 -1
  102. package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
  103. package/dist/cdn/org-chart/snice-org-chart.js +1 -1
  104. package/dist/cdn/pagination/snice-pagination.js +1 -1
  105. package/dist/cdn/paint/snice-paint.js +1 -1
  106. package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
  107. package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
  108. package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
  109. package/dist/cdn/popover/snice-popover.js +1 -1
  110. package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
  111. package/dist/cdn/product-card/snice-product-card.js +1 -1
  112. package/dist/cdn/progress/snice-progress.js +1 -1
  113. package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
  114. package/dist/cdn/qr-code/snice-qr-code.js +1 -1
  115. package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
  116. package/dist/cdn/radio/snice-radio.js +1 -1
  117. package/dist/cdn/range-slider/snice-range-slider.js +1 -1
  118. package/dist/cdn/rating/snice-rating.js +1 -1
  119. package/dist/cdn/receipt/snice-receipt.js +1 -1
  120. package/dist/cdn/recipe/snice-recipe.js +1 -1
  121. package/dist/cdn/runtime/README.md +2 -2
  122. package/dist/cdn/runtime/snice-runtime.esm.js +28 -8
  123. package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
  124. package/dist/cdn/runtime/snice-runtime.esm.min.js +1 -1
  125. package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
  126. package/dist/cdn/runtime/snice-runtime.js +28 -8
  127. package/dist/cdn/runtime/snice-runtime.js.map +1 -1
  128. package/dist/cdn/runtime/snice-runtime.min.js +1 -1
  129. package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
  130. package/dist/cdn/sankey/snice-sankey.js +1 -1
  131. package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
  132. package/dist/cdn/select/snice-select.js +1 -1
  133. package/dist/cdn/skeleton/snice-skeleton.js +1 -1
  134. package/dist/cdn/slider/snice-slider.js +1 -1
  135. package/dist/cdn/sortable/snice-sortable.js +1 -1
  136. package/dist/cdn/sparkline/snice-sparkline.js +1 -1
  137. package/dist/cdn/spinner/snice-spinner.js +1 -1
  138. package/dist/cdn/split-button/snice-split-button.js +1 -1
  139. package/dist/cdn/split-pane/snice-split-pane.js +1 -1
  140. package/dist/cdn/spotlight/snice-spotlight.js +1 -1
  141. package/dist/cdn/stat-group/snice-stat-group.js +1 -1
  142. package/dist/cdn/step-input/snice-step-input.js +1 -1
  143. package/dist/cdn/stepper/snice-stepper.js +1 -1
  144. package/dist/cdn/switch/snice-switch.js +1 -1
  145. package/dist/cdn/table/snice-table.esm.js +1 -1
  146. package/dist/cdn/table/snice-table.js +1 -1
  147. package/dist/cdn/tabs/snice-tabs.js +1 -1
  148. package/dist/cdn/tag/snice-tag.js +1 -1
  149. package/dist/cdn/tag-input/snice-tag-input.js +1 -1
  150. package/dist/cdn/terminal/snice-terminal.js +1 -1
  151. package/dist/cdn/testimonial/snice-testimonial.js +1 -1
  152. package/dist/cdn/textarea/snice-textarea.js +1 -1
  153. package/dist/cdn/time-picker/snice-time-picker.js +1 -1
  154. package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
  155. package/dist/cdn/timeline/snice-timeline.js +1 -1
  156. package/dist/cdn/timer/snice-timer.js +1 -1
  157. package/dist/cdn/toast/snice-toast.js +1 -1
  158. package/dist/cdn/tooltip/snice-tooltip.js +1 -1
  159. package/dist/cdn/tree/snice-tree.js +1 -1
  160. package/dist/cdn/treemap/snice-treemap.js +1 -1
  161. package/dist/cdn/user-card/snice-user-card.js +1 -1
  162. package/dist/cdn/video-player/snice-video-player.js +1 -1
  163. package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
  164. package/dist/cdn/waterfall/snice-waterfall.js +1 -1
  165. package/dist/cdn/weather/snice-weather.js +1 -1
  166. package/dist/cdn/work-order/snice-work-order.js +1 -1
  167. package/dist/index.cjs +25 -5
  168. package/dist/index.cjs.map +1 -1
  169. package/dist/index.esm.js +25 -5
  170. package/dist/index.esm.js.map +1 -1
  171. package/dist/index.iife.js +25 -5
  172. package/dist/index.iife.js.map +1 -1
  173. package/dist/react/SniceProvider.js +1 -1
  174. package/dist/react/SniceRouter.d.ts +2 -0
  175. package/dist/react/SniceRouter.js +3 -2
  176. package/dist/react/SniceRouter.js.map +1 -1
  177. package/dist/react/index.js +1 -1
  178. package/dist/react/matchRoute.d.ts +4 -1
  179. package/dist/react/matchRoute.js +13 -3
  180. package/dist/react/matchRoute.js.map +1 -1
  181. package/dist/react/useRequestHandler.js +1 -1
  182. package/dist/symbols.cjs +1 -1
  183. package/dist/symbols.esm.js +1 -1
  184. package/dist/transitions.cjs +1 -1
  185. package/dist/transitions.esm.js +1 -1
  186. package/dist/types/page-options.d.ts +15 -1
  187. package/dist/types/router-instance.d.ts +2 -1
  188. package/docs/ai/README.md +6 -4
  189. package/docs/ai/api.md +1 -1
  190. package/docs/ai/architecture.md +21 -8
  191. package/docs/ai/cli.md +4 -1
  192. package/docs/ai/controllers.md +11 -1
  193. package/docs/ai/decorators.md +1 -1
  194. package/docs/ai/react-integration.md +1 -0
  195. package/docs/ai/routing.md +25 -3
  196. package/docs/cli.md +4 -2
  197. package/docs/controllers.md +16 -4
  198. package/docs/react-integration.md +1 -0
  199. package/docs/routing.md +29 -2
  200. package/package.json +1 -1
@@ -426,6 +426,162 @@ const RULE_DEFINITIONS = [
426
426
  }
427
427
  }
428
428
  },
429
+ {
430
+ id: 'snice/light-render-root-with-styles',
431
+ severity: 'suggestion',
432
+ category: 'architecture',
433
+ description: 'Ask for an explicit integration reason before a styled element gives up shadow-root encapsulation.',
434
+ check(context) {
435
+ if (isFrameworkImplementation(context.filename) || isTestFilename(context.filename)) return;
436
+ for (const declaration of findDecoratedClasses(context.source, 'element')) {
437
+ const header = context.source.slice(declaration.index, declaration.bodyStart);
438
+ const option = /\b(?:renderRoot\s*:\s*['"]light['"]|shadow\s*:\s*false)/.exec(header);
439
+ if (!option || !/(?:@styles\b|\bstatic\s+styles\b)/.test(declaration.body)) continue;
440
+ context.report(declaration.index + option.index, {
441
+ message: `${declaration.name} opts into light DOM despite owning component styles, so selectors and internals are no longer encapsulated.`,
442
+ fix: "Use the default shadow render root. Keep renderRoot: 'light' only for a documented integration requirement such as native parser parentage or intentional global-CSS participation."
443
+ });
444
+ }
445
+ }
446
+ },
447
+ {
448
+ id: 'snice/translator-controller',
449
+ severity: 'suggestion',
450
+ category: 'architecture',
451
+ description: 'Recommend keeping internal-event translation in the element contract instead of a controller.',
452
+ check(context) {
453
+ if (isFrameworkImplementation(context.filename)) return;
454
+ for (const declaration of findDecoratedClasses(context.source, 'controller')) {
455
+ const onMethods = findDecoratedClassMethods(declaration.body, 'on');
456
+ const dispatchMethods = findDecoratedClassMethods(declaration.body, 'dispatch');
457
+ if (!onMethods.length || !dispatchMethods.length) continue;
458
+ const dispatchNames = new Set(dispatchMethods.map(method => method.name));
459
+ const translationMethods = new Set([
460
+ ...onMethods.map(method => method.name),
461
+ ...dispatchNames,
462
+ 'attach',
463
+ 'detach'
464
+ ]);
465
+ const translatesEveryHandler = onMethods.every(method =>
466
+ [...dispatchNames].some(name => new RegExp(`\\bthis\\.${escapeRegExp(name)}\\s*\\(`).test(method.body)) ||
467
+ /\bthis\.element(?:\?\.|\.)dispatchEvent\s*\(/.test(method.body)
468
+ );
469
+ const ownsState = [...inspectElementMembers(declaration.body).fields]
470
+ .some(name => name !== 'element');
471
+ const ownsOtherMethods = findClassMethods(declaration.body)
472
+ .some(method => !translationMethods.has(method.name));
473
+ const hasExternalBehavior = /\b(?:fetch|setTimeout|setInterval)\s*\(|\bnew\s+(?:WebSocket|EventSource|Worker)\s*\(|\b(?:localStorage|sessionStorage|indexedDB|caches)\b|@(request|respond|state|property)\b/.test(declaration.body);
474
+ if (!translatesEveryHandler || ownsState || ownsOtherMethods || hasExternalBehavior) continue;
475
+ context.report(declaration.index, {
476
+ message: `${declaration.name} only translates element events into other element events and owns no external behavior or state.`,
477
+ fix: 'Put visual behavior, internal-part handling, and semantic event dispatch in the element itself. Use a controller only for application behavior specific to a set of elements.'
478
+ });
479
+ }
480
+ }
481
+ },
482
+ {
483
+ id: 'snice/controller-event-origin',
484
+ severity: 'warning',
485
+ category: 'events',
486
+ description: 'Require shared controllers to distinguish their host event from the same event bubbling out of nested hosts.',
487
+ check(context) {
488
+ if (isFrameworkImplementation(context.filename)) return;
489
+ for (const declaration of findDecoratedClasses(context.source, 'controller')) {
490
+ const dispatched = new Set(
491
+ findDecoratedClassMethods(declaration.body, 'dispatch')
492
+ .flatMap(method => firstDecoratorStringArguments(method.decoratorArguments))
493
+ );
494
+ if (!dispatched.size) continue;
495
+ for (const handler of findDecoratedClassMethods(declaration.body, 'on')) {
496
+ const listened = firstDecoratorStringArguments(handler.decoratorArguments);
497
+ const onArguments = splitTopLevelArguments(handler.decoratorArguments);
498
+ const hasSelector = /^\s*(['"])[\s\S]*\1\s*$/.test(onArguments[1] ?? '');
499
+ if (hasSelector || !listened.some(event => dispatched.has(event))) continue;
500
+ const parameter = /^\s*([A-Za-z_$][\w$]*)/.exec(handler.parameters)?.[1];
501
+ if (!parameter || hasHostOriginCheck(handler.body, parameter)) continue;
502
+ const event = listened.find(name => dispatched.has(name));
503
+ context.report(declaration.bodyStart + handler.decoratorIndex, {
504
+ message: `${declaration.name} both listens for and dispatches "${event}" without checking that the event originated on its own host.`,
505
+ fix: `Guard the handler with if (${parameter}.target !== this.element) return; so the same event from a nested controller host is not handled twice.`
506
+ });
507
+ }
508
+ }
509
+ }
510
+ },
511
+ {
512
+ id: 'snice/controller-owns-routing',
513
+ severity: 'warning',
514
+ category: 'architecture',
515
+ description: 'Keep route parsing and navigation in routed pages instead of attached controllers.',
516
+ check(context) {
517
+ if (isFrameworkImplementation(context.filename)) return;
518
+ for (const declaration of findDecoratedClasses(context.source, 'controller')) {
519
+ const routing = /\bnew\s+URLSearchParams\s*\(|\b(?:window\.)?location\.(?:search|hash)\b|\b(?:window\.)?history\.(?:pushState|replaceState)\s*\(/.exec(declaration.body);
520
+ if (!routing) continue;
521
+ context.report(declaration.bodyStart + routing.index, {
522
+ message: `${declaration.name} owns URL parsing or navigation even though routing is page orchestration.`,
523
+ fix: 'Declare route/query parameters in @page({ routes }), receive them as page properties, and navigate through the Router. Keep the controller element-scoped.'
524
+ });
525
+ }
526
+ }
527
+ },
528
+ {
529
+ id: 'snice/self-ready-await',
530
+ severity: 'error',
531
+ category: 'lifecycle',
532
+ description: 'Reject awaiting an element\'s own ready promise from one of its @ready handlers.',
533
+ check(context) {
534
+ for (const declaration of findSniceElementClasses(context.source)) {
535
+ for (const method of findDecoratedClassMethods(declaration.body, 'ready', declaration.bodyStart)) {
536
+ const wait = /\bawait\s+(?:Promise\.resolve\s*\(\s*)?this\.ready\b/.exec(method.body);
537
+ if (!wait) continue;
538
+ context.report(method.bodyStart + wait.index, {
539
+ message: `${declaration.name}.${method.name} awaits this.ready from inside @ready, so each side waits for the other and initialization cannot complete.`,
540
+ fix: 'Remove the self-wait. @ready already runs after the first render; await a child element\'s ready/rendered promise when child readiness is what the code needs.'
541
+ });
542
+ }
543
+ }
544
+ }
545
+ },
546
+ {
547
+ id: 'snice/stray-probe-test',
548
+ severity: 'warning',
549
+ category: 'testing',
550
+ description: 'Reject diagnostic test files whose assertions cannot fail.',
551
+ check(context) {
552
+ if (!isTestFilename(context.filename)) return;
553
+ const code = blankStringContents(context.source);
554
+ for (const match of code.matchAll(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g)) {
555
+ context.report(match.index, {
556
+ message: 'This test assertion is always true and looks like an abandoned diagnostic probe.',
557
+ fix: 'Delete the probe file or replace the assertion with an observable behavior or contract that can fail.'
558
+ });
559
+ }
560
+ }
561
+ },
562
+ {
563
+ id: 'snice/test-helper-value-without-event',
564
+ severity: 'suggestion',
565
+ category: 'testing',
566
+ description: 'Warn when a test helper that appears to simulate user input only writes a DOM property.',
567
+ check(context) {
568
+ if (!isTestFilename(context.filename)) return;
569
+ for (const helper of findNamedFunctionBodies(context.source)) {
570
+ if (!/^(?:(?:type|enter|fill|change|check|uncheck|toggle|select|edit|input)|set(?:Input|Field|Control))/i.test(helper.name)) continue;
571
+ const write = /\b([A-Za-z_$][\w$]*)\s*\.\s*(value|checked)\s*=(?!=|>)/.exec(helper.body);
572
+ if (!write) continue;
573
+ const target = escapeRegExp(write[1]);
574
+ const userSignal = new RegExp(
575
+ `\\b${target}\\s*\\.\\s*(?:dispatchEvent|click)\\s*\\(|\\b(?:fireEvent|userEvent)\\b|\\b(?:fill|check|uncheck|selectOption)\\s*\\(`
576
+ );
577
+ if (userSignal.test(helper.body)) continue;
578
+ context.report(helper.bodyStart + write.index, {
579
+ message: `${helper.name}() writes .${write[2]} but emits no event, so it changes mechanism state without simulating a user action.`,
580
+ fix: 'Dispatch the control\'s documented input/change event after the write, or use the test runner\'s user-interaction helper. Keep direct writes only in tests of the property API itself.'
581
+ });
582
+ }
583
+ }
584
+ },
429
585
  {
430
586
  id: 'snice/observe-target',
431
587
  severity: 'error',
@@ -1245,10 +1401,26 @@ const RULE_DEFINITIONS = [
1245
1401
  check() {}
1246
1402
  },
1247
1403
  {
1248
- id: 'snice/recommend-page-controller',
1404
+ id: 'snice/recommend-page-decomposition',
1249
1405
  severity: 'suggestion',
1250
1406
  category: 'architecture',
1251
- description: 'Recommend extracting substantial or repeated non-visual page logic into a controller.',
1407
+ description: 'Recommend decomposing an oversized page without attaching a controller to the page host.',
1408
+ projectOnly: true,
1409
+ check() {}
1410
+ },
1411
+ {
1412
+ id: 'snice/controller-on-page-host',
1413
+ severity: 'warning',
1414
+ category: 'architecture',
1415
+ description: 'Warn when a routed page imperatively attaches a controller to itself.',
1416
+ projectOnly: true,
1417
+ check() {}
1418
+ },
1419
+ {
1420
+ id: 'snice/recommend-route-params',
1421
+ severity: 'suggestion',
1422
+ category: 'router',
1423
+ description: 'Recommend declarative page route/query parameters instead of manual URL parsing and history writes.',
1252
1424
  projectOnly: true,
1253
1425
  check() {}
1254
1426
  },
@@ -1403,8 +1575,16 @@ export function analyzeProject(files) {
1403
1575
  continue;
1404
1576
  }
1405
1577
  const analyzable = maskComments(file.source);
1578
+ const staleGuardOwners = ['page', 'element', 'controller'].flatMap(kind =>
1579
+ findDecoratedClasses(analyzable, kind).map(declaration => ({
1580
+ kind,
1581
+ start: declaration.bodyStart,
1582
+ end: declaration.bodyStart + declaration.body.length
1583
+ }))
1584
+ );
1406
1585
  for (const guard of analyzable.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\+\+this\.([A-Za-z_$][\w$]*(?:Id|Version|Token|Generation))\b([\s\S]{0,2000}?)\b(?:\1\s*!==?\s*this\.\2|this\.\2\s*!==?\s*\1)/g)) {
1407
- staleGuards.push({ file, index: guard.index, field: guard[2] });
1586
+ const owner = staleGuardOwners.find(candidate => guard.index >= candidate.start && guard.index < candidate.end);
1587
+ staleGuards.push({ file, index: guard.index, field: guard[2], owner: owner?.kind });
1408
1588
  }
1409
1589
  const provenance = buildSourceProvenance(analyzable);
1410
1590
  routerConstructions.push(
@@ -1657,6 +1837,38 @@ export function analyzeProject(files) {
1657
1837
  if (!declarationsByClass.has(key)) declarationsByClass.set(key, []);
1658
1838
  declarationsByClass.get(key).push(declaration);
1659
1839
  }
1840
+
1841
+ for (const page of pageClasses) {
1842
+ const selfAttachment = /\battachController\s*\(\s*this\s*,|\bthis\.controller\s*=/.exec(page.body);
1843
+ if (selfAttachment) {
1844
+ const index = page.bodyStart + selfAttachment.index;
1845
+ const location = sourceLocation(page.file.source, index);
1846
+ diagnostics.push({
1847
+ severity: 'warning',
1848
+ ruleId: 'snice/controller-on-page-host',
1849
+ message: `${page.name} attaches a controller to its own page host, coupling two lifecycle owners for one route.`,
1850
+ fix: 'Keep element orchestration in the page. Put application behavior specific to a set of elements in a controller and bind it where the page composes those elements. A host-free reusable function may stay a plain module.',
1851
+ file: page.file.filename,
1852
+ line: location.line,
1853
+ column: location.column
1854
+ });
1855
+ }
1856
+
1857
+ const manualRoute = /\bnew\s+URLSearchParams\s*\(|\b(?:window\.)?location\.search\b|\b(?:window\.)?history\.(?:pushState|replaceState)\s*\(/.exec(page.body);
1858
+ if (manualRoute) {
1859
+ const index = page.bodyStart + manualRoute.index;
1860
+ const location = sourceLocation(page.file.source, index);
1861
+ diagnostics.push({
1862
+ severity: 'suggestion',
1863
+ ruleId: 'snice/recommend-route-params',
1864
+ message: `${page.name} parses or writes URL state manually even though route/query parameters can be declared by the page.`,
1865
+ fix: "Declare parameters in @page({ routes: ['/path?q=:query'] }), receive them as page properties before @ready, and call the Router's navigate(path) to write route state. Review docs/ai/routing.md.",
1866
+ file: page.file.filename,
1867
+ line: location.line,
1868
+ column: location.column
1869
+ });
1870
+ }
1871
+ }
1660
1872
  for (const [key, declarations] of declarationsByClass) {
1661
1873
  const page = declarations.find(declaration => declaration.decorator === 'page');
1662
1874
  const element = declarations.find(declaration => declaration.decorator === 'element');
@@ -1751,14 +1963,15 @@ export function analyzeProject(files) {
1751
1963
  }
1752
1964
  }
1753
1965
 
1754
- if (new Set(staleGuards.map(guard => guard.file.filename)).size >= 2) {
1755
- for (const guard of staleGuards) {
1966
+ const repeatedStaleGuards = new Set(staleGuards.map(guard => guard.file.filename)).size >= 2;
1967
+ if (repeatedStaleGuards) {
1968
+ for (const guard of staleGuards.filter(candidate => candidate.owner)) {
1756
1969
  const location = sourceLocation(guard.file.source, guard.index);
1757
1970
  diagnostics.push({
1758
1971
  severity: 'suggestion',
1759
1972
  ruleId: 'snice/duplicated-stale-guard',
1760
1973
  message: `A ${guard.field} stale-response guard is repeated across project files.`,
1761
- fix: 'Extract the shared fetch/loading/error/stale-response lifecycle into a controller under src/controllers/. Review docs/ai/controllers.md.',
1974
+ fix: 'Extract host-free request sequencing into a plain module in the project\'s chosen location. Keep element-specific application behavior in controllers and element orchestration in pages. Review docs/ai/architecture.md.',
1762
1975
  file: guard.file.filename,
1763
1976
  line: location.line,
1764
1977
  column: location.column
@@ -1766,21 +1979,21 @@ export function analyzeProject(files) {
1766
1979
  }
1767
1980
  }
1768
1981
 
1769
- const pageControllerReported = new Set();
1982
+ const pageDecompositionReported = new Set();
1770
1983
  for (const page of pageClasses) {
1771
1984
  const profile = pageLogicProfile(page.body);
1772
1985
  if (!profile.recommend) continue;
1773
1986
  const location = sourceLocation(page.file.source, page.index);
1774
1987
  diagnostics.push({
1775
1988
  severity: 'suggestion',
1776
- ruleId: 'snice/recommend-page-controller',
1989
+ ruleId: 'snice/recommend-page-decomposition',
1777
1990
  message: `${page.name} contains substantial non-visual logic (${profile.effectfulMethods} effectful methods, ${profile.effects} external effects, ${profile.decisions} decision points).`,
1778
- fix: `Keep route orchestration and presentation in the page; move reusable data, business, timer, storage, or server behavior into a controller under src/controllers/. Review docs/ai/controllers.md and docs/ai/architecture.md.`,
1991
+ fix: 'Keep visual behavior in elements, application behavior specific to a set of elements in controllers, and element orchestration in the page. A host-free reusable function may stay a plain module. Review docs/ai/architecture.md.',
1779
1992
  file: page.file.filename,
1780
1993
  line: location.line,
1781
1994
  column: location.column
1782
1995
  });
1783
- pageControllerReported.add(`${page.file.filename}:${page.index}`);
1996
+ pageDecompositionReported.add(`${page.file.filename}:${page.index}`);
1784
1997
  }
1785
1998
 
1786
1999
  const repeatedPageMethods = new Map();
@@ -1797,18 +2010,18 @@ export function analyzeProject(files) {
1797
2010
  if (files.length < 2) continue;
1798
2011
  for (const { page, method } of occurrences) {
1799
2012
  const reportKey = `${page.file.filename}:${page.index}`;
1800
- if (pageControllerReported.has(reportKey)) continue;
2013
+ if (pageDecompositionReported.has(reportKey)) continue;
1801
2014
  const location = sourceLocation(page.file.source, method.index);
1802
2015
  diagnostics.push({
1803
2016
  severity: 'suggestion',
1804
- ruleId: 'snice/recommend-page-controller',
2017
+ ruleId: 'snice/recommend-page-decomposition',
1805
2018
  message: `${method.name}() repeats substantial non-visual logic across ${files.length} page files.`,
1806
- fix: `Extract the shared behavior into a controller under src/controllers/ and let each page orchestrate it. Also review docs/ai/controllers.md and docs/ai/architecture.md.`,
2019
+ fix: 'Keep visual behavior in elements, application behavior specific to a set of elements in controllers, and element orchestration in pages. Extract this host-free reusable operation into a plain module in the project\'s chosen location. Review docs/ai/architecture.md.',
1807
2020
  file: page.file.filename,
1808
2021
  line: location.line,
1809
2022
  column: location.column
1810
2023
  });
1811
- pageControllerReported.add(reportKey);
2024
+ pageDecompositionReported.add(reportKey);
1812
2025
  }
1813
2026
  }
1814
2027
 
@@ -2162,10 +2375,12 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
2162
2375
  const pattern = new RegExp(`@${escapeRegExp(decoratorName)}\\b`, 'g');
2163
2376
  for (const match of code.matchAll(pattern)) {
2164
2377
  let cursor = match.index + match[0].length;
2378
+ let decoratorArguments = '';
2165
2379
  while (/\s/.test(code[cursor] ?? '')) cursor++;
2166
2380
  if (code[cursor] === '(') {
2167
2381
  const close = findMatchingDelimiter(code, cursor, '(', ')');
2168
2382
  if (close < 0) continue;
2383
+ decoratorArguments = body.slice(cursor + 1, close);
2169
2384
  cursor = close + 1;
2170
2385
  }
2171
2386
  const signature = /^\s*(?:(?:public|private|protected|static|override)\s+)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(code.slice(cursor));
@@ -2179,6 +2394,9 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
2179
2394
  if (methodClose < 0) continue;
2180
2395
  methods.push({
2181
2396
  name: signature[1],
2397
+ decoratorIndex: match.index,
2398
+ decoratorArguments,
2399
+ parameters: body.slice(parametersOpen + 1, parametersClose),
2182
2400
  bodyStart: bodyStart + methodOpen + 1,
2183
2401
  body: body.slice(methodOpen + 1, methodClose)
2184
2402
  });
@@ -2186,6 +2404,55 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
2186
2404
  return methods;
2187
2405
  }
2188
2406
 
2407
+ function firstDecoratorStringArguments(argumentsSource) {
2408
+ const first = /^\s*(['"])([^'"]+)\1/.exec(argumentsSource);
2409
+ if (first) return [first[2]];
2410
+ const array = /^\s*\[([^\]]*)\]/.exec(argumentsSource);
2411
+ if (!array) return [];
2412
+ return [...array[1].matchAll(/(['"])([^'"]+)\1/g)].map(match => match[2]);
2413
+ }
2414
+
2415
+ function splitTopLevelArguments(source) {
2416
+ const argumentsList = [];
2417
+ let start = 0;
2418
+ let quote = '';
2419
+ let escaped = false;
2420
+ let round = 0;
2421
+ let square = 0;
2422
+ let curly = 0;
2423
+ for (let index = 0; index < source.length; index++) {
2424
+ const character = source[index];
2425
+ if (quote) {
2426
+ if (escaped) escaped = false;
2427
+ else if (character === '\\') escaped = true;
2428
+ else if (character === quote) quote = '';
2429
+ continue;
2430
+ }
2431
+ if (character === '"' || character === "'" || character === '`') {
2432
+ quote = character;
2433
+ continue;
2434
+ }
2435
+ if (character === '(') round++;
2436
+ else if (character === ')') round = Math.max(0, round - 1);
2437
+ else if (character === '[') square++;
2438
+ else if (character === ']') square = Math.max(0, square - 1);
2439
+ else if (character === '{') curly++;
2440
+ else if (character === '}') curly = Math.max(0, curly - 1);
2441
+ else if (character === ',' && round === 0 && square === 0 && curly === 0) {
2442
+ argumentsList.push(source.slice(start, index).trim());
2443
+ start = index + 1;
2444
+ }
2445
+ }
2446
+ argumentsList.push(source.slice(start).trim());
2447
+ return argumentsList;
2448
+ }
2449
+
2450
+ function hasHostOriginCheck(body, parameter) {
2451
+ const target = `${escapeRegExp(parameter)}\\s*\\.\\s*target`;
2452
+ const host = 'this\\s*\\.\\s*element';
2453
+ return new RegExp(`(?:${target})\\s*[!=]==?\\s*(?:${host})|(?:${host})\\s*[!=]==?\\s*(?:${target})`).test(body);
2454
+ }
2455
+
2189
2456
  function isDirectMemberWrite(body, index, length) {
2190
2457
  const before = body.slice(Math.max(0, index - 3), index);
2191
2458
  const after = body.slice(index + length).trimStart();
@@ -2218,6 +2485,23 @@ function findClassMethods(body, bodyStart = 0) {
2218
2485
  return methods;
2219
2486
  }
2220
2487
 
2488
+ function findNamedFunctionBodies(source) {
2489
+ const code = blankStringContents(maskComments(source));
2490
+ const functions = [];
2491
+ const pattern = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)[^{]*\{|\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=\n]+)?=>\s*\{/g;
2492
+ for (const match of code.matchAll(pattern)) {
2493
+ const open = match.index + match[0].lastIndexOf('{');
2494
+ const close = findMatchingDelimiter(code, open, '{', '}');
2495
+ if (close < 0) continue;
2496
+ functions.push({
2497
+ name: match[1] ?? match[2],
2498
+ bodyStart: open + 1,
2499
+ body: source.slice(open + 1, close)
2500
+ });
2501
+ }
2502
+ return functions;
2503
+ }
2504
+
2221
2505
  function pageLogicProfile(body) {
2222
2506
  const code = blankStringContents(body);
2223
2507
  const methods = findClassMethods(body);
@@ -3101,6 +3385,10 @@ function isFrameworkImplementation(filename) {
3101
3385
  return /(?:^|[/\\])packages[/\\]components[/\\]src(?:[/\\]|$)/.test(filename);
3102
3386
  }
3103
3387
 
3388
+ function isTestFilename(filename) {
3389
+ return /(?:^|[/\\])[^/\\]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename);
3390
+ }
3391
+
3104
3392
  function sourceLocation(source, index) {
3105
3393
  const prefix = source.slice(0, index);
3106
3394
  const line = prefix.split('\n').length;
@@ -7,6 +7,12 @@ reviewing Snice code. It routes you to the version-matched references under
7
7
  Do not infer Snice behavior from Lit or generic web-component conventions. Read
8
8
  only the topical reference and component documents needed for the task.
9
9
 
10
+ Visual behavior belongs in elements. Application behavior specific to a set of
11
+ elements belongs in a controller. Element orchestration belongs in pages. Keep
12
+ those ownership rules separate from the attachment mechanism: do not attach a
13
+ controller to a page host. A host-free reusable function may stay a plain module
14
+ wherever the project convention puts it; state plus lifecycle belongs in a daemon.
15
+
10
16
  Useful diagnostics:
11
17
 
12
18
  ```bash
@@ -15,4 +21,3 @@ npx snice check
15
21
 
16
22
  Use `doctor` or `validate` only to isolate the configuration or source half of
17
23
  a failed check.
18
-
@@ -61,7 +61,7 @@ src/
61
61
  notifications.ts # Live feed, filters, @watch, keyboard shortcuts
62
62
  settings.ts # Theme toggle, form bindings, @context, Ctrl+S save
63
63
  data.ts # Data table, search-bar, @observe('resize'), filters
64
- services/ # Business logic (auth, storage, jwt)
64
+ services/ # This project's host-free functions (auth, storage, jwt)
65
65
  middleware/ # Fetch middleware (auth, error, retry)
66
66
  daemons/ # Lifecycle-managed classes (notifications WebSocket)
67
67
  guards/ # Route guards (auth)
@@ -1,19 +1,17 @@
1
- import { element, property, render, styles, context, on, dispatch, dispose, html, css, watch, query } from 'snice';
1
+ import { element, render, styles, context, on, dispatch, dispose, html, css, state } from 'snice';
2
2
  import type { Context } from 'snice';
3
3
  import type { ApplicationContext } from '../context';
4
4
  import { logout } from '../services/auth';
5
5
 
6
6
  @element('app-header')
7
7
  export class AppHeader extends HTMLElement {
8
- @property() userName = '';
9
- @property() userAvatar = '';
10
- @property({ type: Boolean }) authenticated = false;
11
- @property({ type: Boolean }) menuOpen = false;
12
- @property({ type: Number }) notificationCount = 0;
8
+ @state() userName = '';
9
+ @state() userAvatar = '';
10
+ @state() authenticated = false;
11
+ @state() menuOpen = false;
12
+ @state() notificationCount = 0;
13
13
  private unsubscribeNotifications: (() => void) | null = null;
14
14
 
15
- @query('.user-menu') $menu!: HTMLElement;
16
-
17
15
  @context()
18
16
  handleContext(ctx: Context) {
19
17
  const { principal, notifications } = ctx.application as ApplicationContext;
@@ -37,13 +35,6 @@ export class AppHeader extends HTMLElement {
37
35
  }
38
36
  }
39
37
 
40
- @watch('menuOpen')
41
- onMenuToggle() {
42
- if (this.$menu) {
43
- this.$menu.style.display = this.menuOpen ? 'block' : 'none';
44
- }
45
- }
46
-
47
38
  toggleMenu() {
48
39
  this.menuOpen = !this.menuOpen;
49
40
  }
@@ -96,7 +87,7 @@ export class AppHeader extends HTMLElement {
96
87
  <span class="user-name">${this.userName}</span>
97
88
  </button>
98
89
 
99
- <div class="user-menu" style="display: none">
90
+ <div class="user-menu" ?hidden=${!this.menuOpen}>
100
91
  <a href="#/profile" @click=${() => this.menuOpen = false}>Profile</a>
101
92
  <a href="#/settings" @click=${() => this.menuOpen = false}>Settings</a>
102
93
  <snice-divider></snice-divider>
@@ -195,6 +186,10 @@ export class AppHeader extends HTMLElement {
195
186
  z-index: 100;
196
187
  }
197
188
 
189
+ .user-menu[hidden] {
190
+ display: none;
191
+ }
192
+
198
193
  .user-menu a,
199
194
  .user-menu button {
200
195
  display: block;
@@ -1,24 +1,15 @@
1
- import { element, property, render, styles, html, css, watch, query } from 'snice';
1
+ import { element, property, render, styles, html, css } from 'snice';
2
2
 
3
3
  @element('notification-badge')
4
4
  export class NotificationBadge extends HTMLElement {
5
5
  @property({ type: Number }) count = 0;
6
6
 
7
- @query('.badge') $badge!: HTMLElement;
8
-
9
- @watch('count')
10
- onCountChange() {
11
- if (this.$badge) {
12
- this.$badge.textContent = this.count > 99 ? '99+' : String(this.count);
13
- this.$badge.style.display = this.count > 0 ? 'flex' : 'none';
14
- }
15
- }
16
-
17
- @render({ once: true })
7
+ @render()
18
8
  renderContent() {
19
- const display = this.count > 0 ? 'flex' : 'none';
20
9
  return html`
21
- <span class="badge" style="display: ${display}">${this.count}</span>
10
+ <if ${this.count > 0}>
11
+ <span class="badge">${this.count > 99 ? '99+' : this.count}</span>
12
+ </if>
22
13
  `;
23
14
  }
24
15
 
@@ -1,20 +1,22 @@
1
- import { element, property, render, styles, on, dispatch, html, css } from 'snice';
1
+ import { debounce, dispatch, element, html, live, property, render, styles, css } from 'snice';
2
2
 
3
3
  @element('search-bar')
4
4
  export class SearchBar extends HTMLElement {
5
5
  @property() value = '';
6
6
  @property() placeholder = 'Search...';
7
7
 
8
- @on('input', 'input', { debounce: 300 })
9
- handleInput(e: Event) {
10
- this.value = (e.target as HTMLInputElement).value;
11
- this.dispatchSearch();
8
+ handleInput(e: CustomEvent<{ value: string }>) {
9
+ this.value = e.detail.value;
10
+ this.queueSearch();
12
11
  }
13
12
 
14
- @on('keydown:Escape', 'input')
15
- clearSearch(e: Event) {
13
+ clearSearch() {
16
14
  this.value = '';
17
- (e.target as HTMLInputElement).value = '';
15
+ this.dispatchSearch();
16
+ }
17
+
18
+ @debounce(300)
19
+ queueSearch() {
18
20
  this.dispatchSearch();
19
21
  }
20
22
 
@@ -26,17 +28,15 @@ export class SearchBar extends HTMLElement {
26
28
  @render()
27
29
  renderContent() {
28
30
  return html`
29
- <div class="search-container">
30
- <span class="icon">&#128269;</span>
31
- <input
32
- type="text"
33
- .value=${this.value}
34
- placeholder="${this.placeholder}"
35
- />
36
- <if ${this.value.length > 0}>
37
- <button class="clear" @click=${() => { this.value = ''; this.dispatchSearch(); }}>&#10005;</button>
38
- </if>
39
- </div>
31
+ <snice-input
32
+ type="search"
33
+ clearable
34
+ prefix-icon="🔍"
35
+ .value=${live(this.value)}
36
+ placeholder="${this.placeholder}"
37
+ @input-input=${this.handleInput}
38
+ @keydown.escape=${this.clearSearch}
39
+ ></snice-input>
40
40
  `;
41
41
  }
42
42
 
@@ -47,52 +47,8 @@ export class SearchBar extends HTMLElement {
47
47
  display: block;
48
48
  }
49
49
 
50
- .search-container {
51
- display: flex;
52
- align-items: center;
53
- gap: 0.5rem;
54
- padding: 0.5rem 0.75rem;
55
- background: var(--snice-color-surface);
56
- border: 1px solid var(--snice-color-border);
57
- border-radius: var(--snice-border-radius-lg);
58
- }
59
-
60
- .search-container:focus-within {
61
- border-color: var(--snice-color-primary);
62
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--snice-color-primary) 15%, transparent);
63
- }
64
-
65
- .icon {
66
- color: var(--snice-color-text-secondary);
67
- font-size: 0.875rem;
68
- }
69
-
70
- input {
71
- flex: 1;
72
- border: none;
73
- outline: none;
74
- background: transparent;
75
- font-size: 0.875rem;
76
- color: var(--snice-color-text);
77
- }
78
-
79
- input::placeholder {
80
- color: var(--snice-color-text-secondary);
81
- }
82
-
83
- .clear {
84
- background: none;
85
- border: none;
86
- cursor: pointer;
87
- color: var(--snice-color-text-secondary);
88
- font-size: 0.75rem;
89
- padding: 0.125rem 0.25rem;
90
- border-radius: var(--snice-border-radius-md);
91
- }
92
-
93
- .clear:hover {
94
- background: var(--snice-color-surface-container-low);
95
- color: var(--snice-color-text);
50
+ snice-input {
51
+ width: 100%;
96
52
  }
97
53
  `;
98
54
  }