@notis_ai/cli 0.2.0-beta.1792679782834.1 → 0.2.0-beta.1792692436042.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 (45) hide show
  1. package/dist/agent-hooks/notis-agent-hook.mjs +119 -24
  2. package/dist/base-skills/notis-query/.notis-base-skill.json +1 -1
  3. package/dist/base-skills/notis-query/references/documents.md +9 -0
  4. package/dist/base-skills/notis-spaces/.notis-base-skill.json +1 -1
  5. package/dist/base-skills/notis-spaces/SKILL.md +4 -0
  6. package/dist/base-skills/notis-spaces/references/sdk.md +13 -2
  7. package/dist/sdk/src/components/Dialog.tsx +66 -9
  8. package/dist/sdk/src/components/RecordComponents.tsx +59 -1
  9. package/dist/sdk/src/index.ts +2 -1
  10. package/dist/sdk/src/runtime.ts +18 -0
  11. package/dist/sdk/src/styles.css +1 -20
  12. package/dist/sdk/src/ui/engine/core.ts +391 -0
  13. package/dist/sdk/src/ui/engine/dom.ts +178 -0
  14. package/dist/sdk/src/ui/engine/hooks.ts +193 -0
  15. package/dist/sdk/src/ui/engine/instance.ts +63 -0
  16. package/dist/sdk/src/ui/engine/keycapContext.ts +2 -0
  17. package/dist/sdk/src/ui/engine/registry.ts +262 -0
  18. package/dist/sdk/src/ui/engine/types.ts +62 -0
  19. package/dist/sdk/src/ui/util.ts +83 -0
  20. package/dist/sdk/src/words/actions.ts +295 -0
  21. package/dist/sdk/src/words/clashes.ts +158 -0
  22. package/dist/sdk/src/words/format.ts +136 -0
  23. package/dist/sdk/src/words/index.ts +13 -0
  24. package/dist/sdk/src/words/keymap.ts +72 -0
  25. package/dist/sdk/src/words/keys.ts +297 -0
  26. package/dist/sdk/src/words/shell.ts +46 -0
  27. package/dist/space-harness/host.js +87 -68
  28. package/dist/space-sdk/dist/chunk-XEA2ONCZ.js +3 -0
  29. package/dist/space-sdk/dist/index.js +4 -2
  30. package/dist/space-sdk/dist/ui.js +7 -8
  31. package/dist/space-sdk/src/components/Dialog.d.ts +9 -1
  32. package/dist/space-sdk/src/components/RecordComponents.d.ts +32 -1
  33. package/dist/space-sdk/src/index.d.ts +2 -2
  34. package/dist/space-sdk/src/runtime.d.ts +17 -0
  35. package/dist/space-sdk/src/tailwind-candidates.d.ts +23 -0
  36. package/dist/view-renderer/dist/host.js +156 -137
  37. package/dist/view-renderer/src/host.tsx +7 -1
  38. package/package.json +3 -3
  39. package/src/command-specs/spaces.js +3 -1
  40. package/src/runtime/app-boundary-validator.js +3 -1
  41. package/src/runtime/app-platform.js +61 -6
  42. package/src/runtime/space-fixture-broker.js +31 -17
  43. package/src/runtime/space-test-server.js +30 -4
  44. package/src/runtime/space-verification.js +6 -1
  45. package/dist/space-sdk/dist/chunk-LOZCVBHW.js +0 -2
