sygnal 6.5.0 → 7.0.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 (104) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +423 -27
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +423 -27
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js +141 -3
  10. package/dist/devtools.cjs.js.map +1 -1
  11. package/dist/devtools.esm.js +141 -4
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +169 -14
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +168 -15
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +2 -0
  18. package/dist/guide/ai-chat.md +6 -6
  19. package/dist/guide/behaviors.md +1 -1
  20. package/dist/guide/element-commands.md +4 -2
  21. package/dist/guide/forms-reference.md +89 -3
  22. package/dist/guide/forms.md +8 -8
  23. package/dist/guide/http.md +77 -7
  24. package/dist/guide/migrating-to-7.md +314 -0
  25. package/dist/guide/recipes/ai-summarize.md +2 -1
  26. package/dist/guide/recipes/i18n.md +1 -1
  27. package/dist/guide/ui/tabs.md +5 -4
  28. package/dist/guide/ui/tooltip.md +24 -0
  29. package/dist/index.cjs.js +781 -320
  30. package/dist/index.cjs.js.map +1 -1
  31. package/dist/index.d.ts +319 -43
  32. package/dist/index.esm.js +781 -320
  33. package/dist/index.esm.js.map +1 -1
  34. package/dist/sygnal.min.js +1 -1
  35. package/dist/sygnal.min.js.map +1 -1
  36. package/dist/ui.cjs.js +96 -11
  37. package/dist/ui.cjs.js.map +1 -1
  38. package/dist/ui.esm.js +96 -12
  39. package/dist/ui.esm.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vike/onRenderClient.cjs.js +63 -43
  42. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  43. package/dist/vike/onRenderClient.mjs +63 -43
  44. package/dist/vike/onRenderClient.mjs.map +1 -1
  45. package/dist/vike/onRenderHtml.cjs.js +21 -25
  46. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  47. package/dist/vike/onRenderHtml.mjs +21 -25
  48. package/dist/vike/onRenderHtml.mjs.map +1 -1
  49. package/dist/vite/plugin.cjs.js +410 -27
  50. package/dist/vite/plugin.cjs.js.map +1 -1
  51. package/dist/vite/plugin.mjs +410 -27
  52. package/dist/vite/plugin.mjs.map +1 -1
  53. package/llms.txt +17 -16
  54. package/package.json +1 -1
  55. package/src/astro/index.d.ts +8 -0
  56. package/src/astro/index.ts +18 -0
  57. package/src/core/actions.ts +28 -21
  58. package/src/core/define.ts +2 -0
  59. package/src/core/hooks.ts +8 -2
  60. package/src/core/hosts/collection.ts +4 -3
  61. package/src/core/instance.ts +60 -16
  62. package/src/core/runtime.ts +5 -1
  63. package/src/core/view.ts +2 -0
  64. package/src/cycle/dom/utils.ts +4 -2
  65. package/src/devtools.d.ts +13 -0
  66. package/src/devtools.ts +3 -0
  67. package/src/extra/ai/chat/behavior.ts +3 -3
  68. package/src/extra/ai/commandBar.ts +3 -3
  69. package/src/extra/ai/compose/behavior.ts +3 -3
  70. package/src/extra/ai/transports/shared.ts +8 -2
  71. package/src/extra/behaviors.ts +21 -0
  72. package/src/extra/devGraph.ts +111 -0
  73. package/src/extra/devMcp.ts +1 -1
  74. package/src/extra/devtools.ts +18 -0
  75. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  76. package/src/extra/diagnostics/checks/forms.ts +4 -3
  77. package/src/extra/diagnostics/checks/index.ts +2 -1
  78. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  79. package/src/extra/diagnostics/checks/next.ts +3 -0
  80. package/src/extra/diagnostics/checks/public.d.ts +133 -12
  81. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  82. package/src/extra/diagnostics/codes.ts +19 -0
  83. package/src/extra/fetchDriver.ts +112 -7
  84. package/src/extra/fetchEventSource.ts +113 -0
  85. package/src/extra/form.ts +68 -26
  86. package/src/extra/formHelpers.ts +3 -1
  87. package/src/extra/lens.ts +3 -2
  88. package/src/extra/queryCache.ts +4 -3
  89. package/src/extra/socketDriver.ts +10 -4
  90. package/src/extra/testing.ts +68 -11
  91. package/src/index.d.ts +199 -30
  92. package/src/index.ts +1 -0
  93. package/src/shared.ts +3 -0
  94. package/src/ui/dialog.ts +3 -2
  95. package/src/ui/tooltips.ts +92 -0
  96. package/src/ui.d.ts +35 -0
  97. package/src/ui.ts +1 -0
  98. package/src/vike/onRenderClient.ts +44 -46
  99. package/src/vike/onRenderHtml.ts +5 -26
  100. package/src/vike/shell.ts +33 -0
  101. package/src/vite/graph.ts +255 -0
  102. package/src/vite/mcp.ts +3 -3
  103. package/src/vite/plugin.d.ts +19 -0
  104. package/src/vite/plugin.ts +144 -25
