@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.196.1

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 (106) hide show
  1. package/README.md +479 -80
  2. package/config/portal-route-roots.json +48 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +29072 -2784
  4. package/dist/base-skills/notis-apps/SKILL.md +88 -67
  5. package/dist/base-skills/notis-apps/references/architecture.md +114 -167
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +104 -142
  10. package/dist/base-skills/notis-apps/references/sdk.md +113 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +142 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +4 -4
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  17. package/dist/base-skills/notis-query/SKILL.md +34 -60
  18. package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
  19. package/dist/base-skills/notis-query/references/documents.md +63 -50
  20. package/dist/base-skills/notis-query/references/query.md +57 -524
  21. package/dist/sdk/src/bundleModule.ts +79 -0
  22. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  23. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  24. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  25. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  26. package/dist/sdk/src/config.ts +4 -26
  27. package/dist/sdk/src/documents.ts +2 -0
  28. package/dist/sdk/src/hooks/useAction.ts +28 -0
  29. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  30. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  31. package/dist/sdk/src/hooks/useHandover.ts +5 -4
  32. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  33. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  34. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  35. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  36. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  37. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  38. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  39. package/dist/sdk/src/index.ts +36 -0
  40. package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
  41. package/dist/sdk/src/nativeData.ts +39 -0
  42. package/dist/sdk/src/presentation.tsx +82 -0
  43. package/dist/sdk/src/provider.tsx +9 -4
  44. package/dist/sdk/src/queryCache.ts +5 -3
  45. package/dist/sdk/src/queryErrors.ts +51 -0
  46. package/dist/sdk/src/runtime.ts +146 -8
  47. package/dist/sdk/src/space.ts +425 -0
  48. package/dist/sdk/src/styles.css +96 -72
  49. package/dist/sdk/src/vite.ts +11 -1
  50. package/dist/skill-sync/index.js +458 -68
  51. package/dist/skill-sync/index.js.map +4 -4
  52. package/dist/skill-sync-worker.mjs +451 -77
  53. package/dist/space-harness/host.js +66 -0
  54. package/dist/view-renderer/dist/host.css +1 -0
  55. package/dist/view-renderer/dist/host.js +1042 -0
  56. package/dist/view-renderer/dist/theme.css +73 -0
  57. package/dist/view-renderer/src/dom.js +180 -0
  58. package/dist/view-renderer/src/errors.js +8 -0
  59. package/dist/view-renderer/src/host.tsx +173 -0
  60. package/dist/view-renderer/src/index.js +4 -0
  61. package/dist/view-renderer/src/pool.js +76 -0
  62. package/dist/view-renderer/src/remote-broker.js +59 -0
  63. package/dist/view-renderer/src/renderer.js +201 -0
  64. package/package.json +11 -5
  65. package/skills/notis-apps/cli.md +270 -163
  66. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +2 -2
  67. package/skills/notis-onboarding/BRIEF.md +7 -3
  68. package/src/command-specs/apps.js +1 -8
  69. package/src/command-specs/files.js +29 -0
  70. package/src/command-specs/helpers.js +1 -0
  71. package/src/command-specs/index.js +10 -4
  72. package/src/command-specs/skills.js +229 -0
  73. package/src/command-specs/spaces.js +572 -0
  74. package/src/command-specs/views.js +69 -0
  75. package/src/runtime/agent-browser.js +30 -7
  76. package/src/runtime/app-boundary-validator.js +7 -4
  77. package/src/runtime/app-data-literals.js +82 -0
  78. package/src/runtime/app-platform.js +321 -253
  79. package/src/runtime/confined-space-build.js +74 -0
  80. package/src/runtime/confined-space-verify.js +49 -0
  81. package/src/runtime/native-record-files.js +47 -0
  82. package/src/runtime/retired-sdk-files.js +20 -0
  83. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  84. package/src/runtime/skill-sync/index.ts +154 -35
  85. package/src/runtime/skill-sync/local-scanner.ts +125 -17
  86. package/src/runtime/skill-sync/native-identity.ts +102 -0
  87. package/src/runtime/skill-sync/native-plan.ts +59 -0
  88. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  89. package/src/runtime/skill-sync/types.ts +32 -6
  90. package/src/runtime/source-root.js +32 -0
  91. package/src/runtime/space-collection-scaffold.js +66 -0
  92. package/src/runtime/space-database-structures.js +229 -0
  93. package/src/runtime/space-fixture-broker.js +50 -0
  94. package/src/runtime/space-lock.js +337 -0
  95. package/src/runtime/space-platform.js +105 -0
  96. package/src/runtime/space-source.js +281 -0
  97. package/src/runtime/space-test-server.js +327 -0
  98. package/src/runtime/space-verification.js +89 -0
  99. package/src/runtime/space-verify-browser-worker.js +51 -0
  100. package/src/runtime/space-view-manifest.js +254 -0
  101. package/src/runtime/sync-skills.js +1 -1
  102. package/src/runtime/transport.js +8 -1
  103. package/src/runtime/view-render.js +49 -0
  104. package/src/space-harness-host.ts +103 -0
  105. package/template/app/globals.css +1 -0
  106. package/src/command-specs/reports.js +0 -97