@@ -38037,7 +38037,7 @@ function validateTextFile(relPath, content) {
38037
38037
  const extension = extname(relPath);
38038
38038
  const rules = getCompiledRules();
38039
38039
  if (extension === ".css" || extension === ".scss" || extension === ".sass" || extension === ".pcss") {
38040
- return applyRules(content, rules.css, relPath);
38040
+ return applyRules(maskComments(content, ".css"), rules.css, relPath);
38041
38041
  }
38042
38042
  if (extension === ".js" || extension === ".jsx" || extension === ".ts" || extension === ".tsx") {
38043
38043
  return applyRules(maskSerializedJsonData(content, extension), rules.javascript, relPath);
@@ -38442,8 +38442,55 @@ function resolveBuiltBundleDir(projectDir) {
38442
38442
  }
38443
38443
  return null;
38444
38444
  }
38445
+ function maskCssForSelectorMatching(css) {
38446
+ let masked = "";
38447
+ let index = 0;
38448
+ const skipString = (from) => {
38449
+ const quote = css[from];
38450
+ let end = from + 1;
38451
+ while (end < css.length && css[end] !== quote && css[end] !== "\n") end += css[end] === "\\" ? 2 : 1;
38452
+ return Math.min(end + 1, css.length);
38453
+ };
38454
+ while (index < css.length) {
38455
+ const char = css[index];
38456
+ if (char === "\\") {
38457
+ masked += css.slice(index, index + 2);
38458
+ index += 2;
38459
+ } else if (char === "/" && css[index + 1] === "*") {
38460
+ const close = css.indexOf("*/", index + 2);
38461
+ const end = close === -1 ? css.length : close + 2;
38462
+ masked += css.slice(index, end).replace(/[^\n]/g, " ");
38463
+ index = end;
38464
+ } else if (char === '"' || char === "'") {
38465
+ const end = skipString(index);
38466
+ masked += "x".repeat(end - index);
38467
+ index = end;
38468
+ } else if (char === "[") {
38469
+ let end = index + 1;
38470
+ while (end < css.length && css[end] !== "]") {
38471
+ end = css[end] === '"' || css[end] === "'" ? skipString(end) : end + (css[end] === "\\" ? 2 : 1);
38472
+ }
38473
+ end = Math.min(end + 1, css.length);
38474
+ masked += "x".repeat(end - index);
38475
+ index = end;
38476
+ } else {
38477
+ masked += char;
38478
+ index += 1;
38479
+ }
38480
+ }
38481
+ return masked;
38482
+ }
38483
+ var SHADOW_GLOBAL_SELECTOR = /(?:(?<![\w\-.#:\\])html|(?<![\w\-\\]):root)\s*(?:,\s*:host\s*)?\{|(?<![\w\-.#:\\])body\s*\{/g;
38445
38484
  function normalizeShadowScopedCss(css) {
38446
- return css.replace(/(?:html|:root)\s*,\s*:host\s*\{/g, "[data-notis-app-root],:host{").replace(/:root\s*\{/g, "[data-notis-app-root],:host{").replace(/html\s*\{/g, "[data-notis-app-root],:host{").replace(/body\s*\{/g, "[data-notis-app-root]{");
38485
+ const masked = maskCssForSelectorMatching(css);
38486
+ let output = "";
38487
+ let last = 0;
38488
+ for (const match of masked.matchAll(SHADOW_GLOBAL_SELECTOR)) {
38489
+ output += css.slice(last, match.index);
38490
+ output += match[0].startsWith("body") ? "[data-notis-app-root]{" : "[data-notis-app-root],:host{";
38491
+ last = match.index + match[0].length;
38492
+ }
38493
+ return output + css.slice(last);
38447
38494
  }
38448
38495
  function normalizeBundleStylesheets(projectDir) {
38449
38496
  const bundleDir = join16(projectDir, BUNDLE_DIR);
@@ -41142,6 +41189,15 @@ function viewDeclarations(manifest) {
41142
41189
  ...["portal", "hidden"].includes(manifest.chrome) ? { chrome: manifest.chrome } : {}
41143
41190
  };
41144
41191
  }
41192
+ var RECORD_VIEW_READS = { record: "readonly note snapshot", html: "stored HTML page", report: "report source" };
41193
+ function recordViewExample(operation, key2) {
41194
+ const result = {
41195
+ record: { record_key: key2, writable: false, schema: { properties: {} }, document: { record_key: key2, properties: {} } },
41196
+ html: { record_key: key2, html: "<h1>Fictional page</h1>" },
41197
+ report: { source: "export default function Report() { return null; }", export_name: "default" }
41198
+ }[operation];
41199
+ return JSON.stringify({ operation, record_key: key2, result });
41200
+ }
41145
41201
  var VIEWER_READS = { list_databases: "databases", get_database: "databases", query_database: "databases", list_skills: "skills", get_skill: "skills" };
41146
41202
  function createSpaceFixtureEngine(release) {
41147
41203
  const bodyTemplates = structuredClone(release.manifest.actions || {});
@@ -41233,18 +41289,27 @@ function createSpaceFixtureEngine(release) {
41233
41289
  if (previousBodies.some((request) => isDeepStrictEqual(request, entry.request))) throw usageError("Ambiguous document body fixture.");
41234
41290
  previousBodies.push(entry.request);
41235
41291
  }
41292
+ const viewName = JSON.stringify(release.manifest?.local_key || release.manifest?.name || "view");
41293
+ const fixtureWhere = `View ${viewName}${release.fixturePath ? ` (${release.fixturePath})` : ""}:`;
41236
41294
  const recordViews = fixtures.recordViews ?? [];
41237
41295
  if (!Array.isArray(recordViews) || recordViews.length > 100) throw usageError("Declare bounded synthetic recordViews fixtures.");
41238
41296
  const recordDeclarations = Object.values(release.manifest.params || {}).filter((value) => value?.type === "record");
41297
+ const nativeDatabases = Object.values(bodyResources).some((value) => value?.kind === "database");
41239
41298
  const previousViews = /* @__PURE__ */ new Set();
41240
41299
  for (const entry of recordViews) {
41241
- if (!object(entry) || Object.keys(entry).sort().join(",") !== "operation,record_key,result" || !["record", "html", "report"].includes(entry.operation) || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(entry.record_key) || !object(entry.result) || !recordDeclarations.length || !recordDeclarations.every((value2) => bodyResources[value2.database]?.kind === "database")) throw usageError("Declare readonly recordViews only for a declared native record param.");
41300
+ if (!object(entry) || Object.keys(entry).sort().join(",") !== "operation,record_key,result" || !["record", "html", "report"].includes(entry.operation) || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(entry.record_key) || !object(entry.result) || !nativeDatabases || !recordDeclarations.every((value2) => bodyResources[value2.database]?.kind === "database")) throw usageError("Declare readonly recordViews only for a declared native database resource.");
41242
41301
  const identity = entry.operation + ":" + entry.record_key;
41243
41302
  if (previousViews.has(identity)) throw usageError("Ambiguous record view fixture.");
41244
41303
  previousViews.add(identity);
41245
41304
  const value = entry.result;
41246
41305
  if (entry.operation === "record" && (value.record_key !== entry.record_key || !object(value.document) || !object(value.schema) || value.writable !== false || Object.hasOwn(value, "target") || value.document.record_key !== entry.record_key)) throw usageError("Record fixtures must be readonly snapshots without authoring targets.");
41247
- if (entry.operation === "html" && (typeof value.html !== "string" || value.record_key !== entry.record_key)) throw usageError("HTML fixtures contain a record key and HTML.");
41306
+ if (entry.operation === "html" && typeof value.html !== "string") {
41307
+ throw usageError(`${fixtureWhere} the html recordViews fixture for record ${entry.record_key} is missing result.html, the page HTML as a string. Use ${recordViewExample("html", entry.record_key)}.`);
41308
+ }
41309
+ if (entry.operation === "html" && value.record_key !== entry.record_key) {
41310
+ throw usageError(`${fixtureWhere} the html recordViews fixture for record ${entry.record_key} needs result.record_key set to that same record key. Use ${recordViewExample("html", entry.record_key)}.`);
41311
+ }
41312
+ if (entry.operation === "html" && value.title !== void 0 && value.title !== null && typeof value.title !== "string") throw usageError("HTML fixtures contain a record key, HTML and an optional title.");
41248
41313
  if (entry.operation === "report" && (typeof value.source !== "string" || typeof value.export_name !== "string")) throw usageError("Report fixtures contain an explicit readonly source and export.");
41249
41314
  }
41250
41315
  return {
@@ -41252,7 +41317,10 @@ function createSpaceFixtureEngine(release) {
41252
41317
  digest: createHash13("sha256").update(JSON.stringify(fixtures)).digest("hex"),
41253
41318
  executeRecordView(operation, key2) {
41254
41319
  const match = recordViews.find((value) => value.operation === operation && value.record_key === key2);
41255
- if (!match) throw usageError("Add an explicit synthetic record view fixture.");
41320
+ if (!match) {
41321
+ const where = release.fixturePath ? `Add it to recordViews in ${release.fixturePath}` : "This Space declares no verificationFixtures file: add one to its Space definition with a recordViews entry";
41322
+ throw usageError(`View ${viewName} reads the ${RECORD_VIEW_READS[operation] || operation} of record ${key2}, but its fixtures have no recordViews entry with operation "${operation}" and that record_key. ${where}, for example ${recordViewExample(operation, key2)}.`);
41323
+ }
41256
41324
  return structuredClone(match.result);
41257
41325
  },
41258
41326
  executeBody(request) {
@@ -41375,32 +41443,48 @@ function createSpaceFixtureBroker({
41375
41443
  },
41376
41444
  async read(request) {
41377
41445
  let result;
41378
- const log = { operation: request?.operation, ...request?.action_id ? { actionId: request.action_id } : {}, ok: false };
41446
+ const log = {
41447
+ operation: request?.operation,
41448
+ ...request?.action_id ? { actionId: request.action_id } : {},
41449
+ ...typeof request?.record_key === "string" ? { recordKey: request.record_key } : {},
41450
+ ok: false
41451
+ };
41379
41452
  calls.push(log);
41380
- if (calls.length > 256) throw usageError("The offline fixture read limit was reached.");
41381
- if (request?.operation === "action" && exact(request, ["operation", "action_id", "inputs", "request_id"])) {
41382
- if (frozen.descriptor.space.actions[request.action_id]?.readOnly !== true) throw usageError("Offline verification never runs write actions.");
41383
- result = engine.execute(request.action_id, request.inputs);
41384
- } else if (request?.operation === "shown" && exact(request, ["operation", "name", "params", "options"])) {
41385
- result = engine.executeShown(request.name, request.params, request.options);
41386
- } else if (request?.operation === "viewer_read" && exact(request, ["operation", "name", "input"])) {
41387
- result = engine.executeViewerRead(request.name, request.input);
41388
- } else if (request?.operation === "document_body" && exact(request, ["operation", "record_key", "binding", "read_action"])) {
41389
- if (frozen.descriptor.space.actions[request.read_action]?.readOnly !== true) throw usageError("This document read action is unavailable.");
41390
- result = engine.executeBody({ operation: "read", recordKey: request.record_key, binding: request.binding, readAction: request.read_action });
41391
- } else if (["record", "html", "report"].includes(request?.operation) && exact(request, ["operation", "record_key"])) {
41392
- result = engine.executeRecordView(request.operation, request.record_key);
41393
- } else {
41394
- throw usageError("Only declared read-only synthetic fixtures are available.");
41453
+ try {
41454
+ result = readFixture(request);
41455
+ } catch (error) {
41456
+ log.error = String(error?.message || error).slice(0, 2e3);
41457
+ throw error;
41395
41458
  }
41396
41459
  log.ok = true;
41397
41460
  return result;
41398
41461
  },
41399
41462
  async finish() {
41400
- if (calls.some((value2) => !value2.ok)) throw usageError("A synthetic fixture read failed.");
41463
+ const failed = calls.find((value2) => !value2.ok);
41464
+ if (failed) throw usageError(failed.error ? `A synthetic fixture read failed: ${failed.error}` : "A synthetic fixture read failed.");
41401
41465
  return { valid: true };
41402
41466
  }
41403
41467
  };
41468
+ function readFixture(request) {
41469
+ let result;
41470
+ if (calls.length > 256) throw usageError("The offline fixture read limit was reached.");
41471
+ if (request?.operation === "action" && exact(request, ["operation", "action_id", "inputs", "request_id"])) {
41472
+ if (frozen.descriptor.space.actions[request.action_id]?.readOnly !== true) throw usageError("Offline verification never runs write actions.");
41473
+ result = engine.execute(request.action_id, request.inputs);
41474
+ } else if (request?.operation === "shown" && exact(request, ["operation", "name", "params", "options"])) {
41475
+ result = engine.executeShown(request.name, request.params, request.options);
41476
+ } else if (request?.operation === "viewer_read" && exact(request, ["operation", "name", "input"])) {
41477
+ result = engine.executeViewerRead(request.name, request.input);
41478
+ } else if (request?.operation === "document_body" && exact(request, ["operation", "record_key", "binding", "read_action"])) {
41479
+ if (frozen.descriptor.space.actions[request.read_action]?.readOnly !== true) throw usageError("This document read action is unavailable.");
41480
+ result = engine.executeBody({ operation: "read", recordKey: request.record_key, binding: request.binding, readAction: request.read_action });
41481
+ } else if (["record", "html", "report"].includes(request?.operation) && exact(request, ["operation", "record_key"])) {
41482
+ result = engine.executeRecordView(request.operation, request.record_key);
41483
+ } else {
41484
+ throw usageError("Only declared read-only synthetic fixtures are available.");
41485
+ }
41486
+ return result;
41487
+ }
41404
41488
  }
41405
41489
 
41406
41490
  // src/runtime/space-verification.js
@@ -41506,6 +41590,13 @@ async function verifySpaceRelease(release, {
41506
41590
  });
41507
41591
  const runtimeCalls = brokers.flatMap(({ width, broker }) => broker.calls.map((call) => ({ width, ...call })));
41508
41592
  const passed = renders.every((value) => value.status === "passed") && runtimeCalls.every((call) => call.ok);
41593
+ const fixtureErrors = [...new Map(runtimeCalls.filter((call) => !call.ok && call.error).map((call) => [call.error, {
41594
+ phase: "fixture",
41595
+ operation: call.operation,
41596
+ ...call.recordKey ? { record_key: call.recordKey } : {},
41597
+ ...call.actionId ? { action_id: call.actionId } : {},
41598
+ message: call.error
41599
+ }])).values()].slice(0, 10);
41509
41600
  return {
41510
41601
  ...diagnostic,
41511
41602
  status: passed ? "passed" : "failed",
@@ -41516,7 +41607,7 @@ async function verifySpaceRelease(release, {
41516
41607
  browser_count: 1,
41517
41608
  renders,
41518
41609
  runtimeCalls,
41519
- errors: passed ? [] : [{ phase: "renderer", message: "At least one width or read-only fixture failed verification." }]
41610
+ errors: passed ? [] : [...fixtureErrors, { phase: "renderer", message: "At least one width or read-only fixture failed verification." }]
41520
41611
  };
41521
41612
  } catch (error) {
41522
41613
  return {
@@ -42380,7 +42471,11 @@ async function sourceRelease(ctx, publish, stageOnly = false) {
42380
42471
  }
42381
42472
  const diagnosticPath = saveSpaceVerification(directory, { ...render, space_id: body.space_id });
42382
42473
  if (!["passed", "not_applicable_container"].includes(render.status)) {
42383
- throw usageError("The built Space did not pass offline render verification. Nothing was published.", { render, diagnostic_path: diagnosticPath });
42474
+ const reason = render.errors?.find((value) => value.phase === "fixture")?.message;
42475
+ throw usageError(
42476
+ `The built Space did not pass offline render verification. Nothing was published.${reason ? ` ${reason}` : ""}`,
42477
+ { render, diagnostic_path: diagnosticPath }
42478
+ );
42384
42479
  }
42385
42480
  if (publish && !ctx.options.dryRun) {
42386
42481
  verification = spacePublicationIntent(directory, identity, {
@@ -1 +1 @@
1
- {"schema":1,"stamp":1792562865,"commit":"8250916acfec33698df9868db2b05d15c18692fb"}
1
+ {"schema":1,"stamp":1792690862,"commit":"5b15dbc2bb00fe99a710e6336a9ba696a7044a66"}
@@ -22,6 +22,15 @@ access before acting. It is not permission to change a record or run a provider.
22
22
  shapes are not guessed. A stale revision requires a merge, not blind replacement.
23
23
  A relation value takes the related record's `record_key` or row ID; one you
24
24
  cannot resolve answers `invalid_relation_value` naming the property.
25
+ To create a record with its body, send `input.content_markdown` (at most
26
+ 2,000,000 characters, never with `body`) on `LOCAL_NOTIS_DATABASE_INSERT`
27
+ with a direct Editor or standalone target: the record is created, then its
28
+ body is saved like an UPDATE body. `record_body_not_saved` or
29
+ `record_body_outcome_unknown` means the record exists; the error names its
30
+ `record_key` and `revision`. Repeat the exact call with the same request ID
31
+ (it replays the same record and resumes the body), or save the body with
32
+ `LOCAL_NOTIS_DATABASE_UPDATE`. Never insert again with a new ID. A declared
33
+ insert action takes no body: insert, then save through its update action.
25
34
  3. On an unknown outcome, recover the original receipt using the same intent.
26
35
  Do not repeat an insert with a new ID. Read back the same row afterward.
27
36
  `space_temporarily_unavailable` (503) is load, not a refusal: retry the same
@@ -1 +1 @@
1
- {"schema":1,"stamp":1792677351,"commit":"771883529c1d8ea5c48ead21c09b868da2362b87"}
1
+ {"schema":1,"stamp":1792690862,"commit":"5b15dbc2bb00fe99a710e6336a9ba696a7044a66"}
@@ -196,6 +196,10 @@ you prepare it and only the submission page's Publish button publishes.
196
196
 
197
197
  Existing report rows render through `<ReportFrame>` in their database's view.
198
198
  Follow [Reports](references/reports.md) for saved pages and interactive reports.
199
+ To show another Space page live inside a view (a slide whose Page property holds
200
+ a Notis link), use `<SpacePageFrame href>` from [SDK](references/sdk.md): only a
201
+ Space view of the same Notis origin is framed, view only, with the viewer's own
202
+ access; any other link shows a card with Open page.
199
203
 
200
204
  [Context sharing](references/context.md) keeps selected text and annotations as
201
205
  unsent reference context. Page context, memory hits and source content are not
@@ -20,7 +20,7 @@ account credential or backend transport.
20
20
  | `useShown<Row>(name, options?)` | Execute the native list in `shows[name]` with current params. Options: `sorts`, `pageSize`, `offset`, `includeContent`, `allPages` (every page, for a whole count or a local search); rows are in `data?.rows` and the schema revision a write needs in `data?.schema_revision`, with `loading`, `hasData`, `isFetching`, `error`, `refetch` (resolves to the read's error, or null when it succeeded; it never rejects). |
21
21
  | `useShownAvailable(name)` | Check whether this host exposes the declared native list. A record Site can expose only its exact server-pinned row scope. |
22
22
  | `useAction<Inputs, Result>(name)` / `useActionQuery<Result>(name, inputs)` | Call a declared action (`call(inputs, options)`); read the result of a declared read action. Never call account-wide tools from a Space. |
23
- | `useNativeMutation(binding, operation)` | Insert, update or delete one row of a linked database through the action that declares `LOCAL_NOTIS_DATABASE_INSERT`, `_UPDATE` or `_DELETE` on that binding with `request: { $input: 'request' }`. `mutate(input, {schemaRevision, requestId})` returns the receipt (`record_key`, `revision`); a delete moves the record to the bin. One new request ID per effect. |
23
+ | `useNativeMutation(binding, operation)` | Insert, update or delete one row of a linked database through the action that declares `LOCAL_NOTIS_DATABASE_INSERT`, `_UPDATE` or `_DELETE` on that binding with `request: { $input: 'request' }`. `mutate(input, {schemaRevision, requestId})` returns the receipt (`record_key`, `revision`); a delete moves the record to the bin. One new request ID per effect. An insert action takes no `content_markdown`: insert, then save the body with `useNativeDocumentBody` and the record's revision. |
24
24
  | `useNativeDocumentBody(binding, recordKey, {readAction, writeAction?, enabled?})` | Markdown body adapter. Keep drafts in the view; save with the original content, record/schema revisions and stable request ID. Rich editing normally uses `DocumentEditor`. |
25
25
  | `useNotis()` | Host resource, Space labels, route and ready state. Use `resource.id` for the current Space identity. |
26
26
 
@@ -51,6 +51,7 @@ user data.
51
51
  | `<HtmlFrame recordKey>` | Attached HTML under viewer CSP and an opaque-origin sandbox. Source cannot relax that boundary. |
52
52
  | `<ReportFrame recordKey>` | A report row's saved independent implementation in its database's current view. |
53
53
  | `<ShareControl recordKey>` | Private/shared record Site, copy link and revoke. |
54
+ | `<SpacePageFrame href>` | Another Notis Space page, live in this view: `href` is a Notis link (a path or a full link of this origin, such as a Page property). The Portal shows only that page's view (no sidebar, top bar or chat) with the viewer's own session and permissions. `interactive` (default `false`) lets it take clicks and keys; off, the view around it (a deck's arrows and swipes) keeps them. `title`, `className` (sizes the box it fills). Any other link, and every verify or build preview, shows a card with the title, the path and Open page. |
54
55
 
55
56
  In its own Space's view, a record's Site is a row of the top bar Share panel, not
56
57
  a toolbar button. The published Space declares the database binding; the server
@@ -229,7 +230,9 @@ extracts tables, lists, controls and visible document/HTML content automatically
229
230
  `NotisSelectionBoundary`, `NotisCommentBoundary` and `NotisCommentBox` support
230
231
  explicit reference context. [Context sharing](context.md) owns the contract.
231
232
  - `Dialog` supplies a themed modal with focus handling; while it is open, only
232
- its own keys and Escape work.
233
+ its own keys and Escape work. For a confirmation (delete, discard), pass
234
+ `nativeOnTouch` and mark the buttons `data-dialog-confirm` and
235
+ `data-dialog-cancel`: phones and tablets then ask with the system dialog.
233
236
  - `MarkdownEditor` is for a view-owned Markdown draft/persistence adapter. Native
234
237
  documents use `DocumentEditor` so collaboration and file authority stay with Notis.
235
238
 
@@ -239,5 +242,13 @@ Declare a `verificationFixtures` JSON source with `actions` and optional `contex
239
242
  `shown`, `viewerReads`, `documentBodies`, `recordViews`. Cases match exact action
240
243
  inputs/list params. Record views use `{operation: 'record'|'html'|'report', record_key,
241
244
  result}`; native record snapshots require `writable: false` and no authoring target.
245
+ A record view fixture needs a declared native database resource; a record param is
246
+ optional (a list row's `HtmlFrame` takes none). An `HtmlFrame` preview reads
247
+ `{operation: 'html', record_key: '<row uuid>', result: {record_key: '<same uuid>',
248
+ html: '<!doctype html>...', title: 'Optional'}}` and renders that HTML in the live
249
+ sandbox and viewer CSP, sized by its `className` (a 1920x1080 slide box shows the
250
+ real slide in verify screenshots); popups and network stay blocked offline.
251
+ When a view reads a record view its fixtures lack, verify names the view, the read,
252
+ the record key and the missing field, and prints a valid entry to copy.
242
253
  Keep every fixture fictional and include every load-time read. Failed or missing
243
254
  fixtures fail verification; there is no fallback to the user's real account.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect, useId, useRef, type ReactNode } from 'react';
3
+ import React, { useEffect, useId, useRef, useState, type ReactNode } from 'react';
4
+ import { useIsoLayoutEffect } from '../ui/util';
4
5
 
5
6
  export interface DialogProps {
6
7
  open: boolean;
@@ -9,21 +10,79 @@ export interface DialogProps {
9
10
  title: ReactNode;
10
11
  description?: ReactNode;
11
12
  role?: 'dialog' | 'alertdialog';
13
+ /**
14
+ * A confirmation: on phones and tablets the operating system's own dialog asks instead of the card. OK clicks the
15
+ * button marked `data-dialog-confirm`; Cancel clicks the one marked `data-dialog-cancel`, else calls `onClose`.
16
+ * Desktop browsers and the Desktop app keep the card.
17
+ */
18
+ nativeOnTouch?: boolean;
12
19
  children: ReactNode;
13
20
  }
14
21
 
22
+ /** How long the card stays hidden after OK while the work runs; a slow or failed action shows it again. */
23
+ const NATIVE_CONFIRM_REVEAL_MS = 1500;
24
+ /** A Cancel faster than this came from a browser with no OS dialog (an embedded view): show the card instead. */
25
+ const NATIVE_CONFIRM_MISSING_MS = 30;
26
+
27
+ /** Touch-first devices confirm with the operating system's dialog, as the Portal's AlertDialog does. */
28
+ export function prefersNativeConfirm(): boolean {
29
+ if (typeof window === 'undefined' || typeof window.confirm !== 'function') return false;
30
+ if ((window as Window & { electronAPI?: { isElectron?: boolean } }).electronAPI?.isElectron) return false;
31
+ return window.matchMedia?.('(hover: none) and (pointer: coarse)').matches === true;
32
+ }
33
+
34
+ /** One OS dialog: the question first, then each line of the explanation as its own paragraph. */
35
+ function nativeConfirmMessage(title: string, description: string): string {
36
+ return [title, ...description.split(/\n+/)].map((part) => part.trim()).filter(Boolean).join('\n\n');
37
+ }
38
+
15
39
  /** Native top-layer dialog; content and actions remain app-owned. */
16
- export function Dialog({ open, onClose, title, description, role = 'dialog', children }: DialogProps) {
40
+ export function Dialog({ open, onClose, title, description, role = 'dialog', nativeOnTouch = false, children }: DialogProps) {
17
41
  const ref = useRef<HTMLDialogElement>(null);
18
42
  const titleId = useId();
19
43
  const descriptionId = useId();
44
+ const onCloseRef = useRef(onClose);
45
+ onCloseRef.current = onClose;
46
+ // Each opening asks the OS again.
47
+ const [native, setNative] = useState(false);
20
48
 
21
- useEffect(() => {
49
+ // Before paint, so a touch confirmation never flashes the card first.
50
+ useIsoLayoutEffect(() => {
22
51
  if (!open) return;
23
52
  const dialog = ref.current!;
24
53
  dialog.showModal();
25
- return () => dialog.close();
26
- }, [open]);
54
+ setNative(nativeOnTouch && prefersNativeConfirm());
55
+ return () => { if (dialog.open) dialog.close(); };
56
+ }, [open, nativeOnTouch]);
57
+
58
+ useEffect(() => {
59
+ if (!open || !native) return;
60
+ const dialog = ref.current!;
61
+ let reveal: ReturnType<typeof setTimeout> | undefined;
62
+ // Let the dialog finish opening (title, buttons) before the OS takes over.
63
+ const ask = setTimeout(() => {
64
+ const asked = performance.now();
65
+ const describedBy = dialog.querySelector('.notis-dialog-description') as HTMLElement | null;
66
+ const confirmed = window.confirm(nativeConfirmMessage(
67
+ dialog.querySelector('.notis-dialog-title')?.textContent ?? '',
68
+ describedBy ? (typeof describedBy.innerText === 'string' ? describedBy.innerText : describedBy.textContent ?? '') : '',
69
+ ));
70
+ if (!confirmed && performance.now() - asked < NATIVE_CONFIRM_MISSING_MS) {
71
+ setNative(false);
72
+ return;
73
+ }
74
+ if (confirmed) dialog.querySelector<HTMLButtonElement>('[data-dialog-confirm]')?.click();
75
+ else {
76
+ const cancel = dialog.querySelector<HTMLButtonElement>('[data-dialog-cancel]');
77
+ if (cancel) cancel.click(); else onCloseRef.current();
78
+ }
79
+ reveal = setTimeout(() => setNative(false), confirmed ? NATIVE_CONFIRM_REVEAL_MS : 0);
80
+ }, 0);
81
+ return () => {
82
+ clearTimeout(ask);
83
+ if (reveal !== undefined) clearTimeout(reveal);
84
+ };
85
+ }, [open, native]);
27
86
 
28
87
  return (
29
88
  <dialog
@@ -32,10 +91,8 @@ export function Dialog({ open, onClose, title, description, role = 'dialog', chi
32
91
  role={role}
33
92
  aria-labelledby={titleId}
34
93
  aria-describedby={description == null ? undefined : descriptionId}
35
- onCancel={(event) => {
36
- event.preventDefault();
37
- onClose();
38
- }}
94
+ data-native-confirm={native ? '' : undefined}
95
+ onCancel={(event) => { event.preventDefault(); onClose(); }}
39
96
  onKeyDown={(event) => event.stopPropagation()}
40
97
  >
41
98
  <h2 id={titleId} className="notis-dialog-title">{title}</h2>
@@ -2,7 +2,9 @@
2
2
 
3
3
  import type { ReactElement } from 'react';
4
4
  import { useNotisRuntime } from '../provider';
5
- import type { NotisHtmlFrameProps, NotisRecordPropertiesProps, NotisReportFrameProps, NotisShareControlProps } from '../runtime';
5
+ import type {
6
+ NotisHtmlFrameProps, NotisRecordPropertiesProps, NotisReportFrameProps, NotisShareControlProps, NotisSpacePageFrameProps,
7
+ } from '../runtime';
6
8
  import { pick, type Locale, type Words } from '../words/types';
7
9
 
8
10
  /** The words a record component says outside Notis, in English and French. */
@@ -11,6 +13,9 @@ export const RECORD_COMPONENT_WORDS = Object.freeze({
11
13
  /** Short enough for a toolbar (C11); the full sentence is its tooltip. */
12
14
  shareUnavailable: { en: 'Share unavailable', fr: 'Partage indisponible' },
13
15
  shareUnavailableHint: { en: 'Open this record in Notis to share it.', fr: 'Ouvre cet enregistrement dans Notis pour le partager.' },
16
+ spacePageTitle: { en: 'Space page', fr: 'Page du Space' },
17
+ spacePagePreview: { en: 'This page shows here live when the Space opens in Notis.', fr: 'Cette page s’affiche ici en direct quand le Space est ouvert dans Notis.' },
18
+ spacePageOpen: { en: 'Open page', fr: 'Ouvrir la page' },
14
19
  }) satisfies Readonly<Record<string, Words>>;
15
20
 
16
21
  function useLocale(): Locale {
@@ -51,3 +56,56 @@ export function ShareControl(props: NotisShareControlProps): ReactElement {
51
56
  {pick(RECORD_COMPONENT_WORDS.shareUnavailable, locale)}
52
57
  </span>;
53
58
  }
59
+
60
+ /**
61
+ * The path a Space page link names and the link the fallback card may open: a site-relative path, or an http(s) URL
62
+ * without credentials. Anything else (script URLs, protocol-relative links) opens nothing.
63
+ */
64
+ export function spacePageLink(href: string): { path: string; openHref: string | null } {
65
+ const value = typeof href === 'string' ? href.trim() : '';
66
+ // Control characters and backslashes change how browsers read a URL; such a link never opens.
67
+ if (!value || /[\u0000-\u001f\u007f\\]/.test(value)) return { path: value, openHref: null };
68
+ if (value.startsWith('/') && !value.startsWith('//')) {
69
+ const path = value.split(/[?#]/, 1)[0] || '/';
70
+ return { path, openHref: value };
71
+ }
72
+ try {
73
+ const url = new URL(value);
74
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password) {
75
+ return { path: url.pathname, openHref: url.href };
76
+ }
77
+ } catch { /* not a URL */ }
78
+ return { path: value, openHref: null };
79
+ }
80
+
81
+ /** What a Space page frame shows when the host does not draw it (previews, verify): its title, its path and Open page. */
82
+ export function SpacePageCard({ href, title, className, notice }: Pick<NotisSpacePageFrameProps, 'href' | 'title' | 'className'> & {
83
+ /** One sentence under the path; default: the page shows live once the Space opens in Notis. */
84
+ notice?: string;
85
+ }): ReactElement {
86
+ const locale = useLocale();
87
+ const { path, openHref } = spacePageLink(href);
88
+ const muted = 'hsl(var(--muted-foreground))';
89
+ return <div role="group" aria-label={title || pick(RECORD_COMPONENT_WORDS.spacePageTitle, locale)} className={className}
90
+ data-notis-space-page-card=""
91
+ style={{ display: 'grid', alignContent: 'start', gap: 8, padding: 16, borderRadius: 10, minWidth: 0,
92
+ background: 'hsl(var(--muted) / .5)', color: 'hsl(var(--foreground))' }}>
93
+ <div style={{ fontSize: 14, fontWeight: 500, overflowWrap: 'anywhere' }}>{title || pick(RECORD_COMPONENT_WORDS.spacePageTitle, locale)}</div>
94
+ {path ? <div style={{ fontSize: 12, color: muted, overflowWrap: 'anywhere',
95
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}>{path}</div> : null}
96
+ <div style={{ fontSize: 13, color: muted }}>{notice ?? pick(RECORD_COMPONENT_WORDS.spacePagePreview, locale)}</div>
97
+ {openHref ? <div><a className="nui-btn" href={openHref} style={{ textDecoration: 'none' }}>
98
+ {pick(RECORD_COMPONENT_WORDS.spacePageOpen, locale)}
99
+ </a></div> : null}
100
+ </div>;
101
+ }
102
+
103
+ /**
104
+ * Another Space page shown live inside this view. The host loads only a Space page of its own Notis origin, with the
105
+ * viewer's own access; any other link (and every host without the frame) shows a card with the title, the path and
106
+ * Open page. Not interactive by default, so a deck around it keeps its keys and clicks.
107
+ */
108
+ export function SpacePageFrame(props: NotisSpacePageFrameProps): ReactElement {
109
+ const Host = useNotisRuntime()?.ui?.SpacePageFrame;
110
+ return Host ? <Host {...props} /> : <SpacePageCard href={props.href} title={props.title} className={props.className} />;
111
+ }
@@ -80,7 +80,7 @@ export { Markdown } from './components/Markdown';
80
80
  export type { MarkdownProps } from './components/Markdown';
81
81
  export { DocumentEditor } from './components/DocumentEditor';
82
82
  export { DocumentPage, DocumentPageSkeleton, usePrefetchRecord } from './components/DocumentPage';
83
- export { RecordProperties, HtmlFrame, ReportFrame, ShareControl } from './components/RecordComponents';
83
+ export { RecordProperties, HtmlFrame, ReportFrame, ShareControl, SpacePageFrame } from './components/RecordComponents';
84
84
  export { RenderChartData } from './ui/charts/RenderChartData';
85
85
  export { MarkdownEditor } from './components/MarkdownEditor';
86
86
  export { NotisSelectionBoundary, NOTIS_CONTEXT_CLIPBOARD_TYPE } from './components/NotisSelectionBoundary';
@@ -175,6 +175,7 @@ export type {
175
175
  NotisHtmlFrameProps,
176
176
  NotisReportFrameProps,
177
177
  NotisShareControlProps,
178
+ NotisSpacePageFrameProps,
178
179
  NotisMarkdownEditorProps,
179
180
  NotisMarkdownEditorSavePayload,
180
181
  NotisMarkdownEditorSaveResult,
@@ -268,6 +268,23 @@ export type NotisHtmlFrameProps = ({ recordKey: string; html?: never } | { html:
268
268
  export interface NotisReportFrameProps extends NotisRecordProps {}
269
269
  export interface NotisShareControlProps extends NotisRecordProps {}
270
270
 
271
+ /**
272
+ * Another Space page (a Space view or record page link on the current Notis origin) shown live inside this view.
273
+ * The host loads only links to a Space page of its own origin; any other link shows a card with an Open link.
274
+ */
275
+ export interface NotisSpacePageFrameProps {
276
+ /** A Space page link: a path such as '/slides/decks?deck=...' or the same link with the current Notis origin. */
277
+ href: string;
278
+ /** Names the frame for assistive technology and the fallback card. */
279
+ title?: string;
280
+ className?: string;
281
+ /**
282
+ * False (the default) ignores clicks and keys inside the page, so the view around it (for example a slide
283
+ * deck) keeps its own navigation. True lets the viewer use the page in place.
284
+ */
285
+ interactive?: boolean;
286
+ }
287
+
271
288
  /** One breadcrumb before the record title; the title is always the last crumb. */
272
289
  export interface NotisDocumentPageCrumb {
273
290
  label: string;
@@ -414,6 +431,7 @@ export interface NotisRuntimeUI {
414
431
  HtmlFrame?: ComponentType<NotisHtmlFrameProps>;
415
432
  ReportFrame?: ComponentType<NotisReportFrameProps>;
416
433
  ShareControl?: ComponentType<NotisShareControlProps>;
434
+ SpacePageFrame?: ComponentType<NotisSpacePageFrameProps>;
417
435
  /** Starts reading a record (and the page's code) before it opens, for example when a list row is hovered. */
418
436
  prefetchRecord?: (recordKey: string) => void;
419
437
  }
@@ -8,26 +8,7 @@
8
8
  * The portal injects the live Notis theme variables at render time.
9
9
  */
10
10
 
11
- /* Top-layer surfaces must remain distinct from the app's flat content panels. */
12
- dialog.notis-dialog {
13
- position: fixed;
14
- inset: 0;
15
- margin: auto;
16
- box-sizing: border-box;
17
- width: min(28rem, calc(100% - 2rem));
18
- max-height: calc(100dvh - 2rem);
19
- overflow: auto;
20
- padding: 1.25rem;
21
- border: 1px solid hsl(var(--border));
22
- border-radius: 0.75rem;
23
- background: hsl(var(--popover));
24
- color: hsl(var(--popover-foreground));
25
- box-shadow: 0 16px 48px rgb(0 0 0 / 0.2);
26
- }
27
-
28
- dialog.notis-dialog::backdrop { background: rgb(0 0 0 / 0.45); }
29
- .notis-dialog-title { margin: 0; font-size: 1rem; font-weight: 600; line-height: 1.5; }
30
- .notis-dialog-description { margin: 0.5rem 0 0; color: hsl(var(--muted-foreground)); font-size: 0.875rem; line-height: 1.5; }
11
+ /* The SDK Dialog's rules live in toolkit.css (section: dialog), so they also reach a Space's shadow root. */
31
12
 
32
13
  /* Framework-neutral styles: the app owns its Tailwind entry point. This file
33
14
  * is also consumed in shadow roots and by apps on older Tailwind versions.