@@ -357,11 +357,11 @@ function inAllowed(h, allowed) {
357
357
  return e.startsWith('.') ? h === e.slice(1) || h.endsWith(e) : h === e;
358
358
  });
359
359
  }
360
- /** Why the request is refused (403), or null */
361
- function refusal(req, allowedHosts) {
360
+ /** Why the request is refused (403), or null. PLAN-10 W-2: the graph route (./graph.ts) uses the same guard */
361
+ function refusal(req, allowedHosts, what = 'the Sygnal MCP endpoint') {
362
362
  const remote = String(req?.socket?.remoteAddress || '');
363
363
  if (!LOOPBACK_IP.test(remote))
364
- return `the Sygnal MCP endpoint only answers requests from this machine (not ${remote || 'an unknown address'})`;
364
+ return `${what} only answers requests from this machine (not ${remote || 'an unknown address'})`;
365
365
  const host = req?.headers?.host;
366
366
  if (typeof host !== 'string' || !host)
367
367
  return 'missing Host header';
@@ -623,6 +623,275 @@ if (import.meta.hot) installMcpBridge(import.meta.hot, { agentTools, confirm: ${
623
623
  `;
624
624
  }
625
625
 
626
+ /**
627
+ * PLAN-10 W-2 / P-2: the live app graph page of sygnal/vite's dev server (D543, D544).
628
+ *
629
+ * `/__sygnal/graph` serves sygnal-check's graph page (`graphHtml`, the same file `sygnal-check
630
+ * --graph=html --watch` serves) in live mode, and `/__sygnal/graph/events` its SSE stream: the
631
+ * whole graph (`event: graph`, `id: <version>`) on connect (unless `Last-Event-ID` already has it)
632
+ * and after each build whose graph changed, a `: ping` comment every 25 s. Dev server only.
633
+ *
634
+ * One live project (sygnal-check's createLiveProject, W-1) feeds the page, the check overlay and
635
+ * the console (the plugin's `check`): each build gives the graph and the diagnostics, so they agree.
636
+ * It builds on start and 100 ms after the last source or config file change Vite's watcher reports.
637
+ *
638
+ * P-2: each build also gives the top-level binding and static key (D521: 'file#name', '#2' for a
639
+ * second of the same name in a file) of every component in a scanned file (sygnal-check's
640
+ * componentStamps); the dev transform appends to those modules a tail that sets a non-enumerable
641
+ * `__sygnalKey` on each component function, and the runtime inspect() reports it as `key`. A
642
+ * module whose text changed since the last build is built again first, so a stamp is never stale.
643
+ *
644
+ * The page is served on the app's origin: its "Live data" (graphView/liveData.js) joins the open
645
+ * app tabs over a BroadcastChannel (src/extra/devGraph.ts), with no server in between.
646
+ *
647
+ * Security: the same guard as `/__sygnal/mcp` (./mcp.ts refusal(): a loopback peer, a local Host
648
+ * (or one in `server.allowedHosts`), a local Origin when there is one); GET and HEAD only; the page
649
+ * gets `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
650
+ * `X-Frame-Options: DENY` and a CSP (inline style and script, `connect-src 'self'`, no framing).
651
+ * Read only: it serves the page and the stream, nothing else.
652
+ */
653
+ // @ts-ignore
654
+ const GRAPH_PATH = '/__sygnal/graph';
655
+ const GRAPH_EVENTS = GRAPH_PATH + '/events';
656
+ const GRAPH_CLIENT = 'virtual:sygnal/graph';
657
+ const GRAPH_CHANNEL = 'sygnal:graph';
658
+ /** The property the dev transform sets on component functions (read by inspect()) */
659
+ const STAMP_PROP = '__sygnalKey';
660
+ const SOURCE_RE$1 = /\.[cm]?[jt]sx?$/;
661
+ const CONFIG_FILES$1 = ['sygnal-check.config.json', 'package.json'];
662
+ const EMPTY = { version: 1, source: 'static', components: [], events: {}, diagnostics: [] };
663
+ const PAGE_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data: blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
664
+ const SECURITY = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' };
665
+ const posix = (p) => p.split(path.sep).join('/');
666
+ /** Whether the project's sygnal-check has the live graph page with "Live data" (checked without loading it). */
667
+ function graphAvailable(root) {
668
+ try {
669
+ const main = node_module.createRequire(path.join(root, 'package.json')).resolve('sygnal-check');
670
+ return fs.existsSync(path.join(path.dirname(main), 'graphView', 'liveData.js'));
671
+ }
672
+ catch (_) {
673
+ return false;
674
+ }
675
+ }
676
+ /**
677
+ * The live project behind the page and the checker. `settings` are live.js's (paths = the check's
678
+ * include, strict / a11y / ignore / defaults as the checker resolves them).
679
+ */
680
+ function createEngine(mod, root, settings, logger, stamps) {
681
+ const live = mod.createLiveProject({ root });
682
+ const listeners = [];
683
+ let last = null;
684
+ // the stamps by the live project's path of each file; `known` maps a module path as Vite gives it
685
+ // (posix, possibly through a symlink) to that path
686
+ let stampsByFile = new Map();
687
+ let known = new Map();
688
+ let failed = '';
689
+ const real = (f) => { try {
690
+ return posix(fs.realpathSync(f));
691
+ }
692
+ catch (_) {
693
+ return posix(f);
694
+ } };
695
+ const build = () => {
696
+ let b;
697
+ try {
698
+ b = live.build({ settings, stamps });
699
+ }
700
+ catch (err) {
701
+ const msg = String(err?.message || err);
702
+ if (msg !== failed)
703
+ logger.warn(`[sygnal] sygnal-check failed: ${msg}`, { timestamp: true });
704
+ failed = msg;
705
+ return null;
706
+ }
707
+ failed = '';
708
+ if (!b.graph)
709
+ b = { ...b, json: JSON.stringify(EMPTY) };
710
+ last = b;
711
+ stampsByFile = b.stamps || new Map();
712
+ known = new Map();
713
+ for (const f of b.files || []) {
714
+ known.set(posix(f), f);
715
+ known.set(real(f), f);
716
+ }
717
+ for (const fn of listeners) {
718
+ try {
719
+ fn(b);
720
+ }
721
+ catch (_) { /* a listener never stops the others */ }
722
+ }
723
+ return b;
724
+ };
725
+ const stampsFor = (file) => {
726
+ if (!last)
727
+ return null;
728
+ const own = known.get(posix(file)) ?? known.get(real(file));
729
+ if (!own)
730
+ return null;
731
+ let text = null;
732
+ try {
733
+ text = fs.readFileSync(own, 'utf8');
734
+ }
735
+ catch (_) { }
736
+ const entry = live.cache?.get?.(own);
737
+ // changed since the last build (Vite transforms it before the watcher's debounced build)
738
+ if (text !== null && (!entry || entry.text !== text))
739
+ build();
740
+ return stampsByFile.get(own) || null;
741
+ };
742
+ let timer;
743
+ const watch = (server) => {
744
+ const onChange = (file) => {
745
+ if (String(file).split(/[\\/]/).includes('node_modules'))
746
+ return;
747
+ if (!SOURCE_RE$1.test(file) && !CONFIG_FILES$1.includes(path.basename(file)))
748
+ return;
749
+ clearTimeout(timer);
750
+ timer = setTimeout(build, 100);
751
+ };
752
+ const watcher = server?.watcher;
753
+ if (watcher && typeof watcher.on === 'function') {
754
+ watcher.on('change', onChange);
755
+ watcher.on('add', onChange);
756
+ watcher.on('unlink', onChange);
757
+ }
758
+ try {
759
+ server.httpServer?.once?.('close', () => clearTimeout(timer));
760
+ }
761
+ catch (_) { }
762
+ };
763
+ return {
764
+ build, stampsFor, watch, mod,
765
+ get last() { return last; },
766
+ onBuild(fn) { listeners.push(fn); },
767
+ };
768
+ }
769
+ /** The tail the dev transform appends to a module: a non-enumerable key on each component function. */
770
+ function stampTail(list) {
771
+ const ok = list.filter(s => /^[A-Za-z_$][\w$]*$/.test(s.binding));
772
+ if (!ok.length)
773
+ return '';
774
+ const pairs = ok.map(s => `[typeof ${s.binding} == 'function' ? ${s.binding} : null, ${JSON.stringify(s.key)}]`).join(', ');
775
+ return `\n;(() => { try { for (const [f, k] of [${pairs}]) if (f) Object.defineProperty(f, '${STAMP_PROP}', { value: k, configurable: true }) } catch (_) {} })();\n`;
776
+ }
777
+ /** 'virtual:sygnal/graph': the app side of the page's "Live data" (dev only) */
778
+ function graphClientModule() {
779
+ return `import { installGraphBridge } from 'sygnal/devtools';\ninstallGraphBridge({ channel: ${JSON.stringify(GRAPH_CHANNEL)} });\n`;
780
+ }
781
+ function deny(res, status, text, headers = {}) {
782
+ res.statusCode = status;
783
+ for (const k in { ...SECURITY, ...headers })
784
+ res.setHeader(k, { ...SECURITY, ...headers }[k]);
785
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
786
+ res.end(text + '\n');
787
+ }
788
+ function projectTitle(root) {
789
+ try {
790
+ const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name;
791
+ if (typeof name === 'string' && name)
792
+ return name;
793
+ }
794
+ catch (_) { }
795
+ return path.basename(root);
796
+ }
797
+ /**
798
+ * The middleware for `/__sygnal/graph` and its stream. `engine` resolves to null when sygnal-check
799
+ * isn't installed (or too old): the route then answers 404 with the install hint.
800
+ */
801
+ function graphMiddleware(server, root, engine, ping = 25000) {
802
+ const streams = new Set();
803
+ let subscribed = false;
804
+ let pinger = null;
805
+ const event = (b) => `event: graph\nid: ${b.version}\ndata: ${b.json}\n\n`;
806
+ const subscribe = (e) => {
807
+ if (subscribed)
808
+ return;
809
+ subscribed = true;
810
+ e.onBuild((b) => { if (b.changed)
811
+ for (const res of streams)
812
+ res.write(event(b)); });
813
+ pinger = setInterval(() => { for (const res of streams)
814
+ res.write(': ping\n\n'); }, ping);
815
+ pinger.unref?.();
816
+ try {
817
+ server.httpServer?.once?.('close', () => {
818
+ clearInterval(pinger);
819
+ for (const res of streams)
820
+ res.end();
821
+ streams.clear();
822
+ });
823
+ }
824
+ catch (_) { }
825
+ };
826
+ return async (req, res, next) => {
827
+ const url = String(req.url || '').replace(/[?#].*$/, '');
828
+ if (url !== GRAPH_PATH && url !== GRAPH_PATH + '/' && url !== GRAPH_EVENTS)
829
+ return next();
830
+ const why = refusal(req, server?.config?.server?.allowedHosts, 'the Sygnal graph page');
831
+ if (why)
832
+ return deny(res, 403, `Forbidden: ${why}`);
833
+ if (req.method !== 'GET' && req.method !== 'HEAD')
834
+ return deny(res, 405, 'Method not allowed', { Allow: 'GET, HEAD' });
835
+ const e = await engine;
836
+ if (!e)
837
+ return deny(res, 404, 'The Sygnal graph page needs a sygnal-check with the live graph page in the project: npm i -D sygnal-check@latest');
838
+ subscribe(e);
839
+ const b = e.last || e.build();
840
+ if (!b)
841
+ return deny(res, 500, 'sygnal-check failed: see the dev server output');
842
+ if (url === GRAPH_EVENTS) {
843
+ res.writeHead(200, { ...SECURITY, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'Content-Security-Policy': "default-src 'none'" });
844
+ if (req.method === 'HEAD')
845
+ return res.end();
846
+ res.write('retry: 1000\n\n');
847
+ if (req.headers['last-event-id'] !== String(b.version))
848
+ res.write(event(b));
849
+ streams.add(res);
850
+ req.on('close', () => streams.delete(res));
851
+ return;
852
+ }
853
+ let body;
854
+ try {
855
+ body = e.mod.graphHtml(b.graph || EMPTY, {
856
+ title: projectTitle(root),
857
+ subtitle: 'dev server',
858
+ root,
859
+ live: { events: GRAPH_EVENTS, version: b.version },
860
+ appData: { channel: GRAPH_CHANNEL },
861
+ });
862
+ }
863
+ catch (err) {
864
+ return deny(res, 500, `The graph page failed: ${err?.message || err}`);
865
+ }
866
+ res.writeHead(200, { ...SECURITY, 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': PAGE_CSP, 'Content-Length': globalThis.Buffer.byteLength(body) });
867
+ res.end(req.method === 'HEAD' ? undefined : body);
868
+ };
869
+ }
870
+ /** The banner line when the project has no sygnal-check with the graph page (it used to print nothing). */
871
+ const GRAPH_MISSING_LINE = ' ➜ Sygnal graph: needs sygnal-check 0.10.0 or later in this project (npm i -D sygnal-check)';
872
+ /**
873
+ * Adds the page's URL under Vite's own when the dev server prints them, or, when the project's
874
+ * sygnal-check is missing or too old for the page (`available` false), a line saying what to install.
875
+ */
876
+ function printGraphUrl(server, available = true) {
877
+ const print = server?.printUrls;
878
+ if (typeof print !== 'function')
879
+ return;
880
+ server.printUrls = function (...args) {
881
+ const out = print.apply(this, args);
882
+ try {
883
+ const logger = server.config?.logger || console;
884
+ const local = server.resolvedUrls?.local?.[0];
885
+ if (!available)
886
+ logger.info(GRAPH_MISSING_LINE);
887
+ else if (local)
888
+ logger.info(` ➜ Sygnal graph: ${new URL(local).origin}${GRAPH_PATH}`);
889
+ }
890
+ catch (_) { }
891
+ return out;
892
+ };
893
+ }
894
+
626
895
  /**
627
896
  * Sygnal Vite Plugin
628
897
  *
@@ -748,6 +1017,22 @@ if (import.meta.hot) installMcpBridge(import.meta.hot, { agentTools, confirm: ${
748
1017
  * `globalThis.__SYGNAL_VIEW_TOKENS__`. The runtime SYG103 check then takes a selector drawn
749
1018
  * by a view helper (`{state.open && panel()}`) as conditionally rendered, as it does for
750
1019
  * markup in the view function itself (see viewTokensTail).
1020
+ * 12. `graph` option (PLAN-10 W-2 / P-2, D543 / D544, default true; dev server only, never
1021
+ * `vite build`, `vite preview` or Vitest): the live app graph page at /__sygnal/graph, with
1022
+ * its SSE stream at /__sygnal/graph/events (./graph.ts; the /__sygnal/mcp guard: loopback
1023
+ * peer, local Host or server.allowedHosts, local Origin). sygnal-check (from the project,
1024
+ * skipped silently when missing; a sygnal-check without the page keeps the checker as in 5)
1025
+ * builds it with createLiveProject on start and after each change: one build gives the page,
1026
+ * the check overlay / console findings of 5, and the component stamps. Its URL is printed
1027
+ * under Vite's ("➜ Sygnal graph: http://localhost:5173/__sygnal/graph"; without a sygnal-check
1028
+ * 0.10.0+ in the project, a line saying so instead). The modules of the
1029
+ * scanned files get a tail setting a non-enumerable `__sygnalKey` (the static 'file#name'
1030
+ * key, D521) on each top-level component function, which the runtime inspect() reports as
1031
+ * `key`; with DevTools on, the dev entries also import 'virtual:sygnal/graph'
1032
+ * (installGraphBridge from 'sygnal/devtools'), which answers the page's "Live data" over a
1033
+ * same-origin BroadcastChannel: the page fades components with no instance, shows instance
1034
+ * counts, flashes a card when its action runs and shows an instance's state. `graph: false`:
1035
+ * no route, stamps or bridge (the checker of 5 runs check() as before).
751
1036
  *
752
1037
  * Why not Vite's `define`? Vite's dependency optimizer does not apply user
753
1038
  * `define` replacements to pre-bundled dependencies (only process.env.NODE_ENV),
@@ -791,6 +1076,8 @@ const OVERLAY_PLUGIN = 'sygnal-check';
791
1076
  // PLAN-6 E-1: the page side of the dev MCP endpoint
792
1077
  const MCP_CLIENT = 'virtual:sygnal/mcp';
793
1078
  const MCP_CLIENT_ID = '\0' + MCP_CLIENT;
1079
+ // PLAN-10 P-2: the page side of the graph page's "Live data"
1080
+ const GRAPH_CLIENT_ID = '\0' + GRAPH_CLIENT;
794
1081
  // Register the runtime checks with the core that loaded last (see the Vike wrapper)
795
1082
  const REINSTALL_IMPORT = `import { installChecks as __sygnalInstallChecks } from 'sygnal/diagnostics';`;
796
1083
  const REINSTALL_CALL = 'try { __sygnalInstallChecks() } catch (e) { console.warn(e) }\n';
@@ -806,7 +1093,7 @@ function sygnal(options = {}) {
806
1093
  // Files whose `import ... from 'sygnal'` resolves to the run() wrapper
807
1094
  const runtimeImporters = new Set();
808
1095
  // Statements run before the app starts: dev flag (+ strict flag)
809
- const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '');
1096
+ const flags = DEV_FLAG + (diagnostics.strict ? STRICT_FLAG : '') + (diagnostics.timingAudit ? AUDIT_FLAG : '');
810
1097
  const devImports = `import 'sygnal/diagnostics';import '${DEV_CLIENT}';`;
811
1098
  // D77: the DevTools bridge, dev only. First, so the diagnostics entry finds it.
812
1099
  // E-1: the MCP endpoint needs the bridge, so `mcp` turns it on
@@ -814,11 +1101,17 @@ function sygnal(options = {}) {
814
1101
  const mcpOptions = typeof options.mcp === 'object' && options.mcp ? options.mcp : {};
815
1102
  const devtoolsOn = options.devtools !== false || mcpOn;
816
1103
  const redux = typeof options.devtools == 'object' && !!options.devtools?.redux;
817
- const devtoolsImport = (!devtoolsOn ? ''
1104
+ // PLAN-10 W-2 / P-2 (D543, D544): the graph page, the component stamps and (with DevTools and a
1105
+ // sygnal-check that has the page, decided in config()) the app side of its "Live data"
1106
+ const graphOn = options.graph !== false;
1107
+ let graphClient = false;
1108
+ let engine = null;
1109
+ const devtoolsBase = (!devtoolsOn ? ''
818
1110
  : redux ? `import { connectReduxDevtools as __sygnalReduxDevtools } from 'sygnal/devtools';__sygnalReduxDevtools();`
819
1111
  : `import 'sygnal/devtools';`) + (mcpOn ? `import '${MCP_CLIENT}';` : '');
1112
+ let devtoolsImport = devtoolsBase;
820
1113
  // What a dev entry gets: DevTools, then (unless diagnostics are 'off') flags + checks
821
- const devSnippet = devtoolsImport + (devOn ? flags + devImports : '');
1114
+ let devSnippet = devtoolsImport + (devOn ? flags + devImports : '');
822
1115
  const devInject = devOn || devtoolsOn;
823
1116
  let astroClient;
824
1117
  return {
@@ -827,6 +1120,10 @@ function sygnal(options = {}) {
827
1120
  isServe = env.command === 'serve';
828
1121
  isVitest = !!nodeProcess?.env?.VITEST;
829
1122
  root = config?.root ? path.resolve(config.root) : nodeProcess.cwd();
1123
+ // P-2: the graph page's app side, in the dev server only, when the page can exist
1124
+ graphClient = isServe && !isVitest && graphOn && devtoolsOn && graphAvailable(root);
1125
+ devtoolsImport = devtoolsBase + (graphClient ? `import '${GRAPH_CLIENT}';` : '');
1126
+ devSnippet = devtoolsImport + (devOn ? flags + devImports : '');
830
1127
  const result = {
831
1128
  // Ensure sygnal is bundled for SSR rather than externalized.
832
1129
  // Without this, Node fails to resolve sygnal's exports from
@@ -931,6 +1228,8 @@ function sygnal(options = {}) {
931
1228
  return DEV_CLIENT_ID;
932
1229
  if (source === MCP_CLIENT && mcpOn)
933
1230
  return MCP_CLIENT_ID;
1231
+ if (source === GRAPH_CLIENT && graphClient)
1232
+ return GRAPH_CLIENT_ID;
934
1233
  if (source === 'sygnal' && importer && wrapRun && runtimeImporters.has(cleanId(importer))) {
935
1234
  return RUNTIME_ID;
936
1235
  }
@@ -953,6 +1252,8 @@ function sygnal(options = {}) {
953
1252
  return devClientModule();
954
1253
  if (id === MCP_CLIENT_ID && mcpOn)
955
1254
  return mcpClientModule(mcpOptions);
1255
+ if (id === GRAPH_CLIENT_ID && graphClient)
1256
+ return graphClientModule();
956
1257
  if (id === RUNTIME_ID)
957
1258
  return runtimeModule(diagnostics);
958
1259
  // The checks register again once the real entry has loaded: a
@@ -972,15 +1273,26 @@ function sygnal(options = {}) {
972
1273
  if (mcpOn && server?.middlewares) {
973
1274
  attachMcp(server, root, mcpOptions, sygnalVersion(root));
974
1275
  }
975
- if (options.check === false)
1276
+ const checkOn = options.check !== false;
1277
+ if (!checkOn && !graphOn)
976
1278
  return;
977
1279
  const checkOptions = typeof options.check === 'object' && options.check ? options.check : {};
978
- // Deferred: the dev server doesn't wait for the first check
979
1280
  const checkDefaults = { strict: diagnostics.strict, ignore: diagnostics.ignore };
980
- setTimeout(() => {
981
- startChecker(server, root, checkOptions, checkDefaults, options.check !== undefined)
982
- .catch(err => server?.config?.logger?.warn?.(`[sygnal] sygnal-check could not start: ${err?.message || err}`));
983
- }, 0);
1281
+ // Deferred: the dev server doesn't wait for the first check. `engine`: the live graph
1282
+ // project (W-2), null without a sygnal-check that has it
1283
+ engine = new Promise((resolve) => {
1284
+ setTimeout(() => {
1285
+ startDev(server, root, checkOptions, checkDefaults, options.check !== undefined, checkOn, graphOn).then(resolve, (err) => {
1286
+ server?.config?.logger?.warn?.(`[sygnal] sygnal-check could not start: ${err?.message || err}`);
1287
+ resolve(null);
1288
+ });
1289
+ }, 0);
1290
+ });
1291
+ // W-2 (D543): the page and its stream, behind the MCP endpoint's guard; its URL in the banner
1292
+ if (graphOn && server?.middlewares) {
1293
+ server.middlewares.use(graphMiddleware(server, root, engine));
1294
+ printGraphUrl(server, graphAvailable(root));
1295
+ }
984
1296
  },
985
1297
  transform(code, id, opts) {
986
1298
  // SYG666 (PLAN-5 W-2): an adapter entry imported without its optional peers; every mode
@@ -989,7 +1301,10 @@ function sygnal(options = {}) {
989
1301
  ? checkPeers(this, adapters, id).then(() => devTransform.call(this, code, id, opts))
990
1302
  : devTransform.call(this, code, id, opts);
991
1303
  // 11. the words SYG103 may take as conditionally rendered markup (dev server, diagnostics on)
992
- const tokens = isServe && !isVitest && devOn && !opts?.ssr ? viewTokensTail(this, code, id) : null;
1304
+ const words = isServe && !isVitest && devOn && !opts?.ssr ? viewTokensTail(this, code, id) : null;
1305
+ // 12. P-2 (D544): the static keys of the module's components (dev server, graph on)
1306
+ const stamps = engine && isServe && !isVitest && !opts?.ssr ? componentStampTail(engine, id) : null;
1307
+ const tokens = words && stamps ? Promise.all([words, stamps]).then(([a, b]) => a + b) : words || stamps;
993
1308
  if (!tokens)
994
1309
  return result;
995
1310
  return Promise.all([result, tokens]).then(([r, tail]) => {
@@ -1081,6 +1396,25 @@ function sygnal(options = {}) {
1081
1396
  return done();
1082
1397
  }
1083
1398
  }
1399
+ // 12. PLAN-10 P-2 (D544): a module of the scanned files gets, in dev, a tail that sets each of its
1400
+ // components' static key (sygnal-check's D521 'file#name', from the live graph engine's last build,
1401
+ // built again first when the module's text changed) as a non-enumerable `__sygnalKey` on the
1402
+ // function; the runtime inspect() reports it as `key`, and the graph page joins instances to cards.
1403
+ function componentStampTail(engine, id) {
1404
+ const file = cleanId(id);
1405
+ if (!/\.[cm]?[jt]sx?$/.test(file) || id.includes('node_modules') || id.startsWith('\0'))
1406
+ return null;
1407
+ return engine.then((e) => {
1408
+ if (!e)
1409
+ return '';
1410
+ try {
1411
+ return stampTail(e.stampsFor(file) || []);
1412
+ }
1413
+ catch (_) {
1414
+ return '';
1415
+ }
1416
+ });
1417
+ }
1084
1418
  // 11. SYG103 (the dev runtime check of an intent selector that never matched) lets a selector
1085
1419
  // through as conditionally rendered when its words appear in the component's view; the runtime
1086
1420
  // can read only the view function's own source. A component module (one that sets `X.intent` or
@@ -1242,7 +1576,7 @@ function normalizeDiagnostics(value) {
1242
1576
  if (!MODES.includes(mode)) {
1243
1577
  throw new Error(`[vite-plugin-sygnal] diagnostics mode must be one of ${MODES.map(m => `'${m}'`).join(', ')}; got ${JSON.stringify(mode)}`);
1244
1578
  }
1245
- return { mode, strict: !!o.strict, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] };
1579
+ return { mode, strict: !!o.strict, timingAudit: !!o.timingAudit, ignore: Array.isArray(o.ignore) ? o.ignore.map(String) : [] };
1246
1580
  }
1247
1581
  /** Module id without a query or hash. */
1248
1582
  function cleanId(id) {
@@ -1415,16 +1749,39 @@ function checkInclude(root, include, notice, label = 'check.include') {
1415
1749
  notice(`[sygnal] sygnal-check: no ${DEFAULT_INCLUDE.join('/, ')}/ directory under ${root}; checking the project root (set check.include to choose)`);
1416
1750
  return ['.'];
1417
1751
  }
1418
- /** Run sygnal-check now and after source changes; report to the terminal and the browser. */
1419
- async function startChecker(server, root, opts, defaults, explicit) {
1752
+ /**
1753
+ * Load sygnal-check and start what dev uses it for: the checker (`check`, reported to the terminal
1754
+ * and the browser) and the live graph engine (`graph`, PLAN-10 W-2: the page, its stream and the
1755
+ * component stamps). With an engine, both come from one build. Resolves to the engine, or null.
1756
+ */
1757
+ async function startDev(server, root, opts, defaults, explicit, checkOn, graphOn) {
1420
1758
  const mod = await loadSygnalCheck(root);
1421
1759
  const logger = server?.config?.logger || console;
1422
1760
  if (!mod || typeof mod.check !== 'function') {
1423
- if (explicit)
1761
+ if (explicit && checkOn)
1424
1762
  logger.info('[sygnal] sygnal-check is not installed, so static checks are off (npm i -D sygnal-check)');
1425
- return;
1763
+ return null;
1764
+ }
1765
+ const setup = checkSetup(mod, root, opts, defaults, (m) => logger.info(m));
1766
+ // PLAN-10 W-2: a sygnal-check with the live project and the page (feature-detected, so an older
1767
+ // one keeps the checker as it was)
1768
+ const engine = graphOn && setup.configAware && typeof mod.createLiveProject === 'function' && typeof mod.graphHtml === 'function'
1769
+ ? createEngine(mod, root, {
1770
+ paths: setup.include, strict: setup.checkOptions.strict, a11y: setup.checkOptions.a11y,
1771
+ ignore: setup.checkOptions.ignore, defaults: setup.checkOptions.defaults,
1772
+ }, logger, true)
1773
+ : null;
1774
+ if (checkOn)
1775
+ startChecker(server, root, mod, setup, opts, engine);
1776
+ if (engine) {
1777
+ if (!engine.last)
1778
+ engine.build();
1779
+ engine.watch(server);
1426
1780
  }
1427
- const notice = (m) => logger.info(m);
1781
+ return engine;
1782
+ }
1783
+ /** What sygnal-check checks, with which options (see the `check` option). */
1784
+ function checkSetup(mod, root, opts, defaults, notice) {
1428
1785
  // PLAN-7 C-5 (D299): a sygnal-check with config support resolves the settings itself:
1429
1786
  // explicit check.* options > its config file > the `diagnostics` defaults passed as `defaults`.
1430
1787
  // An older one gets exactly what it got before.
@@ -1460,6 +1817,16 @@ async function startChecker(server, root, opts, defaults, explicit) {
1460
1817
  a11y: opts.a11y === 'error' ? 'error' : 'warn',
1461
1818
  };
1462
1819
  }
1820
+ return { include, checkOptions, configAware };
1821
+ }
1822
+ /**
1823
+ * Run sygnal-check now and after source changes; report to the terminal and the browser. With the
1824
+ * live graph engine (W-2) the findings come from its builds (one build for the page and the
1825
+ * checker); else from check() on each change.
1826
+ */
1827
+ function startChecker(server, root, mod, setup, opts, engine) {
1828
+ const logger = server?.config?.logger || console;
1829
+ const { include, checkOptions, configAware } = setup;
1463
1830
  const a11y = checkOptions.a11y;
1464
1831
  // Only error-severity findings open Vite's overlay: while an overlay is open
1465
1832
  // Vite's client reloads the page on the first HMR update, so warnings stay
@@ -1488,14 +1855,18 @@ async function startChecker(server, root, opts, defaults, explicit) {
1488
1855
  }
1489
1856
  catch (_) { }
1490
1857
  };
1491
- const run = (initial = false) => {
1858
+ const run = (initial = false, built) => {
1492
1859
  let diags;
1493
- try {
1494
- diags = mod.check(include, checkOptions);
1495
- }
1496
- catch (err) {
1497
- logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true });
1498
- return;
1860
+ if (built)
1861
+ diags = built;
1862
+ else {
1863
+ try {
1864
+ diags = mod.check(include, checkOptions);
1865
+ }
1866
+ catch (err) {
1867
+ logger.warn(`[sygnal] sygnal-check failed: ${err?.message || err}`, { timestamp: true });
1868
+ return;
1869
+ }
1499
1870
  }
1500
1871
  // A sygnal-check from before D144 reports SYG7xx as errors under strict: keep them warnings
1501
1872
  // (one with config support postdates D144 and resolves a11y itself)
@@ -1536,7 +1907,15 @@ async function startChecker(server, root, opts, defaults, explicit) {
1536
1907
  if (!initial)
1537
1908
  broadcast(changed);
1538
1909
  };
1539
- run(true);
1910
+ if (engine) {
1911
+ // every build of the engine: the first on start, then after changes (its own watcher)
1912
+ let first = !engine.last;
1913
+ engine.onBuild((b) => { const initial = first; first = false; run(initial, b.diagnostics || []); });
1914
+ if (engine.last)
1915
+ run(true, engine.last.diagnostics || []);
1916
+ }
1917
+ else
1918
+ run(true);
1540
1919
  // A page that loads (or reloads) asks for the current findings: answer
1541
1920
  // that client only, never every client.
1542
1921
  try {
@@ -1551,6 +1930,8 @@ async function startChecker(server, root, opts, defaults, explicit) {
1551
1930
  });
1552
1931
  }
1553
1932
  catch (_) { }
1933
+ if (engine)
1934
+ return;
1554
1935
  let timer;
1555
1936
  const onChange = (file) => {
1556
1937
  if (file.split(/[\\/]/).includes('node_modules'))
@@ -1573,6 +1954,8 @@ async function startChecker(server, root, opts, defaults, explicit) {
1573
1954
  }
1574
1955
  const DEV_FLAG = 'if (globalThis.__SYGNAL_DEV__ === undefined) globalThis.__SYGNAL_DEV__ = true;';
1575
1956
  const STRICT_FLAG = 'if (globalThis.__SYGNAL_STRICT__ === undefined) globalThis.__SYGNAL_STRICT__ = true;';
1957
+ // 7.0: the opt-in timing audit (SYG257, 'sygnal/diagnostics' checks/timingAudit.ts)
1958
+ const AUDIT_FLAG = 'if (globalThis.__SYGNAL_TIMING_AUDIT__ === undefined) globalThis.__SYGNAL_TIMING_AUDIT__ = true;';
1576
1959
  // A top-level (column 0) run(Component, ...) statement: bare, or declaring a
1577
1960
  // new binding. Assignments to existing variables, nested calls and indented
1578
1961
  // calls (e.g. inside a function or test callback) do not match (B-007). It is