@@ -0,0 +1,73 @@
1
+
2
+ /*
3
+ * Portal-parity theme tokens. In production the portal copies its computed
4
+ * :root custom properties onto the app root, so app CSS written against
5
+ * hsl(var(--token)) resolves to the real portal palette. The harness mirrors
6
+ * the portal's light theme here so dev renders and listing screenshots look
7
+ * exactly like the app inside the portal. Keep in sync with
8
+ * portal/src/app/globals.css (:root).
9
+ */
10
+ :root {
11
+ color-scheme: light;
12
+ --background: 0 0% 100%;
13
+ --foreground: 222.2 84% 4.9%;
14
+ --card: 0 0% 100%;
15
+ --card-foreground: 222.2 84% 4.9%;
16
+ --popover: 0 0% 100%;
17
+ --popover-foreground: 222.2 84% 4.9%;
18
+ --primary: 222.2 47.4% 11.2%;
19
+ --primary-foreground: 210 40% 98%;
20
+ --secondary: 210 40% 96.1%;
21
+ --secondary-foreground: 222.2 47.4% 11.2%;
22
+ --muted: 210 40% 96.1%;
23
+ --muted-foreground: 0 0% 46.9%;
24
+ --accent: 210 40% 96.1%;
25
+ --accent-foreground: 222.2 47.4% 11.2%;
26
+ --destructive: 0 84.2% 60.2%;
27
+ --destructive-foreground: 210 40% 98%;
28
+ --border: 214.3 31.8% 91.4%;
29
+ --input: 214.3 31.8% 91.4%;
30
+ --ring: 222.2 84% 4.9%;
31
+ --radius: 0.5rem;
32
+ --chart-1: 210 100% 49%;
33
+ --chart-2: 173 58% 39%;
34
+ --chart-3: 197 37% 24%;
35
+ --chart-4: 43 74% 66%;
36
+ --chart-5: 27 87% 67%;
37
+ }
38
+ :root.dark {
39
+ color-scheme: dark;
40
+ --background: 0 0% 13%;
41
+ --foreground: 210 40% 98%;
42
+ --card: 0 0% 13%;
43
+ --card-foreground: 210 40% 98%;
44
+ --popover: 0 0% 13%;
45
+ --popover-foreground: 210 40% 98%;
46
+ --primary: 210 40% 98%;
47
+ --primary-foreground: 222.2 47.4% 11.2%;
48
+ --secondary: 0 0% 18%;
49
+ --secondary-foreground: 210 40% 98%;
50
+ --muted: 0 0% 18%;
51
+ --muted-foreground: 0 0% 65.1%;
52
+ --accent: 0 0% 18%;
53
+ --accent-foreground: 210 40% 98%;
54
+ --destructive: 0 84.2% 60.2%;
55
+ --destructive-foreground: 210 40% 98%;
56
+ --border: 0 0% 23.5%;
57
+ --input: 0 0% 23.5%;
58
+ --ring: 212.7 26.8% 83.9%;
59
+ --chart-1: 220 70% 50%;
60
+ --chart-2: 160 60% 45%;
61
+ --chart-3: 30 80% 55%;
62
+ --chart-4: 280 65% 60%;
63
+ --chart-5: 340 75% 55%;
64
+ }
65
+ body {
66
+ margin: 0;
67
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
68
+ background: hsl(var(--background));
69
+ color: hsl(var(--foreground));
70
+ -webkit-font-smoothing: antialiased;
71
+ }
72
+ #root { min-height: 100vh; }
73
+
@@ -0,0 +1,180 @@
1
+ /** Evaluated in Chromium: includes open Shadow roots and explicitly declared chart semantics. */
2
+ export function extractRenderedMarkdown({ baseUrl = document.baseURI } = {}) {
3
+ const space = value => String(value ?? '').replace(/\s+/g, ' ').trim();
4
+ const escape = value => space(value).replaceAll('\\', '\\\\').replaceAll('|', '\\|');
5
+ const link = value => {
6
+ try { const url = new URL(value, baseUrl); return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : ''; }
7
+ catch { return ''; }
8
+ };
9
+ const visible = node => {
10
+ const style = getComputedStyle(node);
11
+ return !node.hidden && node.getAttribute('aria-hidden') !== 'true' && style.display !== 'none' && style.visibility !== 'hidden';
12
+ };
13
+ const labelledBy = node => (node.getAttribute('aria-labelledby') || '').split(' ')
14
+ .map(id => node.getRootNode().getElementById?.(id)?.textContent || '').join(' ').trim();
15
+ const label = node => node.getAttribute('aria-label') || labelledBy(node)
16
+ || (node.id ? [...node.getRootNode().querySelectorAll('label')].find(label => label.htmlFor === node.id)?.textContent : '')
17
+ || node.closest('label')?.textContent || node.getAttribute('placeholder') || node.name || 'Field';
18
+ const table = element => {
19
+ const rows = [...element.querySelectorAll('tr')].map(row => [...row.children].filter(cell => ['TD', 'TH'].includes(cell.tagName)).map(cell => escape([...cell.childNodes].map(walk).join(''))));
20
+ if (!rows.length) return '';
21
+ const columns = Math.max(...rows.map(row => row.length));
22
+ const padded = rows.map(row => [...row, ...Array(Math.max(0, columns - row.length)).fill('')]);
23
+ const header = element.querySelector('tr')?.querySelector('th') ? padded.shift() : Array(columns).fill('');
24
+ return '\n\n' + [header, Array(columns).fill('---'), ...padded].map(row => '| ' + row.join(' | ') + ' |').join('\n') + '\n\n';
25
+ };
26
+ const children = element => [...(element.shadowRoot || element).childNodes].map(walk).join('');
27
+ // RenderChartData: the chart's label line, then its exact values as a table.
28
+ const chartTable = node => '\n\n' + space(node.getAttribute('aria-label')) + '\n' + table(node);
29
+ // An SVG or canvas chart contributes its accessible name and any declared chart
30
+ // data inside it, never mark tooltips or geometry. SVG elements keep a lowercase
31
+ // tagName in HTML documents, so they are matched by localName. A drawing with no
32
+ // name (a diagram) keeps its visible labels, one per line.
33
+ const graphic = node => {
34
+ const own = [...node.children].find(child => child.localName === 'title');
35
+ const name = space(node.getAttribute('aria-label') || labelledBy(node) || own?.textContent);
36
+ const charts = [...node.querySelectorAll('[data-notis-render="chart-data"]')].map(chartTable).join('');
37
+ if (name || node.localName !== 'svg') return (name ? '\n\n' + name + '\n\n' : '') + charts;
38
+ const labels = [...node.querySelectorAll('text, foreignObject')].filter(visible)
39
+ .map(item => item.localName === 'text' ? space(item.textContent) : children(item));
40
+ return '\n\n' + labels.join('\n\n') + '\n\n';
41
+ };
42
+ const descriptionList = element => {
43
+ const lines = [], terms = [], definitions = [];
44
+ const flush = () => {
45
+ if (terms.length || definitions.length) lines.push('- ' + (terms.length ? terms.join(' / ') + ': ' : '') + definitions.join('; '));
46
+ terms.length = 0; definitions.length = 0;
47
+ };
48
+ // SDK properties wrap each dt/dd pair in a div. Keep those pairs and HTML's
49
+ // multi-term/multi-definition groups, but exclude nested lists and hidden wrappers.
50
+ for (const node of element.querySelectorAll('dt,dd')) {
51
+ if (node.closest('dl') !== element) continue;
52
+ let shown = true;
53
+ for (let current = node; current && current !== element; current = current.parentElement) {
54
+ if (!visible(current)) { shown = false; break; }
55
+ }
56
+ if (!shown) continue;
57
+ if (node.tagName === 'DT') {
58
+ if (definitions.length) flush();
59
+ terms.push(space(children(node)));
60
+ } else definitions.push(space(children(node)));
61
+ }
62
+ flush();
63
+ return '\n\n' + lines.join('\n') + '\n\n';
64
+ };
65
+ function walk(node) {
66
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent || '';
67
+ if (node.nodeType !== Node.ELEMENT_NODE) return '';
68
+ const tag = node.tagName;
69
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'LINK', 'META'].includes(tag)) return '';
70
+ const semantic = node.getAttribute('data-notis-render');
71
+ if (semantic === 'markdown') return '\n\n' + (node.textContent || '') + '\n\n';
72
+ if (semantic === 'chart-data') return chartTable(node);
73
+ if (!visible(node)) return '';
74
+ if (tag === 'TABLE') return table(node);
75
+ if (tag === 'DL') return descriptionList(node);
76
+ if (/^H[1-6]$/.test(tag)) return '\n\n' + '#'.repeat(Number(tag[1])) + ' ' + space(children(node)) + '\n\n';
77
+ if (tag === 'A') { const url = link(node.getAttribute('href')); const text = space(children(node)); return url ? `[${text || url}](${url})` : text; }
78
+ if (tag === 'IMG') { const url = link(node.currentSrc || node.src); return url ? `![${escape(node.alt)}](${url})` : space(node.alt); }
79
+ if (tag === 'PRE') return '\n\n```' + (node.getAttribute('data-language') || '') + '\n' + node.textContent.trim() + '\n```\n\n';
80
+ if (tag === 'CODE') return '`' + node.textContent.replaceAll('`', '\\`') + '`';
81
+ if (['STRONG', 'B'].includes(tag)) return '**' + children(node) + '**';
82
+ if (['EM', 'I'].includes(tag)) return '*' + children(node) + '*';
83
+ if (tag === 'BR') return '\n';
84
+ if (tag === 'HR') return '\n\n---\n\n';
85
+ if (tag === 'LI') {
86
+ const nested = [...node.children].filter(child => ['UL', 'OL'].includes(child.tagName));
87
+ const text = space([...node.childNodes].filter(child => !nested.includes(child)).map(walk).join(''));
88
+ const prefix = node.parentElement?.tagName === 'OL' ? `${[...node.parentElement.children].indexOf(node) + 1}. ` : '- ';
89
+ return '\n' + prefix + text + nested.map(list => children(list).trim().split('\n').map(line => '\n ' + line).join('')).join('');
90
+ }
91
+ if (tag === 'BLOCKQUOTE') return '\n\n' + children(node).trim().split('\n').map(line => '> ' + line).join('\n') + '\n\n';
92
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) {
93
+ if (['password', 'hidden'].includes(node.type)) return '';
94
+ const value = node.type === 'checkbox' || node.type === 'radio' ? (node.checked ? '[x]' : '[ ]')
95
+ : tag === 'SELECT' ? [...node.selectedOptions].map(option => option.text).join(', ') : node.value;
96
+ return '\n\n' + escape(label(node)) + ': ' + escape(value) + '\n\n';
97
+ }
98
+ if (tag === 'BUTTON') return ' ' + space(node.textContent) + ' ';
99
+ if (node.localName === 'svg' || tag === 'CANVAS') return graphic(node);
100
+ const content = children(node);
101
+ return ['P', 'DIV', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER', 'MAIN', 'UL', 'OL', 'FIGURE'].includes(tag) ? '\n\n' + content + '\n\n' : content;
102
+ }
103
+ return walk(document.body).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
104
+ }
105
+
106
+ /** Bounded capture expands clipped content; no 1500px crop or hidden inner scroll tail. */
107
+ export function expandScrollAreas({ maxHeight = 60000 } = {}) {
108
+ const nodes = [];
109
+ const visit = root => { for (const node of root.querySelectorAll('*')) { nodes.push(node); if (node.shadowRoot) visit(node.shadowRoot); } };
110
+ visit(document);
111
+ let expanded = 0;
112
+ for (const node of nodes.reverse()) {
113
+ const style = getComputedStyle(node);
114
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
115
+ if (node.scrollHeight > maxHeight) return { tooLarge: true, height: node.scrollHeight };
116
+ // Descendants come first, so an ancestor holding an expanded area (even with
117
+ // visible overflow, or a height frozen by freezeFrameViewportSizes) grows with it
118
+ // instead of letting the following content paint over the expanded tail.
119
+ if (node.scrollHeight > node.clientHeight + 2 && (/(auto|scroll|hidden|clip)/.test(style.overflowY) || node.hasAttribute('data-notis-render-expanded')
120
+ || node.querySelector('[data-notis-render-expanded]') || node.shadowRoot?.querySelector('[data-notis-render-expanded]'))) {
121
+ node.setAttribute('data-notis-render-expanded', 'true');
122
+ node.style.setProperty('height', `${node.scrollHeight}px`, 'important');
123
+ node.style.setProperty('max-height', 'none', 'important');
124
+ node.style.setProperty('overflow-y', 'visible', 'important');
125
+ const parent = node.parentElement || node.getRootNode().host;
126
+ const parentStyle = parent && getComputedStyle(parent);
127
+ // A column flex item sizes from its basis, not its height: flex-1 (basis 0%)
128
+ // would keep the expanded area at its old size. Only the vertical axis may
129
+ // change; a row child's basis/shrink would widen it and stop copy wrapping.
130
+ if (parentStyle?.display.includes('flex') && parentStyle.flexDirection.startsWith('column')) {
131
+ node.style.setProperty('flex-shrink', '0', 'important');
132
+ node.style.setProperty('flex-basis', 'auto', 'important');
133
+ }
134
+ expanded++;
135
+ }
136
+ if (style.position === 'fixed' || style.position === 'sticky') node.style.setProperty('position', 'static', 'important');
137
+ }
138
+ document.documentElement.style.setProperty('height', 'auto', 'important');
139
+ document.body.style.setProperty('height', 'auto', 'important');
140
+ const height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
141
+ return { expanded, height, tooLarge: height > maxHeight };
142
+ }
143
+
144
+ export function restrictBrowserTransports() {
145
+ const blocked = function () { throw new Error('External transports are unavailable in a read-only render.'); };
146
+ for (const key of ['RTCPeerConnection', 'webkitRTCPeerConnection', 'Worker', 'SharedWorker']) {
147
+ try { Object.defineProperty(globalThis, key, { value: blocked, writable: false, configurable: false }); } catch {}
148
+ }
149
+ }
150
+
151
+ /** Preserve each child frame's original viewport-sized layout while enlarging
152
+ * its capture box. Otherwise a 100vh child grows again on every iframe resize. */
153
+ export function freezeFrameViewportSizes() {
154
+ const visit = root => {
155
+ for (const node of root.querySelectorAll('*')) {
156
+ const style = getComputedStyle(node);
157
+ if (style.display !== 'none') {
158
+ for (const key of ['height', 'min-height', 'max-height']) {
159
+ const value = style.getPropertyValue(key);
160
+ if (/^[0-9.]+px$/.test(value) && Number.parseFloat(value) > 0) node.style.setProperty(key, value, 'important');
161
+ }
162
+ }
163
+ if (node.shadowRoot) visit(node.shadowRoot);
164
+ }
165
+ };
166
+ visit(document);
167
+ }
168
+
169
+ export async function settleFrameImages() {
170
+ const images = [];
171
+ const collect = root => { for (const element of root.querySelectorAll('*')) {
172
+ if (element.tagName === 'IMG' && element.getAttribute('src')) images.push(element);
173
+ if (element.shadowRoot) collect(element.shadowRoot);
174
+ } };
175
+ collect(document);
176
+ return (await Promise.all(images.map(async image => {
177
+ image.loading = 'eager';
178
+ try { await image.decode(); return true; } catch { return false; }
179
+ }))).every(Boolean);
180
+ }
@@ -0,0 +1,8 @@
1
+ export class ViewRenderError extends Error {
2
+ constructor(code, message, status = 409) { super(message); this.name = 'ViewRenderError'; this.code = code; this.status = status; }
3
+ }
4
+ export function renderFailure(code = 'render_failed') {
5
+ // Browser/source errors may contain private content. Never return them before
6
+ // final authority revalidation, or log tokens/source/request arguments.
7
+ return new ViewRenderError(code, 'The view could not be rendered. Reopen its current link and try again.');
8
+ }
@@ -0,0 +1,173 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import * as ReactDOM from 'react-dom';
3
+ import * as ReactDOMClient from 'react-dom/client';
4
+ import * as JSXRuntime from 'react/jsx-runtime';
5
+ import { BlockNoteEditor } from '@blocknote/core';
6
+ import { BlockNoteView } from '@blocknote/ariakit';
7
+ import { useCreateBlockNote } from '@blocknote/react';
8
+ import '@blocknote/core/fonts/inter.css';
9
+ import '@blocknote/react/style.css';
10
+ import '@blocknote/ariakit/style.css';
11
+ import { getNotisBlockNoteEditorOptions, getDocumentEditorInitialContent, NOTIS_BLOCKNOTE_TABLE_OPTIONS } from '../../../portal/src/lib/blockNote';
12
+ import { importBundleSource, resolveBundleRouteExport } from '../../sdk/src/bundleModule';
13
+ import { SdkPresentation } from '../../sdk/src/presentation';
14
+ import { createQueryClient } from '../../sdk/src/queryCache';
15
+ import { Markdown } from '../../sdk/src/components/Markdown';
16
+ import { useNotisRuntime } from '../../sdk/src/provider';
17
+
18
+ const state = { mounted: false, pending: 0, blocked: [] as string[], lastActivity: performance.now(), failed: false };
19
+ Object.assign(window, { __notisRenderState: state, React, ReactDOM, ReactDOMClient, __ReactJSXRuntime: JSXRuntime });
20
+ let sequence = 0;
21
+ let locale = 'en';
22
+ const copy = { en: { body: 'Document body', unavailable: 'Record unavailable', opening: 'Opening record…', html: 'HTML document', view: 'View unavailable' },
23
+ fr: { body: 'Contenu du document', unavailable: 'Enregistrement indisponible', opening: 'Ouverture de l’enregistrement…', html: 'Document HTML', view: 'Vue indisponible' } };
24
+ const text = key => copy[locale === 'fr' ? 'fr' : 'en'][key];
25
+ const changed = () => { state.lastActivity = performance.now(); };
26
+ function denied() {
27
+ const error = Object.assign(new Error('This operation is unavailable in a read-only render.'), { code: 'render_read_only' });
28
+ state.blocked.push(error.code); changed();
29
+ return Promise.reject(error);
30
+ }
31
+ window.addEventListener('unhandledrejection', event => { if (event.reason?.code === 'render_read_only') event.preventDefault(); else state.failed = true; });
32
+ async function read(payload) {
33
+ state.pending++; changed();
34
+ try {
35
+ const response = await fetch('/__read', { method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
36
+ const result = await response.json();
37
+ if (!response.ok) throw Object.assign(new Error(result.message || 'This read is unavailable.'), { code: result.error });
38
+ return result.result;
39
+ } finally { state.pending--; changed(); }
40
+ }
41
+ function useRead(payload) {
42
+ const key = JSON.stringify(payload);
43
+ const [loaded, setLoaded] = useState<{ key: string; value?: any; failed?: boolean }>();
44
+ useEffect(() => { let active = true; void read(payload).then(value => { if (active) setLoaded({ key, value }); })
45
+ .catch(() => { if (active) setLoaded({ key, failed: true }); }); return () => { active = false; }; }, [key]);
46
+ return loaded?.key === key ? loaded : {};
47
+ }
48
+ function PropertyList({ value }) {
49
+ const fields = Object.values(value.schema?.properties || {}) as any[];
50
+ const text = (property: any) => {
51
+ if (!property) return '';
52
+ const item = property[property.type];
53
+ if (['rich_text', 'title', 'text'].includes(property.type)) return (property.rich_text || property.title || []).map(part => part.plain_text || part.text?.content || '').join('');
54
+ if (Array.isArray(item)) return item.map(value => value.name || value.url || value.external?.url || value.id || '').join(', ');
55
+ if (item && typeof item === 'object') return item.name || item.start || '';
56
+ return item == null ? '' : String(item);
57
+ };
58
+ return <dl>{fields.filter(field => field.kind !== 'title').map(field => <div key={field.id}>
59
+ <dt>{field.name}</dt><dd>{text(value.document?.properties?.[field.name])}</dd>
60
+ </div>)}</dl>;
61
+ }
62
+ function ReadOnlyBlocks({ blocks }) {
63
+ // Pure snapshot editor: no collaboration provider/token/init or uploadFile.
64
+ const editor = useCreateBlockNote({ ...getNotisBlockNoteEditorOptions(),
65
+ initialContent: getDocumentEditorInitialContent(blocks, false), tables: NOTIS_BLOCKNOTE_TABLE_OPTIONS,
66
+ domAttributes: { editor: { 'aria-label': text('body') } },
67
+ }, [blocks]);
68
+ return <BlockNoteView editor={editor} editable={false} theme="light" />;
69
+ }
70
+ function DocumentEditor({ recordKey, variant = 'full', showProperties = true, className }) {
71
+ const loaded = useRead({ operation: 'record', record_key: recordKey });
72
+ if (!loaded.value) return <div role="status">{text(loaded.failed ? 'unavailable' : 'opening')}</div>;
73
+ const value = loaded.value;
74
+ if (value.document?.view_type === 'html') return <HtmlFrame recordKey={recordKey} className={className} />;
75
+ if (value.document?.view_type === 'report') return <ReportFrame recordKey={recordKey} className={className} />;
76
+ return <article className={className}>{variant === 'full' ? <h1>{value.document?.title}</h1> : null}
77
+ {showProperties ? <PropertyList value={value} /> : null}<ReadOnlyBlocks blocks={value.document?.content_blocknote} /></article>;
78
+ }
79
+ function RecordProperties({ recordKey, className }) {
80
+ const loaded = useRead({ operation: 'record', record_key: recordKey });
81
+ return <div className={className}>{loaded.value ? <PropertyList value={loaded.value} /> : null}</div>;
82
+ }
83
+ function HtmlFrame(props) {
84
+ return props.html !== undefined ? <HtmlValue html={props.html} title={props.title} className={props.className} /> : <HtmlRecord {...props} />;
85
+ }
86
+ function HtmlRecord({ recordKey, title, className }) {
87
+ const loaded = useRead({ operation: 'html', record_key: recordKey });
88
+ return loaded.value ? <HtmlValue html={loaded.value.html} title={title || loaded.value.title} className={className} /> : null;
89
+ }
90
+ function HtmlValue({ html, title, className }) {
91
+ return <iframe sandbox="allow-scripts" referrerPolicy="no-referrer" title={title || text('html')} srcDoc={html} className={className} style={{ width: '100%', height: 900, border: 0 }} />;
92
+ }
93
+ function ReportFrame({ recordKey, className }) {
94
+ const loaded = useRead({ operation: 'report', record_key: recordKey });
95
+ const [module, setModule] = useState<any>();
96
+ useEffect(() => { let active = true; if (loaded.value?.source) { state.pending++; changed();
97
+ void importBundleSource(loaded.value.source).then(value => {
98
+ if (!resolveBundleRouteExport(value, [loaded.value.export_name])) { state.failed = true; return; }
99
+ if (active) setModule(value);
100
+ }).catch(() => { state.failed = true; }).finally(() => { state.pending--; changed(); });
101
+ } return () => { active = false; }; }, [loaded.value]);
102
+ const View = module && loaded.value ? resolveBundleRouteExport(module, [loaded.value.export_name])?.component : null;
103
+ return <div className={className}>{loaded.value?.css ? <style>{loaded.value.css}</style> : null}{View ? <View /> : null}</div>;
104
+ }
105
+
106
+ async function start() {
107
+ const response = await fetch('/__snapshot', { credentials: 'omit' });
108
+ if (!response.ok) throw new Error('Render unavailable');
109
+ const snapshot = await response.json();
110
+ locale = snapshot.locale || 'en';
111
+ document.documentElement.lang = locale;
112
+ const authority = structuredClone(snapshot.descriptor.space);
113
+ const shown = {};
114
+ let activeMarkdown: string | null = null;
115
+ const runtime = {
116
+ ...snapshot.descriptor, databases: [], queryClient: createQueryClient({ maxEntries: 100 }),
117
+ context: { ...snapshot.descriptor.context, locale, params: Object.fromEntries(Object.entries(snapshot.params || {}).map(([key, value]) => [key, String(value)])), renderReadOnly: true },
118
+ listTools: denied, callTool: denied, request: denied, navigate: denied,
119
+ async documentBody(request) {
120
+ if (request?.operation !== 'read') return denied();
121
+ state.pending++; changed();
122
+ try {
123
+ const value = await read({ operation: 'document_body', record_key: request.recordKey, binding: request.binding, read_action: request.readAction });
124
+ if (typeof value.content_markdown === 'string') return value; // Pure offline body fixtures, never a writable room.
125
+ const editor = BlockNoteEditor.create({ ...getNotisBlockNoteEditorOptions(), tables: NOTIS_BLOCKNOTE_TABLE_OPTIONS });
126
+ try { return { ...value, content_markdown: await editor.blocksToMarkdownLossy(value.content_blocknote) }; }
127
+ finally { editor._tiptapEditor.destroy(); }
128
+ } finally { state.pending--; changed(); }
129
+ },
130
+ handover: undefined, draftHandover: undefined, addContext: denied, updateContext: denied, removeContext: denied,
131
+ captureContextSelection() {}, registerTopBarSearch() {}, setTopBarSearchValue() {}, setTopBarSearchLoading() {},
132
+ publishActiveResource(value) { activeMarkdown = value?.snapshot?.format === 'markdown' && typeof value.snapshot.content === 'string' ? value.snapshot.content : null; },
133
+ // The document page renders as its read-only snapshot: no toolbar, live session or saves.
134
+ ui: { DocumentEditor, RecordProperties, HtmlFrame, ReportFrame, ShareControl: () => null,
135
+ DocumentPage: ({ recordKey, className, header, properties }) => <DocumentEditor recordKey={recordKey} className={className}
136
+ variant={header === false ? 'body' : 'full'} showProperties={properties !== 'hidden'} />,
137
+ MarkdownEditor: ({ value }) => <Markdown value={value} /> },
138
+ callAction(id, inputs = {}) {
139
+ if (!Object.hasOwn(authority.actions, id) || authority.actions[id].readOnly !== true) return denied();
140
+ return read({ operation: 'action', action_id: id, inputs, request_id: String(++sequence) });
141
+ },
142
+ async shown(name, params, options = {}) {
143
+ const list = authority.shows?.[name];
144
+ if (!list || !('database' in list)) return denied();
145
+ const values = { ...(options.sorts ? { sorts: options.sorts } : {}), ...(options.pageSize ? { page_size: options.pageSize } : {}),
146
+ ...(options.offset ? { offset: options.offset } : {}), ...(options.includeContent ? { include_content: true } : {}) };
147
+ const result = await read({ operation: 'shown', name, params, options: values }); shown[name] = result; return result;
148
+ },
149
+ viewerRead: (name, input = {}) => read({ operation: 'viewer_read', name, input }),
150
+ };
151
+ const module = await importBundleSource(snapshot.source);
152
+ const view = resolveBundleRouteExport(module, ['SpaceView'], true);
153
+ if (!view) throw new Error('View unavailable');
154
+ Object.assign(window, { __notisRenderMarkdown: async () => {
155
+ if (snapshot.markdownExport) {
156
+ if (typeof module[snapshot.markdownExport] !== 'function') throw new Error('The declared Markdown export is unavailable.');
157
+ return await (module[snapshot.markdownExport] as any)({ params: snapshot.params, shown });
158
+ }
159
+ return activeMarkdown;
160
+ } });
161
+ const host = document.createElement('div'); host.style.cssText = 'height:100vh;min-height:100vh';
162
+ const shadow = host.attachShadow({ mode: 'open' });
163
+ const styles = document.createElement('style'); styles.textContent = (snapshot.hostCss || '') + '\n' + snapshot.css; shadow.appendChild(styles);
164
+ const mount = document.createElement('div'); mount.style.cssText = 'height:100%;min-height:0'; shadow.appendChild(mount);
165
+ document.body.appendChild(host);
166
+ const controller = document.createElement('div'); document.body.appendChild(controller);
167
+ ReactDOMClient.createRoot(controller).render(<SdkPresentation mount={mount} runtime={runtime as any} component={view.component}
168
+ shell={module.__AppShell as any} theme={{ mode: 'light', style: { colorScheme: 'light' } }} resetKey={snapshot.cacheKey}
169
+ fallback={() => <div role="alert">{text('view')}</div>} onError={() => { state.failed = true; }}
170
+ onProtocolError={() => { state.blocked.push('render_read_only'); changed(); }}
171
+ onCommit={mounted => { state.mounted = mounted; changed(); }} />);
172
+ }
173
+ void start().catch(() => { state.failed = true; });
@@ -0,0 +1,4 @@
1
+ export { renderView, RENDER_ORIGIN } from './renderer.js';
2
+ export { createBrowserPool, browserEnvironment } from './pool.js';
3
+ export { createRemoteRenderBroker } from './remote-broker.js';
4
+ export { ViewRenderError } from './errors.js';
@@ -0,0 +1,76 @@
1
+ import { ViewRenderError } from './errors.js';
2
+
3
+ export function browserEnvironment(environment = process.env) {
4
+ const names = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'SystemRoot', 'WINDIR', 'DISPLAY', 'XDG_RUNTIME_DIR'];
5
+ return Object.fromEntries(names.filter(name => typeof environment[name] === 'string').map(name => [name, environment[name]]));
6
+ }
7
+
8
+ /** A warm process, never a shared profile, cookie jar, localStorage or context. */
9
+ export function createBrowserPool({ concurrency = 2, maxQueue = 8, launch = {}, chromium } = {}) {
10
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4 || !Number.isInteger(maxQueue) || maxQueue < 0 || maxQueue > 32) throw new TypeError('Choose a bounded render pool.');
11
+ let browser, starting, closing = false, inUse = 0;
12
+ const queue = new Set();
13
+ const contexts = new Set();
14
+ async function acquire(signal) {
15
+ if (closing || signal?.aborted) throw new ViewRenderError('render_cancelled', 'The render was cancelled.', 499);
16
+ if (inUse < concurrency) { inUse++; return; }
17
+ if (queue.size >= maxQueue) throw new ViewRenderError('render_busy', 'The renderer is busy. Try again shortly.', 503);
18
+ await new Promise((resolve, reject) => {
19
+ const entry = { resolve: () => { signal?.removeEventListener('abort', abort); resolve(); }, reject };
20
+ const abort = () => { queue.delete(entry); reject(new ViewRenderError('render_cancelled', 'The render was cancelled.', 499)); };
21
+ queue.add(entry); signal?.addEventListener('abort', abort, { once: true });
22
+ });
23
+ }
24
+ function release() {
25
+ const next = queue.values().next().value;
26
+ if (next) { queue.delete(next); next.resolve(); }
27
+ else inUse--;
28
+ }
29
+ async function getBrowser() {
30
+ if (browser?.isConnected()) return browser;
31
+ if (!starting) starting = (async () => {
32
+ // Importing CLI command metadata never loads Playwright or starts Chrome.
33
+ const engine = chromium || (await import('playwright-core')).chromium;
34
+ const allowed = Object.fromEntries(['executablePath', 'channel', 'timeout'].filter(key => key in launch).map(key => [key, launch[key]]));
35
+ try {
36
+ browser = await engine.launch({ ...allowed, headless: true, chromiumSandbox: true,
37
+ env: browserEnvironment(), args: ['--force-webrtc-ip-handling-policy=disable_non_proxied_udp', '--disable-background-networking'] });
38
+ } catch (error) {
39
+ if (/Executable doesn't exist/.test(String(error?.message))) throw new ViewRenderError('render_browser_missing', 'Install the pinned Chromium browser with npx playwright-core@1.63.0 install chromium.', 503);
40
+ throw new ViewRenderError('render_browser_unavailable', 'Sandboxed Chromium could not start.', 503);
41
+ }
42
+ return browser;
43
+ })().finally(() => { starting = undefined; });
44
+ return starting;
45
+ }
46
+ return {
47
+ warm: () => getBrowser(),
48
+ async use(operation, { signal, width = 1440, locale = 'en', timezone = 'UTC' } = {}) {
49
+ await acquire(signal);
50
+ let context;
51
+ const abort = () => { void context?.close(); };
52
+ try {
53
+ const browser = await getBrowser();
54
+ if (closing || signal?.aborted) throw new ViewRenderError('render_cancelled', 'The render was cancelled.', 499);
55
+ context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: 1,
56
+ locale, timezoneId: timezone, colorScheme: 'light', serviceWorkers: 'block', acceptDownloads: false, permissions: [] });
57
+ contexts.add(context); signal?.addEventListener('abort', abort, { once: true });
58
+ if (signal?.aborted) throw new ViewRenderError('render_cancelled', 'The render was cancelled.', 499);
59
+ return await operation(context);
60
+ } finally {
61
+ signal?.removeEventListener('abort', abort);
62
+ if (context) { contexts.delete(context); await context.close().catch(() => {}); }
63
+ release();
64
+ }
65
+ },
66
+ async close() {
67
+ closing = true;
68
+ for (const entry of queue) entry.reject(new ViewRenderError('render_cancelled', 'The renderer is stopping.', 503));
69
+ queue.clear();
70
+ await Promise.allSettled([...contexts].map(context => context.close()));
71
+ if (starting) await starting.catch(() => {});
72
+ await browser?.close();
73
+ },
74
+ stats: () => ({ active: inUse, queued: queue.size, contexts: contexts.size, warm: Boolean(browser?.isConnected()) }),
75
+ };
76
+ }
@@ -0,0 +1,59 @@
1
+ import { ViewRenderError } from './errors.js';
2
+
3
+ /** The purpose credential never enters request URLs, browser headers or source. */
4
+ export function createRemoteRenderBroker({ apiBase, credential, fetcher = fetch }) {
5
+ const base = new URL(apiBase);
6
+ if (!['https:', 'http:'].includes(base.protocol) || (base.protocol === 'http:' && !['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname))
7
+ || base.username || base.password || base.search || base.hash || typeof credential !== 'string' || !credential) throw new TypeError('Use a configured render API and its purpose credential.');
8
+ let claims;
9
+ try { claims = JSON.parse(Buffer.from(credential.split('.')[1], 'base64url').toString('utf8')); } catch {}
10
+ // This is rejection/identity checking only. Python performs cryptographic
11
+ // authentication and live authorization before returning any data.
12
+ if (claims?.type !== 'notis_view_render' || claims?.aud !== 'notis-view-render' || claims?.read_only !== true
13
+ || !claims?.jti || !claims?.target || typeof claims.url !== 'string') throw new ViewRenderError('render_credential_invalid', 'Use a dedicated read-only render credential, never a user JWT.', 401);
14
+ if (!Array.isArray(claims.outputs) || !claims.outputs.length || claims.outputs.some(value => !['markdown', 'screenshot'].includes(value))
15
+ || new Set(claims.outputs).size !== claims.outputs.length || ![390, 1440].includes(claims.width)) {
16
+ throw new ViewRenderError('render_credential_invalid', 'This credential does not pin render outputs.', 401);
17
+ }
18
+ const stable = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
19
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
20
+ const endpoint = path => new URL(`portal_view_render/${path}`, base.href.replace(/\/?$/, '/')).href;
21
+ async function request(operation, body, { signal } = {}) {
22
+ const response = await fetcher(endpoint(operation), { method: 'POST', redirect: 'error', credentials: 'omit', signal,
23
+ headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json', 'X-Notis-Spaces-Protocol': '1' },
24
+ body: JSON.stringify(body) });
25
+ let result;
26
+ try { result = await response.json(); } catch { throw new ViewRenderError('render_broker_unavailable', 'The render read could not be confirmed.', 503); }
27
+ if (!response.ok) throw new ViewRenderError(typeof result?.error === 'string' ? result.error : 'render_unavailable', 'This view is unavailable for rendering.', response.status);
28
+ return result;
29
+ }
30
+ return Object.freeze({
31
+ snapshot: async options => {
32
+ const value = await request('read', { operation: 'manifest' }, options);
33
+ if (value?.url !== claims.url || value?.view?.space_id !== claims.target.space_id || value.view.revision !== claims.target.revision
34
+ || (value.record_key || null) !== (claims.target.record_key || null) || stable(value.params) !== stable(claims.target.params)
35
+ || value.descriptor?.space?.id !== claims.target.space_id || value.descriptor.space.revision !== claims.target.revision
36
+ || value.descriptor?.resource?.id !== claims.target.space_id || value.descriptor.resource.revision !== claims.target.revision
37
+ || stable(value.render_options) !== stable({ outputs: claims.outputs, width: claims.width })) {
38
+ throw new ViewRenderError('render_manifest_invalid', 'The render manifest belongs to another view.', 502);
39
+ }
40
+ return value;
41
+ },
42
+ read: (payload, options) => request('read', payload, options),
43
+ finish: async options => {
44
+ const value = await request('finish', {}, options);
45
+ if (value?.valid !== true || value.render_id !== claims.jti || value.url !== claims.url) {
46
+ throw new ViewRenderError('render_access_changed', 'The final render authority is not confirmed.', 403);
47
+ }
48
+ return value;
49
+ },
50
+ complete: async (rendered, options) => {
51
+ const value = await request('complete', { rendered }, options);
52
+ if (value?.valid !== true || value.render_id !== claims.jti || value.url !== claims.url) {
53
+ throw new ViewRenderError('render_capture_unconfirmed', 'The completed capture could not be recorded.', 409);
54
+ }
55
+ // Attestation/receipts remain backend-only even if an endpoint regresses.
56
+ return { valid: true, render_id: value.render_id, url: value.url };
57
+ },
58
+ });
59
+ }