@frontera-sdk/cli 1.50.26 → 1.50.28

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.26",
3
+ "version": "1.50.28",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.50.26",
43
- "@frontera-sdk/core": "1.50.26",
42
+ "@frontera-sdk/functions": "1.50.28",
43
+ "@frontera-sdk/core": "1.50.28",
44
44
  "ai": "^6.0.116",
45
45
  "gray-matter": "^4.0.3",
46
46
  "yaml": "^2.9.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.50.26",
49
+ "@frontera-sdk/forge-contracts": "1.50.28",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -1,18 +1,18 @@
1
1
  {
2
- "sdkVersion": "1.50.19",
2
+ "sdkVersion": "1.50.25",
3
3
  "files": {
4
4
  "frontera/core/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
5
- "frontera/core/bridge-client.ts": "import { isHostMessage, type BridgeInit, type AppMessage } from './bridge-protocol'\nimport { readRuntimeGlobal } from './config'\nimport type { HostTheme } from './theme'\n\n/**\n * App side of the bridge.\n *\n * The app announces itself with `frontera:ready` and waits for `frontera:init`,\n * which carries the API credential. It does NOT read the credential from its\n * own URL — the only thing there is the narrow asset token, and treating that\n * as a data credential would turn a loggable URL into data access.\n *\n * `event.origin` is checked against the parent's origin on every message.\n */\nexport interface BridgeSession {\n init: BridgeInit\n /** Send a message to the host. */\n send(message: AppMessage): void\n /** Subscribe to shared-state updates pushed by the host. */\n onState(handler: (state: Record<string, unknown>) => void): () => void\n /** Subscribe to credential refreshes. */\n onToken(handler: (token: string) => void): () => void\n /** Subscribe to host palette / colour-scheme changes. */\n onTheme(handler: (theme: HostTheme) => void): () => void\n dispose(): void\n}\n\nexport interface ConnectOptions {\n /**\n * Origin of the embedding platform. Defaults to the injected runtime config,\n * then `document.referrer`.\n */\n parentOrigin?: string\n timeoutMs?: number\n}\n\n/**\n * Who is framing us.\n *\n * The injected runtime config comes FIRST and `document.referrer` is only a\n * fallback. The injected value is preferred because it is exact and, when it\n * comes from the signed per-mount claim, unforgeable — not because the\n * referrer is empty. (An earlier version of this comment blamed the\n * `referrer-policy: no-referrer` these responses carry; that header governs\n * the referrer this document SENDS on its own requests, not the\n * `document.referrer` its parent handed it, which is set by the embedding\n * page's policy — the platform frame does populate it, and pre-1.45 Apps\n * mounted through exactly this fallback for months.)\n */\nfunction resolveParentOrigin(explicit?: string): string | null {\n if (explicit) return explicit\n const injected = readRuntimeGlobal().platformOrigin\n if (injected) return injected\n if (typeof document === 'undefined') return null\n try {\n return document.referrer ? new URL(document.referrer).origin : null\n } catch {\n return null\n }\n}\n\n/**\n * Complete the handshake.\n *\n * Rejects rather than hanging if the host never answers: a frame that waits\n * forever looks identical to a slow network, and an author debugging a broken\n * mount deserves a real error.\n */\nexport function connectToHost(options: ConnectOptions = {}): Promise<BridgeSession> {\n const parentOrigin = resolveParentOrigin(options.parentOrigin)\n const timeoutMs = options.timeoutMs ?? 10_000\n\n if (typeof window === 'undefined' || window.parent === window) {\n return Promise.reject(\n new Error('Not running inside a Frontera host frame — connectToHost() needs a parent window.'),\n )\n }\n if (!parentOrigin) {\n return Promise.reject(\n new Error('Could not determine the host origin; pass parentOrigin explicitly.'),\n )\n }\n\n const stateHandlers = new Set<(s: Record<string, unknown>) => void>()\n const tokenHandlers = new Set<(t: string) => void>()\n const themeHandlers = new Set<(t: HostTheme) => void>()\n\n return new Promise<BridgeSession>((resolve, reject) => {\n let settled = false\n\n const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)\n\n const onMessage = (event: MessageEvent) => {\n if (event.origin !== parentOrigin) return\n if (!isHostMessage(event.data)) return\n const message = event.data\n\n switch (message.type) {\n case 'frontera:init':\n if (settled) return\n settled = true\n clearTimeout(timer)\n resolve({\n init: message,\n send,\n onState(handler) {\n stateHandlers.add(handler)\n return () => stateHandlers.delete(handler)\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme(handler) {\n themeHandlers.add(handler)\n return () => themeHandlers.delete(handler)\n },\n dispose() {\n window.removeEventListener('message', onMessage)\n stateHandlers.clear()\n tokenHandlers.clear()\n themeHandlers.clear()\n },\n })\n break\n case 'frontera:state':\n for (const handler of stateHandlers) handler(message.state)\n break\n case 'frontera:token':\n for (const handler of tokenHandlers) handler(message.token)\n break\n // The host pushes this on every platform theme change — a system\n // dark-mode switch, a surface that forces its own scheme. Without a\n // handler the message was received and dropped, so an app followed the\n // palette it was mounted with and then never again.\n case 'frontera:theme':\n for (const handler of themeHandlers) {\n handler({ tokens: message.tokens, colorScheme: message.colorScheme })\n }\n break\n default:\n break\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n window.removeEventListener('message', onMessage)\n reject(new Error(`Frontera host did not respond within ${timeoutMs}ms`))\n }, timeoutMs)\n\n window.addEventListener('message', onMessage)\n send({ type: 'frontera:ready' })\n })\n}\n",
5
+ "frontera/core/bridge-client.ts": "import { isHostMessage, type BridgeInit, type AppMessage } from './bridge-protocol'\nimport { readRuntimeGlobal } from './config'\nimport type { HostTheme } from './theme'\n\n/**\n * App side of the bridge.\n *\n * The app announces itself with `frontera:ready` and waits for `frontera:init`,\n * which carries the API credential. It does NOT read the credential from its\n * own URL — the only thing there is the narrow asset token, and treating that\n * as a data credential would turn a loggable URL into data access.\n *\n * `event.origin` is checked against the parent's origin on every message.\n */\nexport interface BridgeSession {\n init: BridgeInit\n /** Send a message to the host. */\n send(message: AppMessage): void\n /** Subscribe to shared-state updates pushed by the host. */\n onState(handler: (state: Record<string, unknown>) => void): () => void\n /** Subscribe to credential refreshes. */\n onToken(handler: (token: string) => void): () => void\n /** Subscribe to host palette / colour-scheme changes. */\n onTheme(handler: (theme: HostTheme) => void): () => void\n /** Visibility changes do not revoke credentials or imply execution is paused. */\n onActivation(handler: (active: boolean) => void): () => void\n dispose(): void\n}\n\nexport interface ConnectOptions {\n /**\n * Origin of the embedding platform. Defaults to the injected runtime config,\n * then `document.referrer`.\n */\n parentOrigin?: string\n timeoutMs?: number\n}\n\n/**\n * Who is framing us.\n *\n * The injected runtime config comes FIRST and `document.referrer` is only a\n * fallback. The injected value is preferred because it is exact and, when it\n * comes from the signed per-mount claim, unforgeable — not because the\n * referrer is empty. (An earlier version of this comment blamed the\n * `referrer-policy: no-referrer` these responses carry; that header governs\n * the referrer this document SENDS on its own requests, not the\n * `document.referrer` its parent handed it, which is set by the embedding\n * page's policy — the platform frame does populate it, and pre-1.45 Apps\n * mounted through exactly this fallback for months.)\n */\nfunction resolveParentOrigin(explicit?: string): string | null {\n if (explicit) return explicit\n const injected = readRuntimeGlobal().platformOrigin\n if (injected) return injected\n if (typeof document === 'undefined') return null\n try {\n return document.referrer ? new URL(document.referrer).origin : null\n } catch {\n return null\n }\n}\n\n/**\n * Complete the handshake.\n *\n * Rejects rather than hanging if the host never answers: a frame that waits\n * forever looks identical to a slow network, and an author debugging a broken\n * mount deserves a real error.\n */\nexport function connectToHost(options: ConnectOptions = {}): Promise<BridgeSession> {\n const parentOrigin = resolveParentOrigin(options.parentOrigin)\n const timeoutMs = options.timeoutMs ?? 10_000\n\n if (typeof window === 'undefined' || window.parent === window) {\n return Promise.reject(\n new Error('Not running inside a Frontera host frame — connectToHost() needs a parent window.'),\n )\n }\n if (!parentOrigin) {\n return Promise.reject(\n new Error('Could not determine the host origin; pass parentOrigin explicitly.'),\n )\n }\n\n const stateHandlers = new Set<(s: Record<string, unknown>) => void>()\n const tokenHandlers = new Set<(t: string) => void>()\n const themeHandlers = new Set<(t: HostTheme) => void>()\n const activationHandlers = new Set<(active: boolean) => void>()\n let active = true\n\n return new Promise<BridgeSession>((resolve, reject) => {\n let settled = false\n\n const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)\n\n const onMessage = (event: MessageEvent) => {\n if (event.origin !== parentOrigin || event.source !== window.parent) return\n if (!isHostMessage(event.data)) return\n const message = event.data\n\n switch (message.type) {\n case 'frontera:init':\n if (settled) return\n settled = true\n clearTimeout(timer)\n active = message.active ?? active\n resolve({\n init: message,\n send,\n onState(handler) {\n stateHandlers.add(handler)\n return () => stateHandlers.delete(handler)\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme(handler) {\n themeHandlers.add(handler)\n return () => themeHandlers.delete(handler)\n },\n onActivation(handler) {\n activationHandlers.add(handler)\n handler(active)\n return () => activationHandlers.delete(handler)\n },\n dispose() {\n window.removeEventListener('message', onMessage)\n stateHandlers.clear()\n tokenHandlers.clear()\n themeHandlers.clear()\n activationHandlers.clear()\n },\n })\n break\n case 'frontera:state':\n for (const handler of stateHandlers) handler(message.state)\n break\n case 'frontera:token':\n for (const handler of tokenHandlers) handler(message.token)\n break\n // The host pushes this on every platform theme change — a system\n // dark-mode switch, a surface that forces its own scheme. Without a\n // handler the message was received and dropped, so an app followed the\n // palette it was mounted with and then never again.\n case 'frontera:theme':\n for (const handler of themeHandlers) {\n handler({ tokens: message.tokens, colorScheme: message.colorScheme })\n }\n break\n case 'frontera:activation':\n active = message.active\n for (const handler of activationHandlers) handler(message.active)\n break\n default:\n break\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n window.removeEventListener('message', onMessage)\n reject(new Error(`Frontera host did not respond within ${timeoutMs}ms`))\n }, timeoutMs)\n\n window.addEventListener('message', onMessage)\n send({ type: 'frontera:ready' })\n })\n}\n",
6
6
  "frontera/core/errors.ts": "/**\n * Error taxonomy for the Frontera SDK.\n *\n * `code` mirrors the service's `ErrorCode` enum\n * (packages/service/src/lib/errors.ts) so callers branch on the same strings\n * the API emits. Codes are deliberately not enumerated here — the service adds\n * new ones, and an SDK that rejected unknown codes would need a release every\n * time it did.\n */\nexport class FronteraError extends Error {\n readonly code: string\n readonly status?: number\n readonly details?: unknown\n\n constructor(message: string, opts: { code: string; status?: number; details?: unknown }) {\n super(message)\n this.name = 'FronteraError'\n this.code = opts.code\n this.status = opts.status\n this.details = opts.details\n }\n}\n\n/** Codes used when the response body carries no envelope of its own. */\nconst STATUS_FALLBACK: Record<number, string> = {\n 400: 'BAD_REQUEST',\n 401: 'UNAUTHORIZED',\n 403: 'FORBIDDEN',\n 404: 'NOT_FOUND',\n 409: 'CONFLICT',\n 429: 'RATE_LIMITED',\n}\n\nfunction isErrorEnvelope(body: unknown): body is { error: true; code?: string; message?: string } {\n return typeof body === 'object' && body !== null && (body as { error?: unknown }).error === true\n}\n\n/** Turn a non-2xx response body into a `FronteraError`. */\nexport function errorFromResponse(status: number, body: unknown): FronteraError {\n if (isErrorEnvelope(body)) {\n return new FronteraError(body.message ?? `request failed with ${status}`, {\n code: body.code ?? STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body,\n })\n }\n return new FronteraError(`request failed with ${status}`, {\n code: STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body ?? undefined,\n })\n}\n",
7
- "frontera/core/create-frontera-app.tsx": "import {\n Component,\n StrictMode,\n createContext,\n useContext,\n useEffect,\n useState,\n type ErrorInfo,\n type ReactNode,\n} from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { connectToHost, type BridgeSession } from './bridge-client'\nimport type { BridgeInit } from './bridge-protocol'\nimport { FronteraClient } from './client'\nimport { readRuntimeGlobal } from './config'\nimport {\n connectToSessionEndpoint,\n detectAppMode,\n type FronteraAppMode,\n} from './app-session'\nimport { applyHostTheme } from './theme'\n\n/**\n * The entry point every app shares.\n *\n * Before this existed, the quickstart asked each author to hand-write the same\n * thirty-five lines: find the parent origin, postMessage `frontera:ready`,\n * listen for `frontera:init`, build a client from the token that arrives, and\n * only then mount. Copied boilerplate is where the origin check gets dropped —\n * and a missing origin check is the whole vulnerability class this architecture\n * exists to close. So it is written once, here.\n *\n * Mounting is DEFERRED until the handshake completes. An app has no data scope\n * until the host tells it who it is, so rendering earlier only produces a flash\n * of unauthorized requests, and every hook would need a \"not ready yet\" branch.\n */\n\nexport interface FronteraAppValue {\n mode: FronteraAppMode\n /** Everything the host said at handshake, including `path`. */\n init: BridgeInit\n client: FronteraClient\n /** Ask the platform to change its URL — the counterpart to `init.path`. */\n navigate(path: string): void\n}\n\nconst FronteraAppReactContext = createContext<FronteraAppValue | null>(null)\n\nexport function useFronteraApp(): FronteraAppValue {\n const value = useContext(FronteraAppReactContext)\n if (!value) {\n throw new Error(\n 'useFronteraApp() outside a Frontera app — createFronteraApp() installs this provider.',\n )\n }\n return value\n}\n\n/**\n * A wrapper contributed by a domain package.\n *\n * This is a callback rather than `createFronteraApp` importing the domain\n * clients directly, because `@frontera-sdk/blueprint` already depends on\n * `@frontera-sdk/core` — reaching back the other way would make the two\n * packages mutually dependent. `blueprintProvider` from\n * `@frontera-sdk/blueprint/provider` is one of these.\n */\nexport type FronteraProvider = (value: FronteraAppValue, children: ReactNode) => ReactNode\n\nexport interface CreateFronteraAppOptions {\n /** Element id to mount into. Defaults to `root`. */\n rootId?: string\n providers?: FronteraProvider[]\n queryClient?: QueryClient\n /** How long to wait for the host before reporting a failed mount. */\n timeoutMs?: number\n}\n\ninterface BoundaryProps {\n onError: (error: Error, info: ErrorInfo) => void\n fallback?: (error: Error) => ReactNode\n children: ReactNode\n}\n\n/**\n * Report a render crash to the host instead of leaving a blank frame.\n *\n * The platform listens for `frontera:error` and surfaces it next to the app, so\n * the author sees the message rather than an empty rectangle they have to open\n * devtools to explain.\n */\nclass AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }> {\n state: { error: Error | null } = { error: null }\n\n static getDerivedStateFromError(error: Error) {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo) {\n this.props.onError(error, info)\n }\n\n render() {\n if (!this.state.error) return this.props.children\n return this.props.fallback?.(this.state.error) ?? diagnostic('This app stopped rendering', this.state.error.message)\n }\n}\n\n/**\n * The keyframes the boot screen needs.\n *\n * Inlined as a <style> element rather than a CSS import because this package\n * is consumed as source by app bundlers that are not all configured to handle\n * a stylesheet import, and a boot screen must never be the thing that breaks\n * the build it is meant to report on.\n */\nconst BOOT_KEYFRAMES =\n '@keyframes frontera-boot-cube{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}' +\n '@keyframes frontera-boot-shimmer{0%{background-position:100% 0}100%{background-position:-100% 0}}' +\n '@media(prefers-reduced-motion:reduce){' +\n '[data-frontera-boot-cube]{animation-duration:3.9s!important}' +\n '[data-frontera-boot-shimmer]{animation:none!important}}'\n\n/**\n * Per-cube animation delays, in grid order.\n *\n * Copied from the platform's `.spinner-cube:nth-child(n)` rules rather than\n * re-derived, so the two read as the same object in motion. The pattern is a\n * diagonal wave: equal delays run bottom-left to top-right.\n */\nconst BOOT_CUBE_DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]\n\n/**\n * Colours for the boot screen.\n *\n * Tokens, with literals only for the app that defines none of them.\n *\n * This paints in whatever scheme the document is already in, which before the\n * handshake means the app's own `:root` — light, unless the app itself has put\n * `.dark` on the document. Nothing here sets the scheme: `applyHostTheme` runs\n * at handshake, by which point this screen is gone, and a guess made earlier\n * would be a write that outlives the screen and fights the app for control of\n * its own theme.\n *\n * There is deliberately no `foreground` entry. In the platform theme\n * `--foreground` holds a bare HSL triplet (`47 13% 14%`) rather than a colour —\n * it is wrapped as `hsl(var(--foreground))` at the point of use — and Tailwind's\n * `@theme inline` does not emit a `--color-foreground` custom property to reach\n * for instead. Naming either one here produces an invalid declaration that the\n * browser drops. Text therefore INHERITS, which lands on the app's own body\n * colour: already correct, in either scheme, with nothing to keep in sync.\n */\nconst BOOT_PALETTE = {\n // `--surface`, not `--background`: this screen sits where the app's own\n // surface will be, so booting on the surface colour means the handover to\n // the real UI is not also a change of backdrop. It needs no `--color-*`\n // form — unlike the two below it, `--surface` is already a colour.\n background: 'var(--surface, #fafafa)',\n muted: 'var(--muted-foreground, #6b7280)',\n border: 'var(--border, #e5e7eb)',\n surface: 'var(--surface-raised, var(--surface, #fafafa))',\n destructive: 'var(--destructive, #dc2626)',\n}\n\n/**\n * The spinner shown while the session is being established.\n *\n * The platform's own `GridSpinner` — a 3×3 of pulsing cubes — rebuilt here in\n * inline styles, because this package has neither Tailwind nor the stylesheet\n * that rule lives in. Matching it matters more than the few lines it costs: an\n * app booting inside the platform should not announce itself with a spinner\n * shape that appears nowhere else in the product.\n *\n * Not a top bar: `AppFrame` already shows `LoadingBar` outside the iframe for\n * exactly this wait, and a second bar inside the frame would draw the same\n * progress twice.\n *\n * Sized in `em` like the original, so the cubes track the font size rather\n * than needing a second number kept in sync.\n */\nfunction bootSpinner(): ReactNode {\n return (\n <div\n aria-hidden=\"true\"\n style={{\n display: 'inline-grid',\n gridTemplateColumns: 'repeat(3, 1fr)',\n gap: '0.08em',\n width: '1em',\n height: '1em',\n fontSize: 28,\n color: BOOT_PALETTE.muted,\n // Tops the container's 10 up to the 16 this sits above.\n marginBottom: 6,\n }}\n >\n {BOOT_CUBE_DELAYS.map((delay, index) => (\n <span\n key={index}\n data-frontera-boot-cube=\"\"\n style={{\n backgroundColor: 'currentColor',\n transform: 'scale3d(0.5, 0.5, 1)',\n animation: `frontera-boot-cube 1.3s ${delay}s infinite ease-in-out`,\n }}\n />\n ))}\n </div>\n )\n}\n\n/**\n * The screens shown before an app can render: connecting, and the two ways\n * starting can fail.\n *\n * Colours come from `BOOT_PALETTE` rather than being written inline, because\n * this paints BEFORE `applyHostTheme` has run and the values have to hold up\n * without it.\n */\nfunction diagnostic(title: string, detail: string, variant: 'loading' | 'error' = 'error'): ReactNode {\n const loading = variant === 'loading'\n return (\n <div\n // A loading screen is a status, not an alert: `alert` is assertive and\n // interrupts the screen-reader user on every single app boot.\n role={loading ? 'status' : 'alert'}\n aria-live={loading ? 'polite' : undefined}\n style={{\n boxSizing: 'border-box',\n minHeight: '100dvh',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 32,\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n background: BOOT_PALETTE.background,\n lineHeight: 1.5,\n }}\n >\n <style>{BOOT_KEYFRAMES}</style>\n <div\n style={{\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'center',\n // The two gaps differ: 10 between the lines of text, 16 from the\n // indicator down to them. `gap` sets the smaller one for every pair\n // and the indicator adds the remainder below itself, so the two\n // numbers stay readable instead of being one compromise value.\n gap: 10,\n textAlign: 'center',\n maxWidth: 380,\n }}\n >\n {loading ? (\n bootSpinner()\n ) : (\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width={30}\n height={30}\n fill=\"none\"\n stroke={BOOT_PALETTE.destructive}\n strokeWidth={2}\n strokeLinecap=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"M12 8v5\" />\n <path d=\"M12 16.5h.01\" />\n </svg>\n )}\n <div\n data-frontera-boot-shimmer={loading ? '' : undefined}\n style={{\n // No font-size: `<strong>` inherited the body size on main, and\n // matching it keeps this screen the size it has always been.\n fontWeight: 'bold' as const,\n // A highlight sweeping across the glyphs themselves, clipped to the\n // text. Only while connecting: an error is not in progress, and\n // animating it would suggest the app is still trying.\n //\n // The gradient rests on `currentColor`, which is the heading's own\n // full-contrast colour — on main this was plain `--foreground`, and\n // a muted base would leave it at the same weight as the line\n // beneath it for most of the sweep.\n //\n // `currentColor` rather than the token, because a token can fail:\n // in the platform theme `--foreground` holds a bare HSL triplet, so\n // naming it inside `linear-gradient()` makes the whole declaration\n // invalid. The browser then drops the gradient but KEEPS\n // `-webkit-text-fill-color: transparent` — and the heading vanishes\n // entirely. `currentColor` is always a valid colour, so the worst\n // case is a shimmer that does not shimmer, never invisible text.\n //\n // The travelling stop is `muted-foreground`, NOT `primary`: primary\n // is near-black in the default light theme, which is also what the\n // heading already is, so that sweep was three identical stops and\n // no visible motion. Muted is the one token guaranteed to differ\n // from body text in both schemes — that is what it is for.\n ...(loading\n ? {\n backgroundImage: `linear-gradient(90deg, currentColor 30%, ${BOOT_PALETTE.muted} 50%, currentColor 70%)`,\n backgroundSize: '200% 100%',\n WebkitBackgroundClip: 'text',\n backgroundClip: 'text',\n fontSize: 18,\n // Transparent text is invisible if `background-clip: text` is\n // not honoured, so the fill is set the same way and the\n // colour underneath stays the readable one.\n WebkitTextFillColor: 'transparent',\n animation: 'frontera-boot-shimmer 2s linear infinite',\n }\n : {}),\n }}\n >\n {title}\n </div>\n <div\n style={{\n fontSize: 14,\n color: BOOT_PALETTE.muted,\n // An error detail is a raw message or stack fragment: monospaced,\n // left-aligned and boxed so a long one stays readable instead of\n // becoming a centred wall of text.\n ...(loading\n ? {}\n : {\n fontFamily: 'var(--font-mono, ui-monospace, monospace)',\n textAlign: 'left' as const,\n background: BOOT_PALETTE.surface,\n border: `1px solid ${BOOT_PALETTE.border}`,\n borderRadius: 'calc(var(--radius, 8px) - 2px)',\n padding: '10px 12px',\n maxWidth: '100%',\n overflowWrap: 'anywhere' as const,\n }),\n }}\n >\n {detail}\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * Holds what the host can change underneath a running app.\n *\n * The credential is state rather than a captured constant because the host\n * rotates it mid-session; `withCredential` returns a NEW client so requests\n * already in flight keep the one they started with.\n */\nfunction FronteraRoot({\n session,\n mode,\n children,\n providers,\n errorFallback,\n}: {\n session: BridgeSession\n mode: FronteraAppMode\n children: ReactNode\n providers: FronteraProvider[]\n errorFallback?: (error: Error) => ReactNode\n}) {\n const { init } = session\n const [client, setClient] = useState(\n () =>\n new FronteraClient({\n apiBaseUrl: init.apiBaseUrl,\n orgId: init.orgId ?? undefined,\n workspaceId: init.workspaceId ?? undefined,\n credential: { kind: 'token', token: init.token },\n }),\n )\n\n useEffect(\n () => session.onToken((token) => setClient((c) => c.withCredential({ kind: 'token', token }))),\n [session],\n )\n\n useEffect(() => session.onTheme((theme) => applyHostTheme(theme)), [session])\n\n const value: FronteraAppValue = {\n mode,\n init,\n client,\n navigate: (path) => {\n if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })\n },\n }\n\n let tree: ReactNode = children\n // Applied in reverse so the FIRST provider listed ends up outermost, which is\n // the order a reader expects from the array.\n for (const provider of [...providers].reverse()) tree = provider(value, tree)\n\n return (\n <FronteraAppReactContext.Provider value={value}>\n <AppErrorBoundary\n fallback={errorFallback}\n onError={(error) =>\n session.send({ type: 'frontera:error', message: error.message, stack: error.stack })\n }\n >\n {tree}\n </AppErrorBoundary>\n </FronteraAppReactContext.Provider>\n )\n}\n\nexport interface FronteraAppProviderProps {\n children: ReactNode\n providers?: FronteraProvider[]\n loading?: ReactNode\n errorFallback?: (error: Error) => ReactNode\n /** Compatibility hooks used by createFronteraApp. */\n queryClient?: QueryClient\n timeoutMs?: number\n}\n\n/**\n * Transport-neutral React entry point for embedded, standalone, and local Apps.\n * Mode selection happens before connection, so an ordinary standalone page\n * never waits for an iframe handshake that cannot succeed.\n */\nexport function FronteraAppProvider({\n children,\n providers = [],\n loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.', 'loading'),\n errorFallback,\n queryClient: providedQueryClient,\n timeoutMs,\n}: FronteraAppProviderProps): ReactNode {\n const [connection, setConnection] = useState<{\n mode: FronteraAppMode\n session: BridgeSession\n } | null>(null)\n const [error, setError] = useState<Error | null>(null)\n const [queryClient] = useState(\n () =>\n providedQueryClient ??\n new QueryClient({\n defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },\n }),\n )\n\n useEffect(() => {\n const runtime = readRuntimeGlobal()\n const framed = typeof window !== 'undefined' && window.parent !== window\n const mode = detectAppMode(runtime, framed)\n if (!mode) {\n // Reachable only when NOT framed: `detectAppMode` returns 'embedded' for\n // any framed document, so a null mode means there is no parent and no\n // session endpoint either. A framed App that cannot resolve a parent\n // origin fails later and more precisely, inside `connectToHost`.\n setError(\n new Error(\n 'Frontera App is not configured: no embedded host, standalone session endpoint, or local development session endpoint was provided.',\n ),\n )\n return\n }\n\n let active = true\n let sessionToDispose: BridgeSession | null = null\n const connected =\n mode === 'embedded'\n ? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })\n : connectToSessionEndpoint(\n mode === 'standalone' ? runtime.sessionEndpoint! : runtime.devSessionEndpoint!,\n )\n void connected\n .then((session) => {\n sessionToDispose = session\n if (!active) {\n session.dispose()\n return\n }\n applyHostTheme(session.init.theme)\n setConnection({ mode, session })\n })\n .catch((reason) => {\n if (active) setError(reason instanceof Error ? reason : new Error(String(reason)))\n })\n\n return () => {\n active = false\n sessionToDispose?.dispose()\n }\n }, [])\n\n if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)\n if (!connection) return loading\n\n return (\n <QueryClientProvider client={queryClient}>\n <FronteraRoot\n mode={connection.mode}\n session={connection.session}\n providers={providers}\n errorFallback={errorFallback}\n >\n {children}\n </FronteraRoot>\n </QueryClientProvider>\n )\n}\n\nexport async function createFronteraApp(\n app: ReactNode,\n options: CreateFronteraAppOptions = {},\n): Promise<void> {\n const rootId = options.rootId ?? 'root'\n const element = document.getElementById(rootId)\n if (!element) {\n throw new Error(`createFronteraApp: no #${rootId} element — check index.html.`)\n }\n\n const root = createRoot(element)\n\n root.render(\n <StrictMode>\n <FronteraAppProvider\n providers={options.providers}\n queryClient={options.queryClient}\n timeoutMs={options.timeoutMs}\n >\n {app}\n </FronteraAppProvider>\n </StrictMode>,\n )\n}\n",
7
+ "frontera/core/create-frontera-app.tsx": "import {\n Component,\n StrictMode,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useState,\n type ErrorInfo,\n type ReactNode,\n} from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { connectToHost, type BridgeSession } from './bridge-client'\nimport type { BridgeInit } from './bridge-protocol'\nimport { FronteraClient } from './client'\nimport { readRuntimeGlobal } from './config'\nimport {\n connectToSessionEndpoint,\n detectAppMode,\n type FronteraAppMode,\n} from './app-session'\nimport { applyHostTheme } from './theme'\n\n/**\n * The entry point every app shares.\n *\n * Before this existed, the quickstart asked each author to hand-write the same\n * thirty-five lines: find the parent origin, postMessage `frontera:ready`,\n * listen for `frontera:init`, build a client from the token that arrives, and\n * only then mount. Copied boilerplate is where the origin check gets dropped —\n * and a missing origin check is the whole vulnerability class this architecture\n * exists to close. So it is written once, here.\n *\n * Mounting is DEFERRED until the handshake completes. An app has no data scope\n * until the host tells it who it is, so rendering earlier only produces a flash\n * of unauthorized requests, and every hook would need a \"not ready yet\" branch.\n */\n\nexport interface FronteraAppValue {\n mode: FronteraAppMode\n /** Everything the host said at handshake, including `path`. */\n init: BridgeInit\n client: FronteraClient\n /** Whether this app is the visible platform surface. */\n active: boolean\n /** Report local work explicitly; unreported state remains unknown to the host. */\n setUnsavedChanges(dirty: boolean): void\n /** Ask the platform to change its URL — the counterpart to `init.path`. */\n navigate(path: string): void\n}\n\nconst FronteraAppReactContext = createContext<FronteraAppValue | null>(null)\n\nexport function useFronteraApp(): FronteraAppValue {\n const value = useContext(FronteraAppReactContext)\n if (!value) {\n throw new Error(\n 'useFronteraApp() outside a Frontera app — createFronteraApp() installs this provider.',\n )\n }\n return value\n}\n\n/**\n * A wrapper contributed by a domain package.\n *\n * This is a callback rather than `createFronteraApp` importing the domain\n * clients directly, because `@frontera-sdk/blueprint` already depends on\n * `@frontera-sdk/core` — reaching back the other way would make the two\n * packages mutually dependent. `blueprintProvider` from\n * `@frontera-sdk/blueprint/provider` is one of these.\n */\nexport type FronteraProvider = (value: FronteraAppValue, children: ReactNode) => ReactNode\n\nexport interface CreateFronteraAppOptions {\n /** Element id to mount into. Defaults to `root`. */\n rootId?: string\n providers?: FronteraProvider[]\n queryClient?: QueryClient\n /** How long to wait for the host before reporting a failed mount. */\n timeoutMs?: number\n}\n\ninterface BoundaryProps {\n onError: (error: Error, info: ErrorInfo) => void\n fallback?: (error: Error) => ReactNode\n children: ReactNode\n}\n\n/**\n * Report a render crash to the host instead of leaving a blank frame.\n *\n * The platform listens for `frontera:error` and surfaces it next to the app, so\n * the author sees the message rather than an empty rectangle they have to open\n * devtools to explain.\n */\nclass AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }> {\n state: { error: Error | null } = { error: null }\n\n static getDerivedStateFromError(error: Error) {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo) {\n this.props.onError(error, info)\n }\n\n render() {\n if (!this.state.error) return this.props.children\n return this.props.fallback?.(this.state.error) ?? diagnostic('This app stopped rendering', this.state.error.message)\n }\n}\n\n/**\n * The keyframes the boot screen needs.\n *\n * Inlined as a <style> element rather than a CSS import because this package\n * is consumed as source by app bundlers that are not all configured to handle\n * a stylesheet import, and a boot screen must never be the thing that breaks\n * the build it is meant to report on.\n */\nconst BOOT_KEYFRAMES =\n '@keyframes frontera-boot-cube{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}' +\n '@keyframes frontera-boot-shimmer{0%{background-position:100% 0}100%{background-position:-100% 0}}' +\n '@media(prefers-reduced-motion:reduce){' +\n '[data-frontera-boot-cube]{animation-duration:3.9s!important}' +\n '[data-frontera-boot-shimmer]{animation:none!important}}'\n\n/**\n * Per-cube animation delays, in grid order.\n *\n * Copied from the platform's `.spinner-cube:nth-child(n)` rules rather than\n * re-derived, so the two read as the same object in motion. The pattern is a\n * diagonal wave: equal delays run bottom-left to top-right.\n */\nconst BOOT_CUBE_DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]\n\n/**\n * Colours for the boot screen.\n *\n * Tokens, with literals only for the app that defines none of them.\n *\n * This paints in whatever scheme the document is already in, which before the\n * handshake means the app's own `:root` — light, unless the app itself has put\n * `.dark` on the document. Nothing here sets the scheme: `applyHostTheme` runs\n * at handshake, by which point this screen is gone, and a guess made earlier\n * would be a write that outlives the screen and fights the app for control of\n * its own theme.\n *\n * There is deliberately no `foreground` entry. In the platform theme\n * `--foreground` holds a bare HSL triplet (`47 13% 14%`) rather than a colour —\n * it is wrapped as `hsl(var(--foreground))` at the point of use — and Tailwind's\n * `@theme inline` does not emit a `--color-foreground` custom property to reach\n * for instead. Naming either one here produces an invalid declaration that the\n * browser drops. Text therefore INHERITS, which lands on the app's own body\n * colour: already correct, in either scheme, with nothing to keep in sync.\n */\nconst BOOT_PALETTE = {\n // `--surface`, not `--background`: this screen sits where the app's own\n // surface will be, so booting on the surface colour means the handover to\n // the real UI is not also a change of backdrop. It needs no `--color-*`\n // form — unlike the two below it, `--surface` is already a colour.\n background: 'var(--surface, #fafafa)',\n muted: 'var(--muted-foreground, #6b7280)',\n border: 'var(--border, #e5e7eb)',\n surface: 'var(--surface-raised, var(--surface, #fafafa))',\n destructive: 'var(--destructive, #dc2626)',\n}\n\n/**\n * The spinner shown while the session is being established.\n *\n * The platform's own `GridSpinner` — a 3×3 of pulsing cubes — rebuilt here in\n * inline styles, because this package has neither Tailwind nor the stylesheet\n * that rule lives in. Matching it matters more than the few lines it costs: an\n * app booting inside the platform should not announce itself with a spinner\n * shape that appears nowhere else in the product.\n *\n * Not a top bar: `AppFrame` already shows `LoadingBar` outside the iframe for\n * exactly this wait, and a second bar inside the frame would draw the same\n * progress twice.\n *\n * Sized in `em` like the original, so the cubes track the font size rather\n * than needing a second number kept in sync.\n */\nfunction bootSpinner(): ReactNode {\n return (\n <div\n aria-hidden=\"true\"\n style={{\n display: 'inline-grid',\n gridTemplateColumns: 'repeat(3, 1fr)',\n gap: '0.08em',\n width: '1em',\n height: '1em',\n fontSize: 28,\n color: BOOT_PALETTE.muted,\n // Tops the container's 10 up to the 16 this sits above.\n marginBottom: 6,\n }}\n >\n {BOOT_CUBE_DELAYS.map((delay, index) => (\n <span\n key={index}\n data-frontera-boot-cube=\"\"\n style={{\n backgroundColor: 'currentColor',\n transform: 'scale3d(0.5, 0.5, 1)',\n animation: `frontera-boot-cube 1.3s ${delay}s infinite ease-in-out`,\n }}\n />\n ))}\n </div>\n )\n}\n\n/**\n * The screens shown before an app can render: connecting, and the two ways\n * starting can fail.\n *\n * Colours come from `BOOT_PALETTE` rather than being written inline, because\n * this paints BEFORE `applyHostTheme` has run and the values have to hold up\n * without it.\n */\nfunction diagnostic(title: string, detail: string, variant: 'loading' | 'error' = 'error'): ReactNode {\n const loading = variant === 'loading'\n return (\n <div\n // A loading screen is a status, not an alert: `alert` is assertive and\n // interrupts the screen-reader user on every single app boot.\n role={loading ? 'status' : 'alert'}\n aria-live={loading ? 'polite' : undefined}\n style={{\n boxSizing: 'border-box',\n minHeight: '100dvh',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 32,\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n background: BOOT_PALETTE.background,\n lineHeight: 1.5,\n }}\n >\n <style>{BOOT_KEYFRAMES}</style>\n <div\n style={{\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'center',\n // The two gaps differ: 10 between the lines of text, 16 from the\n // indicator down to them. `gap` sets the smaller one for every pair\n // and the indicator adds the remainder below itself, so the two\n // numbers stay readable instead of being one compromise value.\n gap: 10,\n textAlign: 'center',\n maxWidth: 380,\n }}\n >\n {loading ? (\n bootSpinner()\n ) : (\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width={30}\n height={30}\n fill=\"none\"\n stroke={BOOT_PALETTE.destructive}\n strokeWidth={2}\n strokeLinecap=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"M12 8v5\" />\n <path d=\"M12 16.5h.01\" />\n </svg>\n )}\n <div\n data-frontera-boot-shimmer={loading ? '' : undefined}\n style={{\n // No font-size: `<strong>` inherited the body size on main, and\n // matching it keeps this screen the size it has always been.\n fontWeight: 'bold' as const,\n // A highlight sweeping across the glyphs themselves, clipped to the\n // text. Only while connecting: an error is not in progress, and\n // animating it would suggest the app is still trying.\n //\n // The gradient rests on `currentColor`, which is the heading's own\n // full-contrast colour — on main this was plain `--foreground`, and\n // a muted base would leave it at the same weight as the line\n // beneath it for most of the sweep.\n //\n // `currentColor` rather than the token, because a token can fail:\n // in the platform theme `--foreground` holds a bare HSL triplet, so\n // naming it inside `linear-gradient()` makes the whole declaration\n // invalid. The browser then drops the gradient but KEEPS\n // `-webkit-text-fill-color: transparent` — and the heading vanishes\n // entirely. `currentColor` is always a valid colour, so the worst\n // case is a shimmer that does not shimmer, never invisible text.\n //\n // The travelling stop is `muted-foreground`, NOT `primary`: primary\n // is near-black in the default light theme, which is also what the\n // heading already is, so that sweep was three identical stops and\n // no visible motion. Muted is the one token guaranteed to differ\n // from body text in both schemes — that is what it is for.\n ...(loading\n ? {\n backgroundImage: `linear-gradient(90deg, currentColor 30%, ${BOOT_PALETTE.muted} 50%, currentColor 70%)`,\n backgroundSize: '200% 100%',\n WebkitBackgroundClip: 'text',\n backgroundClip: 'text',\n fontSize: 18,\n // Transparent text is invisible if `background-clip: text` is\n // not honoured, so the fill is set the same way and the\n // colour underneath stays the readable one.\n WebkitTextFillColor: 'transparent',\n animation: 'frontera-boot-shimmer 2s linear infinite',\n }\n : {}),\n }}\n >\n {title}\n </div>\n <div\n style={{\n fontSize: 14,\n color: BOOT_PALETTE.muted,\n // An error detail is a raw message or stack fragment: monospaced,\n // left-aligned and boxed so a long one stays readable instead of\n // becoming a centred wall of text.\n ...(loading\n ? {}\n : {\n fontFamily: 'var(--font-mono, ui-monospace, monospace)',\n textAlign: 'left' as const,\n background: BOOT_PALETTE.surface,\n border: `1px solid ${BOOT_PALETTE.border}`,\n borderRadius: 'calc(var(--radius, 8px) - 2px)',\n padding: '10px 12px',\n maxWidth: '100%',\n overflowWrap: 'anywhere' as const,\n }),\n }}\n >\n {detail}\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * Holds what the host can change underneath a running app.\n *\n * The credential is state rather than a captured constant because the host\n * rotates it mid-session; `withCredential` returns a NEW client so requests\n * already in flight keep the one they started with.\n */\nfunction FronteraRoot({\n session,\n mode,\n children,\n providers,\n errorFallback,\n}: {\n session: BridgeSession\n mode: FronteraAppMode\n children: ReactNode\n providers: FronteraProvider[]\n errorFallback?: (error: Error) => ReactNode\n}) {\n const { init } = session\n const [client, setClient] = useState(\n () =>\n new FronteraClient({\n apiBaseUrl: init.apiBaseUrl,\n orgId: init.orgId ?? undefined,\n workspaceId: init.workspaceId ?? undefined,\n credential: { kind: 'token', token: init.token },\n }),\n )\n\n useEffect(\n () => session.onToken((token) => setClient((c) => c.withCredential({ kind: 'token', token }))),\n [session],\n )\n\n useEffect(() => session.onTheme((theme) => applyHostTheme(theme)), [session])\n const [active, setActive] = useState(init.active ?? true)\n useEffect(() => session.onActivation(setActive), [session])\n const setUnsavedChanges = useCallback((dirty: boolean) => {\n if (mode === 'embedded') session.send({ type: 'frontera:unsaved', dirty })\n }, [mode, session])\n\n const value: FronteraAppValue = {\n mode,\n init,\n client,\n active,\n setUnsavedChanges,\n navigate: (path) => {\n if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })\n },\n }\n\n let tree: ReactNode = children\n // Applied in reverse so the FIRST provider listed ends up outermost, which is\n // the order a reader expects from the array.\n for (const provider of [...providers].reverse()) tree = provider(value, tree)\n\n return (\n <FronteraAppReactContext.Provider value={value}>\n <AppErrorBoundary\n fallback={errorFallback}\n onError={(error) =>\n session.send({ type: 'frontera:error', message: error.message, stack: error.stack })\n }\n >\n {tree}\n </AppErrorBoundary>\n </FronteraAppReactContext.Provider>\n )\n}\n\nexport interface FronteraAppProviderProps {\n children: ReactNode\n providers?: FronteraProvider[]\n loading?: ReactNode\n errorFallback?: (error: Error) => ReactNode\n /** Compatibility hooks used by createFronteraApp. */\n queryClient?: QueryClient\n timeoutMs?: number\n}\n\n/**\n * Transport-neutral React entry point for embedded, standalone, and local Apps.\n * Mode selection happens before connection, so an ordinary standalone page\n * never waits for an iframe handshake that cannot succeed.\n */\nexport function FronteraAppProvider({\n children,\n providers = [],\n loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.', 'loading'),\n errorFallback,\n queryClient: providedQueryClient,\n timeoutMs,\n}: FronteraAppProviderProps): ReactNode {\n const [connection, setConnection] = useState<{\n mode: FronteraAppMode\n session: BridgeSession\n } | null>(null)\n const [error, setError] = useState<Error | null>(null)\n const [queryClient] = useState(\n () =>\n providedQueryClient ??\n new QueryClient({\n defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },\n }),\n )\n\n useEffect(() => {\n const runtime = readRuntimeGlobal()\n const framed = typeof window !== 'undefined' && window.parent !== window\n const mode = detectAppMode(runtime, framed)\n if (!mode) {\n // Reachable only when NOT framed: `detectAppMode` returns 'embedded' for\n // any framed document, so a null mode means there is no parent and no\n // session endpoint either. A framed App that cannot resolve a parent\n // origin fails later and more precisely, inside `connectToHost`.\n setError(\n new Error(\n 'Frontera App is not configured: no embedded host, standalone session endpoint, or local development session endpoint was provided.',\n ),\n )\n return\n }\n\n let active = true\n let sessionToDispose: BridgeSession | null = null\n const connected =\n mode === 'embedded'\n ? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })\n : connectToSessionEndpoint(\n mode === 'standalone' ? runtime.sessionEndpoint! : runtime.devSessionEndpoint!,\n )\n void connected\n .then((session) => {\n sessionToDispose = session\n if (!active) {\n session.dispose()\n return\n }\n applyHostTheme(session.init.theme)\n setConnection({ mode, session })\n })\n .catch((reason) => {\n if (active) setError(reason instanceof Error ? reason : new Error(String(reason)))\n })\n\n return () => {\n active = false\n sessionToDispose?.dispose()\n }\n }, [])\n\n if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)\n if (!connection) return loading\n\n return (\n <QueryClientProvider client={queryClient}>\n <FronteraRoot\n mode={connection.mode}\n session={connection.session}\n providers={providers}\n errorFallback={errorFallback}\n >\n {children}\n </FronteraRoot>\n </QueryClientProvider>\n )\n}\n\nexport async function createFronteraApp(\n app: ReactNode,\n options: CreateFronteraAppOptions = {},\n): Promise<void> {\n const rootId = options.rootId ?? 'root'\n const element = document.getElementById(rootId)\n if (!element) {\n throw new Error(`createFronteraApp: no #${rootId} element — check index.html.`)\n }\n\n const root = createRoot(element)\n\n root.render(\n <StrictMode>\n <FronteraAppProvider\n providers={options.providers}\n queryClient={options.queryClient}\n timeoutMs={options.timeoutMs}\n >\n {app}\n </FronteraAppProvider>\n </StrictMode>,\n )\n}\n",
8
8
  "frontera/core/client.ts": "import { resolveConfig, type FronteraConfig, type FronteraCredential } from './config'\nimport { httpRequest, type RequestOptions } from './transport'\n\n/**\n * The transport-level entry point. Domain clients (`@frontera-sdk/blueprint`, and\n * later chat and automation) take one of these rather than reimplementing\n * auth, URL building, or error mapping.\n *\n * `withCredential` exists because a bridge-hosted app receives a refreshed\n * token mid-session: swapping produces a new client rather than mutating one\n * that in-flight requests may still hold.\n */\nexport class FronteraClient {\n readonly config: FronteraConfig\n private readonly fetchImpl: typeof fetch\n\n constructor(config: Partial<FronteraConfig>, fetchImpl: typeof fetch = fetch) {\n this.config = resolveConfig(config)\n this.fetchImpl = fetchImpl\n }\n\n request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return httpRequest<T>(this.config, path, options, this.fetchImpl)\n }\n\n withCredential(credential: FronteraCredential): FronteraClient {\n return new FronteraClient({ ...this.config, credential }, this.fetchImpl)\n }\n}\n",
9
- "frontera/core/bridge-protocol.ts": "/**\n * postMessage protocol between the platform host and an app iframe.\n *\n * The app runs on its own origin (`<appId>.apps.<domain>`), so postMessage is\n * the ONLY channel. Both sides validate `event.origin` on every message: Luigi,\n * a mature framework with this same architecture, carries an open issue for\n * omitting exactly that check, and the consequence is any page being able to\n * drive an embedded app.\n */\n\nexport interface BridgeInit {\n type: 'frontera:init'\n /** Scoped API credential. Arrives here, never in a URL. */\n token: string\n apiBaseUrl: string\n appId: string\n version: string\n orgId: string | null\n workspaceId: string | null\n /**\n * The host's palette and light/dark state.\n *\n * Defaults, not a house style: `applyHostTheme` writes them behind the app's\n * own stylesheets so a customer's brand still wins.\n */\n theme: { tokens: Record<string, string>; colorScheme?: 'light' | 'dark'; fontCss?: string }\n /** Current shared app state, already validated by the host. */\n state: Record<string, unknown>\n /**\n * Where the app should open, taken from the platform URL.\n *\n * The counterpart to `frontera:navigate`: without it a shared link to\n * `/apps/<slug>/shipments` mounts the app at its home page, and the address\n * bar describes a view the user is not looking at. Absent means the app's\n * own default.\n */\n path?: string\n}\n\nexport type HostMessage =\n | BridgeInit\n | { type: 'frontera:state'; state: Record<string, unknown> }\n | {\n type: 'frontera:theme'\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n fontCss?: string\n }\n | { type: 'frontera:token'; token: string }\n\nexport type AppMessage =\n | { type: 'frontera:ready' }\n | { type: 'frontera:navigate'; path: string }\n | { type: 'frontera:resize'; height: number }\n | { type: 'frontera:state-write'; patch: Record<string, unknown> }\n | { type: 'frontera:error'; message: string; stack?: string }\n | { type: 'frontera:state-repair'; keys: string[] }\n\nexport function isHostMessage(data: unknown): data is HostMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:init':\n return typeof m.token === 'string' && typeof m.apiBaseUrl === 'string' && typeof m.appId === 'string'\n case 'frontera:state':\n return typeof m.state === 'object' && m.state !== null\n case 'frontera:theme':\n return typeof m.tokens === 'object' && m.tokens !== null\n case 'frontera:token':\n return typeof m.token === 'string'\n default:\n return false\n }\n}\n\nexport function isAppMessage(data: unknown): data is AppMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:ready':\n return true\n case 'frontera:navigate':\n return typeof m.path === 'string'\n case 'frontera:resize':\n return typeof m.height === 'number' && Number.isFinite(m.height)\n case 'frontera:state-write':\n return typeof m.patch === 'object' && m.patch !== null\n case 'frontera:error':\n return typeof m.message === 'string'\n case 'frontera:state-repair':\n return Array.isArray(m.keys)\n default:\n return false\n }\n}\n",
9
+ "frontera/core/bridge-protocol.ts": "/**\n * postMessage protocol between the platform host and an app iframe.\n *\n * The app runs on its own origin (`<appId>.apps.<domain>`), so postMessage is\n * the ONLY channel. Both sides validate `event.origin` on every message: Luigi,\n * a mature framework with this same architecture, carries an open issue for\n * omitting exactly that check, and the consequence is any page being able to\n * drive an embedded app.\n */\n\nexport interface BridgeInit {\n type: 'frontera:init'\n /** Scoped API credential. Arrives here, never in a URL. */\n token: string\n apiBaseUrl: string\n appId: string\n version: string\n orgId: string | null\n workspaceId: string | null\n /**\n * The host's palette and light/dark state.\n *\n * Defaults, not a house style: `applyHostTheme` writes them behind the app's\n * own stylesheets so a customer's brand still wins.\n */\n theme: { tokens: Record<string, string>; colorScheme?: 'light' | 'dark'; fontCss?: string }\n /** Current shared app state, already validated by the host. */\n state: Record<string, unknown>\n /**\n * Where the app should open, taken from the platform URL.\n *\n * The counterpart to `frontera:navigate`: without it a shared link to\n * `/apps/<slug>/shipments` mounts the app at its home page, and the address\n * bar describes a view the user is not looking at. Absent means the app's\n * own default.\n */\n path?: string\n active?: boolean\n}\n\nexport type HostMessage =\n | BridgeInit\n | { type: 'frontera:host-ready' }\n | { type: 'frontera:host-navigate'; requestId: string; path: string }\n | { type: 'frontera:activation'; active: boolean }\n | { type: 'frontera:state'; state: Record<string, unknown> }\n | {\n type: 'frontera:theme'\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n fontCss?: string\n }\n | { type: 'frontera:token'; token: string }\n\nexport type AppMessage =\n | { type: 'frontera:ready' }\n | { type: 'frontera:route-ready'; protocol: 1 }\n | { type: 'frontera:navigate'; path: string; mode?: 'push' | 'replace' | 'traverse' | 'hash' | 'load' }\n | { type: 'frontera:navigation-ack'; requestId: string; path: string }\n | { type: 'frontera:unsaved'; dirty: boolean }\n | { type: 'frontera:resize'; height: number }\n | { type: 'frontera:state-write'; patch: Record<string, unknown> }\n | { type: 'frontera:error'; message: string; stack?: string }\n | { type: 'frontera:state-repair'; keys: string[] }\n\nexport function isHostMessage(data: unknown): data is HostMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:host-ready':\n return true\n case 'frontera:host-navigate':\n return typeof m.requestId === 'string' && isAppPath(m.path)\n case 'frontera:activation':\n return typeof m.active === 'boolean'\n case 'frontera:init':\n return typeof m.token === 'string' && typeof m.apiBaseUrl === 'string' && typeof m.appId === 'string'\n case 'frontera:state':\n return typeof m.state === 'object' && m.state !== null\n case 'frontera:theme':\n return typeof m.tokens === 'object' && m.tokens !== null\n case 'frontera:token':\n return typeof m.token === 'string'\n default:\n return false\n }\n}\n\nexport function isAppMessage(data: unknown): data is AppMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:unsaved':\n return typeof m.dirty === 'boolean'\n case 'frontera:route-ready':\n return m.protocol === 1\n case 'frontera:navigation-ack':\n return typeof m.requestId === 'string' && isAppPath(m.path)\n case 'frontera:ready':\n return true\n case 'frontera:navigate':\n return isAppPath(m.path) && (m.mode === undefined || ['push', 'replace', 'traverse', 'hash', 'load'].includes(m.mode as string))\n case 'frontera:resize':\n return typeof m.height === 'number' && Number.isFinite(m.height)\n case 'frontera:state-write':\n return typeof m.patch === 'object' && m.patch !== null\n case 'frontera:error':\n return typeof m.message === 'string'\n case 'frontera:state-repair':\n return Array.isArray(m.keys)\n default:\n return false\n }\n}\n\n/** App locations are relative to the isolated app origin. */\nfunction isAppPath(path: unknown): path is string {\n return typeof path === 'string' && path.startsWith('/') && !path.startsWith('//') && !path.includes('\\\\')\n}\n",
10
10
  "frontera/core/app-state.ts": "/**\n * Shared app state: typed, validated, and legible to a mounted agent.\n *\n * Free-form JSON was explicitly rejected. It types as `any`, so a typo compiles,\n * and a write from a stale app version or a guessing agent can put a string\n * where the app expects an object and take the render down — worse than V1,\n * where a schema made invalid state unrepresentable.\n *\n * One declaration does three jobs: it types `useAppState` via inference, it\n * validates every read and write at runtime, and the build emits it as JSON\n * Schema so the agent receives a typed tool rather than a free-form object.\n */\n\n/** Minimal structural type for a Zod-like schema, so this file has no Zod dependency. */\nexport interface StandardSchema<T> {\n safeParse(value: unknown): { success: true; data: T } | { success: false; error: unknown }\n}\n\nexport interface AppStateDefinition<T extends Record<string, unknown>> {\n schema: StandardSchema<T>\n defaults: T\n /** Per-key schemas, when available, enable per-key recovery. */\n shape?: Record<string, StandardSchema<unknown>>\n}\n\nexport interface DefineAppStateInput<T extends Record<string, unknown>> {\n schema: StandardSchema<T> & { shape?: Record<string, StandardSchema<unknown>> }\n defaults: T\n}\n\nexport function defineAppState<T extends Record<string, unknown>>(\n input: DefineAppStateInput<T>,\n): AppStateDefinition<T> {\n return {\n schema: input.schema,\n defaults: input.defaults,\n shape: (input.schema as { shape?: Record<string, StandardSchema<unknown>> }).shape,\n }\n}\n\nexport interface RecoveryResult<T> {\n state: T\n /** Keys that failed validation and were reset to their default. */\n repaired: string[]\n /** Keys present in storage but absent from the schema — hidden, not dropped. */\n unknown: string[]\n}\n\n/**\n * Parse a stored state document into something the app can render.\n *\n * **Invariant: this never throws.** Recovery is per key — a malformed or\n * missing key resets to its default while valid keys survive — so the app\n * always receives a well-formed, correctly-typed object. Unknown keys (written\n * by a newer version, or invented by an agent) are hidden from the app but\n * PRESERVED by the caller in storage, so a rollback does not lose them.\n *\n * Whole-document validation is tried first because it is the common path and\n * cheap; per-key recovery is the fallback that keeps a single bad field from\n * blanking everything else.\n */\nexport function parseAppState<T extends Record<string, unknown>>(\n definition: AppStateDefinition<T>,\n stored: unknown,\n): RecoveryResult<T> {\n const document = (typeof stored === 'object' && stored !== null ? stored : {}) as Record<string, unknown>\n\n const whole = definition.schema.safeParse(document)\n if (whole.success) {\n // Project onto known keys explicitly rather than trusting the schema to\n // strip: a `.passthrough()` (or any hand-rolled) schema would otherwise\n // hand the app a key it has no type for.\n const projected = {} as Record<string, unknown>\n for (const key of Object.keys(definition.defaults)) {\n projected[key] = (whole.data as Record<string, unknown>)[key] ?? definition.defaults[key]\n }\n return {\n state: projected as T,\n repaired: [],\n unknown: Object.keys(document).filter((k) => !(k in definition.defaults)),\n }\n }\n\n const state = { ...definition.defaults } as Record<string, unknown>\n const repaired: string[] = []\n const unknownKeys: string[] = []\n\n for (const key of Object.keys(document)) {\n if (!(key in definition.defaults)) {\n unknownKeys.push(key)\n continue\n }\n const keySchema = definition.shape?.[key]\n if (!keySchema) {\n // No per-key schema available: accept only if it matches the default's\n // primitive shape, otherwise fall back. Cheap, and better than trusting it.\n const sameShape = typeof document[key] === typeof definition.defaults[key]\n if (sameShape) state[key] = document[key]\n else repaired.push(key)\n continue\n }\n const parsed = keySchema.safeParse(document[key])\n if (parsed.success) state[key] = parsed.data\n else repaired.push(key)\n }\n\n // Keys the document omitted entirely already hold their default.\n return { state: state as T, repaired, unknown: unknownKeys }\n}\n",
11
11
  "frontera/core/transport.ts": "import type { FronteraConfig } from './config'\nimport { errorFromResponse } from './errors'\n\nexport interface RequestOptions {\n // PATCH is included because the platform uses it for partial writes —\n // staging an agent draft is one. The transport already passes any method\n // straight to fetch, so its absence here was a type narrower than the\n // runtime, which type-checks a correct call as an error.\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n body?: unknown\n query?: Record<string, string | number | boolean | undefined>\n signal?: AbortSignal\n /**\n * Extra headers for ONE request. Added for `idempotency-key`, which the\n * governed write plane reads from the header rather than the body — a key in\n * the payload would be part of the invocation it is meant to deduplicate.\n *\n * Auth is not overridable: the credential headers are applied after these, so\n * a caller cannot swap the principal by passing `authorization` here.\n */\n headers?: Record<string, string>\n}\n\n/**\n * Auth and scope headers for one request.\n *\n * Both credential kinds travel as `Authorization: Bearer`. `x-org-id` and\n * `x-workspace-id` mirror what the web app sends and are what the service's\n * auth middleware reads to scope a session request. A workspace key ignores\n * them and resolves its own scope from the key row, so they can never widen\n * access beyond what the credential already allows.\n */\nexport function authHeaders(config: FronteraConfig): Record<string, string> {\n const token =\n config.credential.kind === 'apiKey' ? config.credential.key : config.credential.token\n const headers: Record<string, string> = { authorization: `Bearer ${token}` }\n if (config.orgId) headers['x-org-id'] = config.orgId\n if (config.workspaceId) headers['x-workspace-id'] = config.workspaceId\n return headers\n}\n\n/**\n * Unwrap the service response envelope when present.\n *\n * `ok()` returns `{ data, message, error: false }`, but several routers — the\n * Blueprint query router among them — return their payload raw. Detect the\n * envelope rather than assuming it.\n */\nexport function unwrap<T>(body: unknown): T {\n if (\n typeof body === 'object' &&\n body !== null &&\n !Array.isArray(body) &&\n (body as { error?: unknown }).error === false &&\n 'data' in body\n ) {\n return (body as { data: T }).data\n }\n return body as T\n}\n\nfunction lowerCasedKeys(headers: Record<string, string> | undefined): Record<string, string> {\n if (!headers) return {}\n return Object.fromEntries(\n Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),\n )\n}\n\nfunction buildUrl(config: FronteraConfig, path: string, query?: RequestOptions['query']): string {\n const url = new URL(`${config.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`)\n if (query) {\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined) continue\n url.searchParams.set(key, String(value))\n }\n }\n return url.toString()\n}\n\n/**\n * `fetchImpl` is injectable so tests never touch the network. Production\n * callers omit it and get the platform `fetch`.\n */\nexport async function httpRequest<T>(\n config: FronteraConfig,\n path: string,\n options: RequestOptions = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<T> {\n // Caller headers first, so the credential and content type below win. A\n // request that could overwrite `authorization` would let any call site act as\n // another principal, which is precisely what the transport exists to prevent.\n //\n // Lower-cased on the way in, because \"later key wins\" is a property of the\n // OBJECT and header names are case-insensitive on the wire: `Authorization`\n // and `authorization` are two distinct properties here and one name to\n // `Headers`, which COMBINES them into `forged, real` rather than letting the\n // credential replace the forgery.\n const headers: Record<string, string> = {\n ...lowerCasedKeys(options.headers),\n ...authHeaders(config),\n accept: 'application/json',\n }\n if (options.body !== undefined) headers['content-type'] = 'application/json'\n\n const response = await fetchImpl(buildUrl(config, path, options.query), {\n method: options.method ?? 'GET',\n headers,\n body: options.body === undefined ? undefined : JSON.stringify(options.body),\n signal: options.signal,\n })\n\n const text = await response.text()\n let parsed: unknown = null\n if (text.length > 0) {\n try {\n parsed = JSON.parse(text)\n } catch {\n parsed = text\n }\n }\n\n if (!response.ok) throw errorFromResponse(response.status, parsed)\n return unwrap<T>(parsed)\n}\n",
12
12
  "frontera/core/theme.ts": "/**\n * Applying the host's palette inside an app.\n *\n * The point is that an app sitting in the platform should not look pasted in —\n * it follows the deployment's colours and its light/dark state without the\n * author wiring anything.\n *\n * The hard part is precedence. An app that has defined its own brand must keep\n * it; the host is supplying DEFAULTS, not a house style. So host tokens are\n * written into a stylesheet inserted as the FIRST child of `<head>`, before the\n * app's own imports. Same `:root` specificity, earlier in the cascade — the\n * app's `brand.css` still wins every token it names, and inherits the rest.\n *\n * Inline styles on the root element would have been simpler and wrong: they\n * beat every stylesheet, so the host would silently overwrite the customer's\n * brand the moment it was mounted.\n */\n\nconst STYLE_ID = 'frontera-host-theme'\n\nexport interface HostTheme {\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n}\n\n/** CSS custom properties only — never arbitrary declarations from the host. */\nfunction isTokenName(name: string): boolean {\n return /^--[a-zA-Z0-9_-]+$/.test(name)\n}\n\n/**\n * A token value the host sent.\n *\n * Restricted because this string is interpolated into a stylesheet: `}` would\n * close the rule and let anything after it become a new one. The host is\n * trusted, but a value that escapes its rule is a bug class worth closing at\n * the boundary rather than reasoning about.\n */\nfunction isTokenValue(value: string): boolean {\n return typeof value === 'string' && value.length < 200 && !/[{}<>;]/.test(value)\n}\n\nexport function applyHostTheme(theme: HostTheme | undefined): void {\n if (typeof document === 'undefined' || !theme) return\n\n const declarations = Object.entries(theme.tokens ?? {})\n .filter(([name, value]) => isTokenName(name) && isTokenValue(value))\n .map(([name, value]) => `${name}: ${value};`)\n .join('\\n ')\n\n if (declarations) {\n let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null\n if (!style) {\n style = document.createElement('style')\n style.id = STYLE_ID\n // First in <head>, so every app stylesheet that follows can override.\n document.head.prepend(style)\n }\n style.textContent = `:root {\\n ${declarations}\\n}`\n }\n\n if (theme.colorScheme) {\n const dark = theme.colorScheme === 'dark'\n document.documentElement.classList.toggle('dark', dark)\n // Tells the browser to pick the matching form controls and scrollbars,\n // which no amount of custom properties can do.\n document.documentElement.style.colorScheme = dark ? 'dark' : 'light'\n }\n}\n",
13
13
  "frontera/core/config.ts": "import { FronteraError } from './errors'\n\n/**\n * How a caller proves who it is.\n *\n * `apiKey` is a long-lived `sk-ws-` workspace key, used by servers and CLIs.\n * `token` is a short-lived credential minted by the platform and handed to an\n * app at bridge handshake. Both travel as `Authorization: Bearer`; they are\n * distinct variants because refresh behaviour differs and callers branch on it.\n */\nexport type FronteraCredential =\n | { kind: 'apiKey'; key: string }\n | { kind: 'token'; token: string }\n\nexport interface FronteraConfig {\n /** Origin of the Frontera API, without a trailing slash. */\n apiBaseUrl: string\n orgId?: string\n workspaceId?: string\n credential: FronteraCredential\n}\n\n/**\n * Shape the app runtime injects into the page at serve time.\n *\n * This mirrors `RuntimeConfig` on the serving side, in full. It was previously\n * narrowed to the three fields `resolveConfig` reads, which made the other two\n * invisible to every caller — and `platformOrigin` is the one an app cannot do\n * without: responses carry `referrer-policy: no-referrer`, so `document.referrer`\n * is empty and this is the ONLY way to learn who is framing you.\n */\nexport interface FronteraRuntimeGlobal {\n apiBaseUrl?: string\n orgId?: string\n workspaceId?: string\n appId?: string\n version?: string\n platformOrigin?: string\n /** Same-origin short-lived user session for direct App visits. */\n sessionEndpoint?: string\n /** Loopback-only CLI broker used during authenticated local development. */\n devSessionEndpoint?: string\n}\n\nexport function readRuntimeGlobal(): FronteraRuntimeGlobal {\n const injected = (globalThis as { __FRONTERA_CONFIG__?: FronteraRuntimeGlobal })\n .__FRONTERA_CONFIG__\n return injected ?? {}\n}\n\nfunction readGlobal(): FronteraRuntimeGlobal {\n return readRuntimeGlobal()\n}\n\nfunction missing(field: string): never {\n throw new FronteraError(\n `Frontera SDK is not configured: ${field} is missing. Pass it explicitly, or set the matching FRONTERA_* environment variable.`,\n { code: 'CONFIG_MISSING' },\n )\n}\n\n/**\n * Resolve configuration from, in order of precedence: explicit argument, the\n * runtime global injected into an app page, then environment variables.\n *\n * `env` is a parameter rather than a direct `process.env` read so tests never\n * mutate global state, and so this works unchanged in a browser bundle where\n * `process` does not exist.\n */\nexport function resolveConfig(\n explicit?: Partial<FronteraConfig>,\n env: Record<string, string | undefined> = typeof process === 'undefined' ? {} : process.env,\n): FronteraConfig {\n const injected = readGlobal()\n\n const rawBaseUrl = explicit?.apiBaseUrl ?? injected.apiBaseUrl ?? env.FRONTERA_API_URL\n if (!rawBaseUrl) missing('apiBaseUrl')\n\n const credential: FronteraCredential | undefined =\n explicit?.credential ??\n (env.FRONTERA_TOKEN ? { kind: 'apiKey', key: env.FRONTERA_TOKEN } : undefined)\n if (!credential) missing('credential')\n\n return {\n apiBaseUrl: rawBaseUrl.replace(/\\/+$/, ''),\n orgId: explicit?.orgId ?? injected.orgId ?? env.FRONTERA_ORG_ID,\n workspaceId: explicit?.workspaceId ?? injected.workspaceId ?? env.FRONTERA_WORKSPACE_ID,\n credential,\n }\n}\n",
14
14
  "frontera/core/react.ts": "export {\n FronteraAppProvider,\n useFronteraApp,\n type FronteraAppProviderProps,\n type FronteraAppValue,\n type FronteraProvider,\n} from './create-frontera-app'\nexport type { FronteraAppMode } from './app-session'\n",
15
- "frontera/core/app-session.ts": "import type { BridgeSession } from './bridge-client'\nimport { isHostMessage, type BridgeInit } from './bridge-protocol'\nimport type { FronteraRuntimeGlobal } from './config'\n\nexport type FronteraAppMode = 'embedded' | 'standalone' | 'local'\n\n/**\n * Which transport this App should use.\n *\n * `framed` alone decides embedding — it is the only fact that answers \"is there\n * a parent to talk to\". `platformOrigin` is an INPUT to that conversation, not\n * evidence of it: `connectToHost` resolves the origin itself and falls back to\n * `document.referrer` when the host injected nothing.\n *\n * Requiring `platformOrigin` here made a framed App fall through to `null` and\n * report \"not configured\" — while the deployment was fine and only its\n * `CORS_ORIGIN` was `*`, which is the default. The App then hung before issuing\n * a single request, so nothing in the browser named the cause.\n */\nexport function detectAppMode(\n runtime: FronteraRuntimeGlobal,\n framed: boolean,\n): FronteraAppMode | null {\n if (framed) return 'embedded'\n if (runtime.sessionEndpoint) return 'standalone'\n if (runtime.devSessionEndpoint) return 'local'\n return null\n}\n\ninterface SessionResponse {\n init: BridgeInit\n expiresAt: number\n}\n\nexport interface EndpointSessionOptions {\n fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n now?: () => number\n setTimer?: (callback: () => void, delay: number) => unknown\n clearTimer?: (timer: unknown) => void\n}\n\nfunction parseExpiry(value: unknown): number {\n const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN\n if (!Number.isFinite(parsed)) throw new Error('Frontera session response has an invalid expiresAt')\n return parsed\n}\n\nfunction parseSessionResponse(value: unknown): SessionResponse {\n const envelope = value as { data?: unknown } | null\n const raw = (envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : value) as\n | Record<string, unknown>\n | null\n if (!raw || typeof raw !== 'object' || !isHostMessage(raw.init) || raw.init.type !== 'frontera:init') {\n throw new Error('Frontera session response is malformed')\n }\n return { init: raw.init, expiresAt: parseExpiry(raw.expiresAt) }\n}\n\nasync function fetchSession(\n endpoint: string,\n fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,\n): Promise<SessionResponse> {\n const response = await fetchImpl(endpoint, {\n credentials: 'include',\n headers: { accept: 'application/json' },\n })\n if (!response.ok) throw new Error(`Frontera session request failed with ${response.status}`)\n return parseSessionResponse(await response.json())\n}\n\n/** Build a bridge-compatible session from standalone or local HTTP auth. */\nexport async function connectToSessionEndpoint(\n endpoint: string,\n options: EndpointSessionOptions = {},\n): Promise<BridgeSession> {\n const fetchImpl = options.fetchImpl ?? fetch\n const now = options.now ?? Date.now\n const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))\n const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>))\n const first = await fetchSession(endpoint, fetchImpl)\n const tokenHandlers = new Set<(token: string) => void>()\n let disposed = false\n let timer: unknown\n\n const schedule = (expiresAt: number) => {\n if (disposed) return\n const delay = Math.max(0, Math.floor((expiresAt - now()) * 0.8))\n timer = setTimer(() => {\n void fetchSession(endpoint, fetchImpl)\n .then((next) => {\n if (disposed) return\n for (const handler of tokenHandlers) handler(next.init.token)\n schedule(next.expiresAt)\n })\n // A revoked session must not keep emitting credentials. Requests made\n // with the previous short-lived token naturally stop at its expiry.\n .catch(() => {})\n }, delay)\n }\n schedule(first.expiresAt)\n\n return {\n init: first.init,\n send() {},\n onState() {\n return () => {}\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme() {\n return () => {}\n },\n dispose() {\n disposed = true\n if (timer !== undefined) clearTimer(timer)\n tokenHandlers.clear()\n },\n }\n}\n",
15
+ "frontera/core/app-session.ts": "import type { BridgeSession } from './bridge-client'\nimport { isHostMessage, type BridgeInit } from './bridge-protocol'\nimport type { FronteraRuntimeGlobal } from './config'\n\nexport type FronteraAppMode = 'embedded' | 'standalone' | 'local'\n\n/**\n * Which transport this App should use.\n *\n * `framed` alone decides embedding — it is the only fact that answers \"is there\n * a parent to talk to\". `platformOrigin` is an INPUT to that conversation, not\n * evidence of it: `connectToHost` resolves the origin itself and falls back to\n * `document.referrer` when the host injected nothing.\n *\n * Requiring `platformOrigin` here made a framed App fall through to `null` and\n * report \"not configured\" — while the deployment was fine and only its\n * `CORS_ORIGIN` was `*`, which is the default. The App then hung before issuing\n * a single request, so nothing in the browser named the cause.\n */\nexport function detectAppMode(\n runtime: FronteraRuntimeGlobal,\n framed: boolean,\n): FronteraAppMode | null {\n if (framed) return 'embedded'\n if (runtime.sessionEndpoint) return 'standalone'\n if (runtime.devSessionEndpoint) return 'local'\n return null\n}\n\ninterface SessionResponse {\n init: BridgeInit\n expiresAt: number\n}\n\nexport interface EndpointSessionOptions {\n fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n now?: () => number\n setTimer?: (callback: () => void, delay: number) => unknown\n clearTimer?: (timer: unknown) => void\n}\n\nfunction parseExpiry(value: unknown): number {\n const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN\n if (!Number.isFinite(parsed)) throw new Error('Frontera session response has an invalid expiresAt')\n return parsed\n}\n\nfunction parseSessionResponse(value: unknown): SessionResponse {\n const envelope = value as { data?: unknown } | null\n const raw = (envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : value) as\n | Record<string, unknown>\n | null\n if (!raw || typeof raw !== 'object' || !isHostMessage(raw.init) || raw.init.type !== 'frontera:init') {\n throw new Error('Frontera session response is malformed')\n }\n return { init: raw.init, expiresAt: parseExpiry(raw.expiresAt) }\n}\n\nasync function fetchSession(\n endpoint: string,\n fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,\n): Promise<SessionResponse> {\n const response = await fetchImpl(endpoint, {\n credentials: 'include',\n headers: { accept: 'application/json' },\n })\n if (!response.ok) throw new Error(`Frontera session request failed with ${response.status}`)\n return parseSessionResponse(await response.json())\n}\n\n/** Build a bridge-compatible session from standalone or local HTTP auth. */\nexport async function connectToSessionEndpoint(\n endpoint: string,\n options: EndpointSessionOptions = {},\n): Promise<BridgeSession> {\n const fetchImpl = options.fetchImpl ?? fetch\n const now = options.now ?? Date.now\n const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))\n const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>))\n const first = await fetchSession(endpoint, fetchImpl)\n const tokenHandlers = new Set<(token: string) => void>()\n let disposed = false\n let timer: unknown\n\n const schedule = (expiresAt: number) => {\n if (disposed) return\n const delay = Math.max(0, Math.floor((expiresAt - now()) * 0.8))\n timer = setTimer(() => {\n void fetchSession(endpoint, fetchImpl)\n .then((next) => {\n if (disposed) return\n for (const handler of tokenHandlers) handler(next.init.token)\n schedule(next.expiresAt)\n })\n // A revoked session must not keep emitting credentials. Requests made\n // with the previous short-lived token naturally stop at its expiry.\n .catch(() => {})\n }, delay)\n }\n schedule(first.expiresAt)\n\n return {\n init: first.init,\n send() {},\n onState() {\n return () => {}\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme() {\n return () => {}\n },\n onActivation() {\n return () => {}\n },\n dispose() {\n disposed = true\n if (timer !== undefined) clearTimer(timer)\n tokenHandlers.clear()\n },\n }\n}\n",
16
16
  "frontera/blueprint/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
17
17
  "frontera/blueprint/action-types.ts": "/**\n * Types for the governed write plane.\n *\n * Reads and writes are deliberately separate surfaces. A read is answered from\n * a catalog snapshot; a write is a REQUEST against a durable ledger that may be\n * approved by someone else, dispatched by a background worker minutes later,\n * and reconciled after that. Modelling both as \"call the server\" would hide the\n * one fact a UI has to show: submitting is not the same as done.\n */\n\n/** Where a Request has got to. Only `succeeded` and the failures are terminal. */\nexport type ActionRequestLifecycle =\n | 'ready'\n | 'awaiting_approval'\n | 'dispatching'\n | 'finalizing'\n | 'succeeded'\n | 'failed'\n | 'cancelled'\n | 'rejected'\n | 'awaiting_resolution'\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `outcome_unknown` is the honest state after a dispatch whose\n * outcome could not be established, and a UI must not render it as failure:\n * the write may well have landed.\n */\nexport type ActionEffectCertainty =\n | 'not_attempted'\n | 'confirmed_applied'\n | 'confirmed_not_applied'\n | 'outcome_unknown'\n\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'failed', 'cancelled', 'rejected',\n])\n\nexport function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {\n return TERMINAL.has(lifecycle)\n}\n\n/**\n * What to tell the person who pressed the button.\n *\n * Four answers, and they come from the CERTAINTY, not the lifecycle — which is\n * the distinction every app gets wrong, because \"succeeded\" reads like the\n * finish line and is not.\n *\n * A request reaches `confirmed_applied` the moment the write commits, and then\n * spends a while in `finalizing` while the platform verifies its own promise:\n * it re-reads the target, checks the properties the Action declared it would\n * change, evaluates the postconditions. That verification NEVER undoes the\n * write — its worst outcome is `awaiting_resolution`, which still carries\n * `confirmed_applied` and means a human should look at why the proof was\n * inconclusive. So waiting for `succeeded` before telling someone their ticket\n * exists leaves them staring at a spinner over a ticket that already exists.\n *\n * `uncertain` is the one that must not be collapsed into either neighbour. It\n * means a dispatch was attempted and the outcome could not be established —\n * the connection died mid-commit, and the write may well have landed. Rendering\n * it as failure invites a duplicate; rendering it as success invites a lie. Say\n * it is being checked; the platform reconciles it against the target and the\n * answer arrives on its own.\n */\nexport type ActionEffect = 'pending' | 'applied' | 'refused' | 'uncertain'\n\nexport function actionEffectOf(\n request: Pick<ActionRequest, 'lifecycle' | 'effectCertainty'> | null | undefined,\n): ActionEffect {\n if (!request) return 'pending'\n switch (request.effectCertainty) {\n case 'confirmed_applied': return 'applied'\n case 'confirmed_not_applied': return 'refused'\n case 'outcome_unknown': return 'uncertain'\n default: break\n }\n // No certainty reported. A terminal lifecycle still answers the question —\n // a rejected or cancelled Request never reached the target at all — while\n // anything else is genuinely still in flight.\n return request.lifecycle && isTerminalLifecycle(request.lifecycle)\n && request.lifecycle !== 'succeeded'\n ? 'refused'\n : 'pending'\n}\n\n/**\n * The record version to send with an edit, read off an object instance.\n *\n * The version arrives as `_meta.recordVersion`, and only from the INSTANCE\n * route — a list query's rows do not carry it, and only an editable type has\n * one at all. Reaching into `_meta` by hand is how a caller ends up sending\n * `undefined`, which does not fail: the compare-and-set is simply skipped, and\n * two people overwrite each other with no refusal and no evidence.\n *\n * So a control that edits a row from a table fetches the instance first:\n *\n * ```tsx\n * const instance = useObjectInstance('SupportTicket', selectedId)\n * submit.mutate({ …, expectedVersion: recordVersionOf(instance.data) })\n * ```\n *\n * Returns `undefined` for a type with no overlay, which is correct — there is\n * no version to assert, and the write path does not expect one.\n */\nexport function recordVersionOf(instance: unknown): number | undefined {\n if (!instance || typeof instance !== 'object') return undefined\n const meta = (instance as { _meta?: unknown })._meta\n if (!meta || typeof meta !== 'object') return undefined\n const version = (meta as { recordVersion?: unknown }).recordVersion\n return typeof version === 'number' ? version : undefined\n}\n\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required'\n threshold?: number\n separationOfDuties?: boolean\n}\n\n/**\n * One Action this caller may invoke.\n *\n * Discovery returns ONLY what the caller is authorized for. An Action absent\n * from this list may be unpublished, undeployed, or simply not permitted to\n * this user — the three are indistinguishable here, by design.\n */\nexport interface ActionDescriptor {\n actionDefinitionId: string\n apiName: string\n displayName: string\n description: string\n contractDigest: string\n activeReleaseId: string\n availability: string\n subject: { objectTypeId: string; mode: 'existing' | 'create' }\n approval: ActionApprovalPolicy\n /** JSON Schema for the whole invocation envelope, not just the inputs. */\n inputSchema: Record<string, unknown>\n}\n\nexport interface ActionRequest {\n id: string\n actionDefinitionId: string\n apiName?: string\n lifecycle: ActionRequestLifecycle\n effectCertainty?: ActionEffectCertainty\n createdAt?: string\n updatedAt?: string\n}\n\n/**\n * What a caller supplies to invoke an Action.\n *\n * Deliberately flatter than the wire envelope. The service takes a subject\n * reference, a separate string `expectedSubjectVersion`, an `input` map and a\n * `reason`; and an Action with compare-and-set ALSO takes a numeric version as\n * an ordinary input. Two version fields, one string and one number, meaning\n * related but different things, is the kind of contract a hand-written caller\n * gets wrong once and then debugs for an hour. `submit` assembles it.\n */\nexport interface SubmitActionInput {\n /** Primary key of the object being changed. Omit only for `mode: 'create'`. */\n objectId?: string\n /** Parameter values, keyed by the Action's parameter API names. */\n input: Record<string, unknown>\n /**\n * The version the caller believes it read. Sent as the subject version AND,\n * when the Action declares a compare-and-set parameter, as that parameter —\n * so a stale write is refused rather than clobbering a concurrent one.\n */\n expectedVersion?: number | string\n /** Required when the Action declares `reason: 'required'`. */\n reason?: string\n correlationId?: string\n /**\n * Reused across retries of the SAME intended effect. Omit and the hook mints\n * one per user intent, which is almost always what you want: a retried\n * network call must not become a second escalation.\n */\n idempotencyKey?: string\n}\n",
18
18
  "frontera/blueprint/action-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport type {\n ActionDescriptor,\n ActionRequest,\n ActionRequestLifecycle,\n SubmitActionInput,\n} from './action-types'\n\nconst BASE = '/v1/blueprint/governed-actions'\n\n/**\n * The governed write plane.\n *\n * Authorized against the PERSON, never the app. A hosted app is handed a token\n * carrying the signed-in user's id, and every call is checked against that\n * user's organization role, their workspace membership, and the workspace's\n * grant on the object type. So the same page can offer a button to one\n * colleague and not another, and neither the app nor its author decides which.\n *\n * A workspace key cannot invoke at all — its principal belongs to no\n * organization member — which is why a scaffolded dev host, holding one, will\n * read fine and refuse every write. See `README` on `frontera app init`.\n */\nexport class ActionClient {\n constructor(private readonly client: FronteraClient) {}\n\n /**\n * Actions this user may invoke, here, now.\n *\n * An empty list is ambiguous ON PURPOSE — unpublished, undeployed and\n * unpermitted are indistinguishable to a caller, so a probe cannot map what\n * exists. That is right for security and hostile to debugging, so treat an\n * unexpected empty list as a question about the CALLER's permissions first.\n */\n discover(): Promise<ActionDescriptor[]> {\n return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)\n }\n\n /**\n * Submit one Action. Returns as soon as the Request is recorded — NOT when\n * the effect has landed.\n *\n * The write is durable from this point: it survives a closed tab, a restarted\n * server, and a worker that is not running yet. What it does not do is finish\n * synchronously, so a UI that renders success here is lying. Poll the Request\n * (`useActionRequest`) and show the lifecycle.\n */\n async submit(\n action: ActionDescriptor,\n input: SubmitActionInput,\n ): Promise<ActionRequest> {\n const idempotencyKey = input.idempotencyKey ?? mintIdempotencyKey()\n return this.client.request<ActionRequest>(\n `${BASE}/actions/${encodeURIComponent(action.apiName)}/requests`,\n {\n method: 'POST',\n headers: { 'idempotency-key': idempotencyKey },\n // Wrapped, and the wrapper is EXACT: the route accepts a body whose\n // keys are precisely `['invocation']` and refuses anything else with\n // \"Governed Action HTTP command body is invalid.\" — a message that\n // names the body rather than the field, so sending the envelope at the\n // top level reads like a malformed invocation instead of a missing\n // wrapper.\n body: { invocation: buildInvocation(action, input) },\n },\n )\n }\n\n requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {\n return this.client.request<ActionRequest[]>(`${BASE}/requests`, {\n query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,\n })\n }\n\n request(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)\n }\n\n /**\n * Approve or reject. Separate from `submit` because it is a different act by\n * a different person — an Action with separation of duties refuses a decision\n * from whoever submitted it.\n */\n decide(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,\n { method: 'POST', body: { decision, reason } },\n )\n }\n\n /**\n * Cancel takes NO body. It reads the request and the caller from the URL and\n * the credential; a `reason` sent here is refused as an invalid command body\n * rather than ignored, because the route accepts an exact key set.\n */\n cancel(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,\n { method: 'POST' },\n )\n }\n}\n\n/**\n * The wire envelope, assembled from the flat input a caller actually has.\n *\n * Exported for the tests: this is the part with a trap in it, and the trap is\n * silent — a wrong shape comes back as \"Action invocation is invalid\" with no\n * field named.\n */\nexport function buildInvocation(\n action: ActionDescriptor,\n input: SubmitActionInput,\n): Record<string, unknown> {\n const invocation: Record<string, unknown> = { input: { ...input.input } }\n\n if (action.subject.mode === 'existing') {\n if (!input.objectId) {\n throw new Error(\n `\"${action.apiName}\" changes an existing ${action.subject.objectTypeId}, so it needs an objectId.`,\n )\n }\n invocation.subjectRef = {\n objectTypeId: action.subject.objectTypeId,\n objectId: input.objectId,\n }\n if (input.expectedVersion !== undefined) {\n // A STRING here, deliberately: the subject version is an opaque token,\n // while the compare-and-set parameter below is the numeric record\n // version. Same number, two types, two meanings.\n invocation.expectedSubjectVersion = String(input.expectedVersion)\n }\n } else if (input.objectId) {\n throw new Error(`\"${action.apiName}\" creates an object, so it takes no objectId.`)\n }\n\n // Fed to the Action's own compare-and-set parameter when it declares one,\n // and only then — an Action without it would refuse the unknown key.\n const casParameter = compareAndSetParameter(action)\n if (casParameter && input.expectedVersion !== undefined) {\n const parameters = invocation.input as Record<string, unknown>\n if (!(casParameter in parameters)) parameters[casParameter] = Number(input.expectedVersion)\n }\n\n if (input.reason !== undefined) invocation.reason = input.reason\n if (input.correlationId !== undefined) invocation.correlationId = input.correlationId\n return invocation\n}\n\n/**\n * The parameter carrying the record version, read off the published schema\n * rather than assumed by name.\n */\nfunction compareAndSetParameter(action: ActionDescriptor): string | null {\n const input = (action.inputSchema as { properties?: Record<string, unknown> } | undefined)\n ?.properties?.input as { properties?: Record<string, unknown> } | undefined\n const properties = input?.properties\n if (!properties) return null\n return 'expectedVersion' in properties ? 'expectedVersion' : null\n}\n\n/**\n * One key per user intent.\n *\n * The service dedupes by this: the same key with a different invocation is\n * REFUSED, and the same key with the same invocation returns the original\n * Request rather than acting twice. So it must be stable across retries of one\n * intent and different between two intents — which is exactly the lifetime of\n * a single `submit` call, not of a component or a session.\n */\nfunction mintIdempotencyKey(): string {\n const random = globalThis.crypto?.randomUUID?.()\n ?? Math.random().toString(36).slice(2).padEnd(22, '0')\n return `frontera-app-${random}`\n}\n",
@@ -30,6 +30,6 @@
30
30
  "frontera/automation/manifest.ts": "import { CronExpressionParser } from 'cron-parser'\nimport { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'\n\nconst SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'\nconst NAME_RE = new RegExp(`^${SEGMENT}$`)\n\n/**\n * Runtime gate for a grant: `<namespace>:<name>[:<action>]`, every segment\n * sharing NAME_RE's grammar so the whole vocabulary is consistent.\n *\n * WIDER than the `Grant` union on namespaces — a server must not reject\n * `notify:email` merely because this build predates it. NARROWER than the\n * union's `agent:${string}:run` arm on the slug, which admits `agent::run` and\n * `agent:AGENT:run`; both are rejected here. No legitimate slug is affected —\n * this repo's agent slugs are already lowercase-kebab.\n */\nconst GRANT_RE = new RegExp(`^${SEGMENT}:${SEGMENT}(?::${SEGMENT})?$`)\n\n/**\n * `http:<host>` and `secret:<NAME>` need their own grammars, because SEGMENT is\n * lowercase-kebab and neither value is.\n *\n * A host contains DOTS (`api.stripe.com`); a secret name is conventionally\n * SCREAMING_SNAKE_CASE (`STRIPE_KEY`). Validating them with SEGMENT rejected both\n * realistic forms — found by deploying an automation that used them.\n *\n * Deliberately not solved by widening SEGMENT: that governs agent slugs too, and\n * loosening it there would admit `agent:AGENT:run`, which the comment above says\n * is rejected on purpose.\n */\nconst HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/\n// Matches SECRET_NAME_PATTERN in workspace-secrets-router exactly. Being MORE\n// permissive here would let a manifest declare `secret:myKey`, validate cleanly,\n// and then never be satisfiable — no such secret can be created. A validator that\n// accepts the unsatisfiable is worse than one that is strict.\nconst SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/\n// Matches `apiNameSchema` in the Blueprint Action definition schema exactly.\n// Same reasoning as SECRET_NAME_RE: a looser grammar here would accept\n// `governed:Approve_Invoice`, validate cleanly, and name an Action that can\n// never exist — no published Action carries that apiName, so the grant is\n// unsatisfiable and the automation fails at its first submit instead of at\n// deploy.\nconst ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/\n\n// `plugin:<install>:<capability>`. The service does NOT validate\n// `app_installs.install_name` — it is `t.String({ minLength: 1 })`, so an\n// admin can name an install \"My CRM\" and it works fine everywhere except\n// here. This grammar (lowercase, dot/dash/underscore, no spaces — the catalog\n// default is kebab) is what makes an install's name usable from a manifest;\n// one outside it has to be renamed before an automation can grant it. The\n// capability half is deliberately wider: MCP tool names and spec capability\n// names are `create_issue` / `listIssues`, neither of which is a SEGMENT. No\n// wildcard in either half — the manifest is the reviewable list of what the\n// automation can reach, same as `http:`.\nconst PLUGIN_GRANT_RE = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/\n\n/** Namespaces whose value is not a SEGMENT. */\nconst TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {\n http: { re: HOST_RE, hint: 'a hostname, e.g. \"http:api.stripe.com\" (no scheme, no path, no wildcard)' },\n secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. \"secret:STRIPE_KEY\"' },\n governed: {\n re: ACTION_API_NAME_RE,\n hint: 'one published Action apiName, e.g. \"governed:approveInvoice\" (camelCase, no wildcard)',\n },\n plugin: {\n re: PLUGIN_GRANT_RE,\n hint:\n '<install>:<capability>, e.g. \"plugin:crm:create_ticket\" — the install name from '\n + '`frontera plugin list` (lowercase, no spaces) and one capability name (no wildcard)'\n + ' — rename the install if its name has capitals or spaces',\n },\n}\n\n/**\n * Shortest description an agent-callable automation may carry.\n *\n * Not a round number picked for looks: it is about the length of one honest\n * clause (\"Reconcile open invoices against the settlement file\"), and it is\n * chosen to be long enough that the slug restated as a sentence — \"reconcile\n * invoices\" — does not clear it. A description that only repeats the name\n * tells a model nothing it did not already have from the tool name.\n */\nexport const AGENT_DESCRIPTION_MIN_CHARS = 24\n\nconst KNOWN_KEYS = new Set([\n 'name', 'trigger', 'grants', 'inputs', 'concurrency', 'retries', 'description',\n])\n\nexport interface ValidationResult {\n valid: boolean\n errors: string[]\n /**\n * Non-fatal. An unknown manifest key lands here rather than in `errors`:\n * a newer SDK must be able to add a field without an older service refusing\n * the deploy. The CLI prints these, so a typo like `concurrancy: 100` — which\n * would otherwise deploy \"successfully\" with the default of 1 — is caught at\n * author time, where the SDK and the manifest are the same version.\n */\n warnings: string[]\n}\n\n/**\n * Takes `unknown`, on purpose.\n *\n * The authoritative call site is the service, validating a manifest that\n * arrived over HTTP — untrusted, and not yet known to have any shape. Typing\n * the parameter as `AutomationManifest` would force every honest caller to\n * launder untrusted input through a cast, which is how a validator ends up\n * trusting the thing it exists to check.\n *\n * The regexes here are deliberately wider than the `Grant` union in `types.ts`:\n * that union is an author-time affordance, this is a runtime gate, and a server\n * must not reject a grant merely because this build predates it.\n */\nexport function validateManifest(input: unknown): ValidationResult {\n const errors: string[] = []\n const warnings: string[] = []\n const m = (input ?? {}) as {\n name?: unknown\n trigger?: unknown\n grants?: unknown\n inputs?: unknown\n concurrency?: unknown\n retries?: unknown\n description?: unknown\n }\n\n if (typeof m.name !== 'string' || !NAME_RE.test(m.name)) {\n errors.push('name must be lowercase kebab-case')\n } else if (m.name.length > 64) {\n errors.push('name must be 64 characters or fewer')\n }\n\n const trigger = m.trigger as\n | { cron?: string; manual?: boolean; agent?: boolean }\n | undefined\n if (\n !trigger\n || (trigger.cron === undefined && trigger.manual !== true && trigger.agent !== true)\n ) {\n errors.push('trigger must be { cron }, { manual: true }, or { agent: true }')\n } else if (trigger.agent !== undefined && trigger.agent !== true) {\n // Not folded into the arm above: `{ manual: true, agent: false }` is a\n // legal-looking manifest that means nothing. `agent` is a permission, and\n // the way to withhold a permission is to omit it, not to write it false —\n // the same rule the grant list follows.\n errors.push(\n 'trigger.agent must be true when present — omit the key to mean \"not agent-callable\"',\n )\n }\n // Separate `if`, not the old `else if`: with three arms the cron check has to\n // run whenever a cron is present, including on `{ cron, agent: true }`, and\n // an `else if` chained off the acceptance test above would skip it there.\n if (trigger?.cron !== undefined) {\n if (typeof trigger.cron !== 'string') {\n errors.push('invalid cron expression: must be a string')\n } else {\n // Both field-count branches exist because `cron-parser` accepts an\n // off-count expression rather than throwing, so neither case would ever\n // reach the `catch` below:\n // `* * * * * *` -> reads field 1 as SECONDS and fires sub-minute.\n // `0 7 * *` -> left-pads, scheduling something the author never wrote.\n // Only an exactly-5-field expression means what it looks like it means.\n const fields = trigger.cron.trim().split(/\\s+/).length\n if (fields !== 5) {\n // One message shape for one class of fault. Splitting it meant a\n // 7-field expression was told it was \"sub-minute\" — a diagnosis\n // asserted rather than derived — while a 4-field one got no diagnosis\n // at all.\n errors.push(\n `invalid cron expression: expected 5 fields, got ${fields}` +\n (fields > 5 ? '; sub-minute schedules are not supported' : ''),\n )\n } else {\n try {\n CronExpressionParser.parse(trigger.cron, { tz: 'UTC' })\n } catch (err) {\n errors.push(`invalid cron expression: ${(err as Error).message}`)\n }\n }\n }\n }\n\n if (m.grants !== undefined && !Array.isArray(m.grants)) {\n errors.push('grants must be an array')\n } else {\n for (const g of (m.grants as unknown[]) ?? []) {\n // String(g), not `${g}` — a template literal THROWS on a symbol, and a\n // validator that exists to absorb hostile input must not have a throwing\n // path. The message names the fix, not just the verdict.\n if (typeof g !== 'string') {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n continue\n }\n const colon = g.indexOf(':')\n const typed = colon > 0 ? TYPED_NAMESPACES[g.slice(0, colon)] : undefined\n if (typed) {\n // A typed namespace validates its OWN value grammar. `http:` and\n // `secret:` carry hosts and secret names, neither of which is a SEGMENT.\n if (!typed.re.test(g.slice(colon + 1))) {\n errors.push(`malformed grant \"${g}\" — the part after the colon must be ${typed.hint}`)\n }\n continue\n }\n if (!GRANT_RE.test(g)) {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n }\n }\n }\n\n const c = m.concurrency\n if (c !== undefined && (!Number.isInteger(c) || (c as number) < 1 || (c as number) > 50)) {\n errors.push('concurrency must be an integer between 1 and 50')\n }\n\n // Capped at 5. Above that it is not a retry policy, it is a loop — and every\n // attempt re-runs whatever side effects the previous one already performed.\n const r = m.retries\n if (r !== undefined && (!Number.isInteger(r) || (r as number) < 0 || (r as number) > 5)) {\n errors.push('retries must be an integer between 0 and 5')\n }\n\n const inputs = m.inputs\n if (inputs !== undefined) {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) {\n errors.push('inputs must be an object of { name: { type, … } }')\n } else {\n // Per-field rules (name shape, type, required/default shape, enum) live\n // in `checkInputFieldSpec` — shared with `sanitizeInputsSchema` so the\n // two can never drift on what \"a well-formed input field\" means.\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const field = checkInputFieldSpec(key, raw)\n errors.push(...field.errors)\n warnings.push(...field.warnings)\n }\n // A cron fire has no one to prompt: every required field must be\n // satisfiable from defaults, or the schedule would fail on every tick.\n const trig = m.trigger as { cron?: unknown } | undefined\n if (typeof trig?.cron === 'string') {\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { type?: unknown; required?: unknown; default?: unknown }\n if (spec?.required === true && spec.default === undefined) {\n // A `file` input can never carry a default (refused above), so the\n // \"add a default\" remedy would send the author straight into the\n // next validation error — name the two remedies that actually work.\n const remedy = spec.type === 'file'\n ? 'A file input cannot have a default — make the input optional or the trigger manual.'\n : 'Add a default or make the trigger manual.'\n errors.push(\n `input \"${key}\" is required with no default, and the trigger is a cron — `\n + `cron has nobody to ask. ${remedy}`,\n )\n }\n }\n }\n // A run's input is capped at MAX_INPUT_BYTES when it starts. If the\n // declared defaults alone already exceed that, a cron fire (or a bare\n // Run-now) fails inside run-open before any run row exists — a silent\n // death only visible in runner logs. Catch it here, the one place the\n // author is still looking at the file.\n const defaultsOnly: Record<string, unknown> = {}\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { default?: unknown }\n if (spec && typeof spec === 'object' && spec.default !== undefined) {\n defaultsOnly[key] = spec.default\n }\n }\n try {\n const bytes = new TextEncoder().encode(JSON.stringify(defaultsOnly)).length\n if (bytes > MAX_INPUT_BYTES) {\n errors.push(\n `input defaults alone serialize to ${bytes} bytes — over the ${MAX_INPUT_BYTES}-byte `\n + 'run-input cap, so every run would fail at start. Slim the defaults.',\n )\n }\n } catch {\n // A default that JSON.stringify chokes on (circular, throwing toJSON)\n // is practically unreachable — the manifest itself must serialize to\n // deploy at all — but the size check must never be the thing that throws.\n }\n }\n }\n\n // `trigger: { agent: true }` turns this manifest into the source of a tool\n // definition a language model reads and decides from. Two fields that are\n // courtesies everywhere else become load-bearing here, so they are errors\n // rather than warnings: a model handed an undescribed tool, or an undescribed\n // argument, does not fail loudly — it guesses, and the guess starts a real\n // run against real systems. Checked at deploy, where the author still has the\n // file open, rather than at bind time in a Console someone else is using.\n if (trigger?.agent === true) {\n const description = m.description\n if (typeof description !== 'string' || description.trim().length < AGENT_DESCRIPTION_MIN_CHARS) {\n errors.push(\n `trigger { agent: true } requires a description of at least ${AGENT_DESCRIPTION_MIN_CHARS} `\n + 'characters — it becomes the tool description an agent reads before calling this '\n + 'automation.',\n )\n }\n const agentInputs = m.inputs\n if (agentInputs && typeof agentInputs === 'object' && !Array.isArray(agentInputs)) {\n for (const [key, raw] of Object.entries(agentInputs as Record<string, unknown>)) {\n const spec = raw as { description?: unknown; redact?: unknown } | null\n if (typeof spec?.description !== 'string' || spec.description.trim().length === 0) {\n errors.push(\n `input \"${key}\" needs a description: trigger { agent: true } publishes every input as `\n + 'a tool argument, and an agent cannot fill an argument it has no description for.',\n )\n }\n // An error, not a warning, and refused here where the author still has\n // the file open. On the agent path the MODEL produces this value as\n // tool-call arguments: it is in the conversation and in that\n // conversation's trace before a run row exists to mask. Masking the run\n // row would advertise a guarantee this path cannot keep.\n if (spec?.redact === true) {\n errors.push(\n `input \"${key}\": redact cannot be used with trigger { agent: true } — the agent `\n + 'supplies this value as a tool argument, so it is already in the conversation and '\n + 'its trace before the run exists.',\n )\n }\n }\n }\n }\n\n if (input && typeof input === 'object' && !Array.isArray(input)) {\n for (const key of Object.keys(input)) {\n if (!KNOWN_KEYS.has(key)) {\n warnings.push(`unknown manifest key \"${key}\" — ignored`)\n }\n }\n }\n\n return { valid: errors.length === 0, errors, warnings }\n}\n",
31
31
  "frontera/automation/index.ts": "export { automation, defineFunction } from './define'\nexport { validateManifest } from './manifest'\nexport type { ValidationResult } from './manifest'\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\nexport { createTestContext } from './testing'\nexport type { TestCall, TestContext, TestContextOptions } from './testing'\nexport {\n MAX_INPUT_BYTES,\n checkInputFieldSpec,\n redactedInputKeys,\n sanitizeInputsSchema,\n validateInputValue,\n} from './inputs'\nexport type { InputFieldCheck, InputValidation } from './inputs'\nexport {\n EVENT_TRIGGER_SOURCES,\n TICKETED_TRIGGER_SOURCES,\n isEventTriggerSource,\n isTicketedTriggerSource,\n} from './types'\nexport type * from './types'\n",
32
32
  "frontera/automation/define.ts": "import type {\n AutomationDescriptor,\n AutomationHandler,\n AutomationManifest,\n AutomationTrigger,\n InputsSchema,\n} from './types'\n\n/**\n * Declare a Function: a manifest and the handler the runner executes.\n *\n * Named `automation` until the rename; that name is still exported below as a\n * deprecated alias, because a project scaffolded before the rename imports it\n * and `frontera function pull` replays a stored archive verbatim.\n */\nexport function defineFunction(\n manifest: AutomationManifest,\n handler: AutomationHandler,\n): AutomationDescriptor {\n if (!manifest?.name) throw new Error('Automation name is required')\n if (typeof handler !== 'function') throw new Error('Automation handler must be a function')\n\n // Copied, not aliased. `{ ...manifest }` is shallow, so without this the\n // frozen manifest would still hold a live reference to the caller's trigger\n // object — and a later mutation of it would silently change the schedule the\n // runner registers.\n const trigger: AutomationTrigger = { ...manifest.trigger }\n\n let inputs: Readonly<InputsSchema> | undefined\n if (manifest.inputs) {\n try {\n inputs = Object.freeze(structuredClone(manifest.inputs))\n } catch {\n // `structuredClone` throws a raw `DataCloneError` DOMException on a\n // function/symbol/etc default, which names neither the automation nor\n // the field — useless in a deploy log. Rethrow with both.\n throw new Error(`Automation \"${manifest.name}\": input defaults must be JSON-serializable values`)\n }\n }\n\n return Object.freeze({\n manifest: Object.freeze({\n ...manifest,\n trigger: Object.freeze(trigger),\n grants: Object.freeze([...(manifest.grants ?? [])]),\n ...(inputs ? { inputs } : {}),\n concurrency: manifest.concurrency ?? 1,\n retries: manifest.retries ?? 0,\n }),\n handler,\n })\n}\n\n/**\n * @deprecated Use `defineFunction`. Kept so a project written against\n * `@frontera-sdk/functions` still compiles after `frontera function pull`\n * hydrates it, and so a bundle built from one still parses — `step-graph.ts`\n * matches both names for exactly this reason.\n */\nexport const automation = defineFunction\n",
33
- "theme.css": "/* GENERATED from packages/web/src/app/globals.css — do not edit.\n * Refresh with `frontera app add theme`.\n *\n * Gives an app the same Tailwind utilities and design tokens the platform\n * uses, so copied components look native rather than unstyled. Import this\n * once from your entry (`import './theme.css'`).\n */\n@import \"tailwindcss\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n --color-background: hsl(var(--background));\n --color-foreground: hsl(var(--foreground));\n --color-dot: var(--dot-foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-inline-code: var(--inline-code);\n --color-inline-code-bg: var(--inline-code-bg);\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);\n --color-info: var(--info);\n --color-info-foreground: var(--info-foreground);\n --color-border: var(--border);\n --color-border-secondary: var(--border-secondary);\n --color-input: var(--input);\n --color-ring: var(--ring);\n --color-sidebar: var(--sidebar);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n --color-chart-6: var(--chart-6);\n --color-chart-7: var(--chart-7);\n --color-chart-8: var(--chart-8);\n --color-chart-9: var(--chart-9);\n --color-chart-10: var(--chart-10);\n --color-chart-11: var(--chart-11);\n --color-chart-12: var(--chart-12);\n /* Capability tints — UI chrome, kept out of the chart ramp on purpose. */\n --color-capability-data: var(--capability-data);\n --color-capability-run: var(--capability-run);\n --color-capability-reach: var(--capability-reach);\n --color-capability-act: var(--capability-act);\n --color-capability-compute: var(--capability-compute);\n --color-capability-flow: var(--capability-flow);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n --radius-2xl: calc(var(--radius) + 8px);\n\n /* next/font variables live on <html>. Inline so `font-mono` and preflight\n `code`/`pre` use Geist (then a real mono stack) rather than Tailwind's\n default, and never a proportional face. */\n --font-mono: var(--font-brand-mono, var(--font-geist-mono)), ui-monospace,\n SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\",\n monospace;\n\n /* Shadow elevation tokens — Attio-inspired multi-layer */\n /* --shadow-xs: var(--elevation-xs);\n --shadow-sm: var(--elevation-sm);\n --shadow-md: var(--elevation-md);\n --shadow-lg: var(--elevation-lg);\n --shadow-xl: var(--elevation-xl); */\n\n /* Surface elevation tokens */\n --color-surface-inset-deep: var(--surface-inset-deep);\n --color-surface-inset-deep-hover: var(--surface-inset-deep-hover);\n --color-surface-inset-deep-active: var(--surface-inset-deep-active);\n --color-surface-inset: var(--surface-inset);\n --color-surface-inset-hover: var(--surface-inset-hover);\n --color-surface-inset-active: var(--surface-inset-active);\n --color-surface: var(--surface);\n --color-surface-chat: var(--surface-chat);\n --color-surface-hover: var(--surface-hover);\n --color-surface-active: var(--surface-active);\n --color-surface-raised: var(--surface-raised);\n --color-surface-raised-hover: var(--surface-raised-hover);\n --color-surface-raised-active: var(--surface-raised-active);\n /* --color-surface-overlay: var(--surface-raised);\n --color-surface-overlay-hover: var(--surface-raised-hover);\n --color-surface-overlay-active: var(--surface-raised-active); */\n --color-surface-overlay: var(--surface-overlay);\n --color-surface-overlay-hover: var(--surface-overlay-hover);\n --color-surface-overlay-active: var(--surface-overlay-active);\n /* The modal scrim, shared by every Dialog and Sheet. */\n --color-scrim: var(--scrim);\n /* Quint-out. For transitions long enough (250ms+) that Tailwind's `ease-out`\n — cubic-bezier(0, 0, 0.2, 1) — reads as near-constant motion: this covers\n 58% of the distance in the first 15% of the time against ease-out's 37%,\n so the element commits immediately and then settles. Use it for a surface\n opening or expanding; keep `ease-out` for short state changes, where the\n difference isn't perceptible. */\n --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);\n\n /* Image lightbox chrome sits over arbitrary pixels while still matching the\n active light/dark theme. */\n --color-image-overlay-scrim: var(--image-overlay-scrim);\n --color-image-overlay-control: var(--image-overlay-control);\n --color-image-overlay-control-hover: var(--image-overlay-control-hover);\n --color-image-overlay-border: var(--image-overlay-border);\n --color-image-overlay-foreground: var(--image-overlay-foreground);\n --color-image-overlay-muted: var(--image-overlay-muted);\n --color-image-overlay-thumb: var(--image-overlay-thumb);\n --color-image-checker-a: var(--image-checker-a);\n --color-image-checker-b: var(--image-checker-b);\n}\n\n:root {\n --radius: 0.625rem;\n\n /* Widest a config-row chip (skill / pack / app / context) may grow before\n its label truncates. Roughly 28 characters at text-xs — long enough to\n read a name, short enough that one chip can't monopolise the row. */\n --chip-max-width: 13rem;\n\n /* Bottom padding every `/console` page ends with, so a scrolled list never\n stops flush against the panel edge and the last row clears the home\n indicator on a notched phone. ONE value at every breakpoint — \"the same\n bottom padding on every console page\" is the whole point of it, and\n `env()` resolves to 0 off a notched device.\n\n Reach for the `console-page` utility below, which already applies this.\n Use `pb-(--console-page-tail)` directly only where the container sets no\n padding shorthand of its own — a bare `pb-*` is emitted with the\n unprefixed utilities and so LOSES to a later `sm:p-*`/`md:py-*` on the\n same element. That is measured, not assumed: in the built stylesheet the\n unprefixed padding block sits around 250kB and `sm:p-4` at 402kB. */\n --console-page-tail: calc(2rem + env(safe-area-inset-bottom));\n\n --background: 0 0% 100%;\n\n /* React flow canvas tokens */\n --dot-foreground: #c9c9c9;\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0 0% 93%);\n --surface-inset-deep-hover: hsl(0 0% 89%);\n --surface-inset-deep-active: hsl(0 0% 86%);\n --surface-inset: hsl(0 0% 96%);\n --surface-inset-hover: hsl(0 0% 91%);\n --surface-inset-active: hsl(0 0% 88%);\n --surface: hsl(0 0% 98%);\n /* Chat-view backdrop — slightly warmer off-white than --surface, light only. */\n --surface-chat: #f9f9f9;\n --surface-hover: hsl(0 0% 96%);\n --surface-active: hsl(0 0% 94%);\n --surface-raised: hsl(0 0% 100%);\n --surface-raised-hover: hsl(0 0% 96%);\n --surface-raised-active: hsl(0 0% 94%);\n --surface-overlay: hsl(0 0% 100%);\n --surface-overlay-hover: hsl(0 0% 95%);\n --surface-overlay-active: hsl(0 0% 91%);\n /* ── Modal scrim ────────────────────────────────────────────────\n ONE value for every Dialog and Sheet. Was `bg-surface-inset-deep/50`\n written inline in both primitives, which in light mode is a 93% grey at\n half alpha — a wash, not a scrim, and it left surfaces that had opted into\n something darker looking like a different app. Black in both themes: what\n a scrim has to do is push the page back, and only black does that at a\n usable alpha in light. */\n --scrim: hsl(0 0% 0% / 60%);\n\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: var(--surface-overlay);\n --image-overlay-control-hover: var(--surface-overlay-hover);\n --image-overlay-border: var(--border);\n --image-overlay-foreground: hsl(var(--foreground));\n --image-overlay-muted: var(--muted-foreground);\n --image-overlay-thumb: var(--surface-inset);\n /* Transparency checkerboard, following the theme (.dark overrides below).\n The scrim is 70% black, which composites over the page rather than\n replacing it, so the lightbox backdrop lands near 30% lightness in light\n theme and near black in dark — a light checker reads against one and a\n dark checker against the other.\n\n Known tradeoff, chosen deliberately: a theme-following check sits close in\n lightness to same-polarity artwork, so a white-ink transparent logo is\n weak on the light check (and a black-ink one on the dark check). A neutral\n mid-grey avoids that but reads as theme-agnostic. If the washout ever\n matters more than the theme character, pull both pairs toward mid —\n 62/82 and 34/54 keep both polarities legible.\n\n Light is the Photoshop/Figma convention (#ccc on #fff). It matters that\n the lighter square is pure white: the chat surface is #f9f9f9 (97.6%), so\n a checker whose mean sits far below that reads as a dark patch inset into\n the page rather than as transparency. Keep the two squares ~20 lightness\n points apart in light and ~17 in dark. */\n --image-checker-a: hsl(0 0% 80%);\n --image-checker-b: hsl(0 0% 100%);\n\n /* ── Shadow elevations: (light) ───── */\n --elevation-xs: 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-sm:\n 0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-md:\n 0px 8px 12px 0px rgba(25, 25, 25, 0.027),\n 0px 2px 6px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-lg:\n 0px 20px 24px 0px rgba(25, 25, 25, 0.05),\n 0px 5px 8px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-xl:\n 0px 24px 48px 0px rgba(25, 25, 25, 0.24),\n 0px 4px 12px 0px rgba(25, 25, 25, 0.14),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --foreground: 47 13% 14%;\n --card: var(--surface-raised);\n --card-foreground: 47 13% 14%;\n --popover: oklch(1 0 0);\n --popover-foreground: 47 13% 14%;\n --primary: oklch(0.21 0.006 285.885);\n --primary-foreground: oklch(0.985 0 0);\n --secondary: oklch(0.967 0.001 286.375);\n --secondary-foreground: oklch(0.21 0.006 285.885);\n --muted: oklch(0.967 0.001 286.375);\n --muted-foreground: oklch(0.5 0.016 285.938);\n --accent: oklch(0.967 0.001 286.375);\n --accent-foreground: 47 13% 14%;\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.627 0.194 149.214);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.735 0.166 70.67);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.6 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n /* Inline code chips in rendered markdown (chat, previews): warm code accent\n on a fill one step off the bubble so the chip reads on raised surfaces. */\n --inline-code: oklch(0.55 0.17 25);\n --inline-code-bg: hsl(0 0% 95.5%);\n --border: hsl(240 100 6 / 0.05);\n --border-secondary: hsl(214 32% 96%);\n --input: oklch(0.92 0.004 286.32 / 0.75);\n --ring: oklch(0.705 0.015 286.067);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n /* Dashboard analytics series — extends the fixed chart palette. */\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n\n /* Capability tokens — what KIND of work a step or node does.\n A closed set of peer kinds needs distinguishable hues, but `chart-N` is\n reserved for data visualisation: a reader who has learnt that chart-1 is\n one series should not meet chart-1 again as the fill behind an icon. These\n start from the categorical ramp's values and are free to move without\n touching a single chart. */\n --capability-data: #5b8dee;\n --capability-run: #3dab82;\n --capability-reach: #e8883e;\n --capability-act: #9b7ef5;\n --capability-compute: #e05c78;\n --capability-flow: #3ab5cc;\n --sidebar: oklch(0.985 0 0);\n --sidebar-foreground: oklch(0.141 0.005 285.823);\n --sidebar-primary: oklch(0.21 0.006 285.885);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.967 0.001 286.375);\n --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n --sidebar-border: oklch(0.92 0.004 286.32);\n --sidebar-ring: oklch(0.705 0.015 286.067);\n}\n\n.dark {\n --background: 0 0% 2%;\n\n /* React flow canvas tokens */\n --dot-foreground: #363636;\n\n /* ── Shadow elevations (dark) ───────────────────── */\n --elevation-xs: 0 0 0 1px #383836;\n\n --elevation-sm: 0 0 0 1px #383836, 0 2px 4px 0 rgba(0, 0, 0, 0.18);\n\n --elevation-md:\n 0 0 0 1px #383836, 0 8px 16px -4px rgba(0, 0, 0, 0.36),\n 0 2px 6px -2px rgba(0, 0, 0, 0.28);\n\n --elevation-lg:\n 0 0 0 1px #383836, 0 14px 28px -6px rgba(0, 0, 0, 0.44),\n 0 2px 4px -1px rgba(0, 0, 0, 0.28);\n\n --elevation-xl:\n 0 0 0 1px #383836, 0 24px 48px 0 rgba(0, 0, 0, 0.56),\n 0 4px 12px 0 rgba(0, 0, 0, 0.4);\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0, 0%, 6%);\n --surface-inset-deep-hover: hsl(0 0% 8%);\n --surface-inset-deep-active: hsl(0 0% 11%);\n --surface-inset: hsl(0 0% 7.5%);\n --surface-inset-hover: hsl(0 0% 10%);\n --surface-inset-active: hsl(0 0% 12%);\n --surface: hsl(0 0% 9%);\n /* Dark mirrors --surface — chat backdrop is only retuned in light. */\n --surface-chat: hsl(0 0% 9%);\n --surface-hover: hsl(0 0% 14%);\n --surface-active: hsl(0 0% 17%);\n --surface-raised: hsl(0 0% 13%);\n --surface-raised-hover: hsl(0 0% 15%);\n --surface-raised-active: hsl(0 0% 17%);\n --surface-overlay: hsl(0 0% 9%);\n --surface-overlay-hover: hsl(0 0% 15%);\n --surface-overlay-active: hsl(0 0% 19%);\n --scrim: hsl(0 0% 0% / 60%);\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: hsl(0 0% 12% / 96%);\n --image-overlay-control-hover: hsl(0 0% 18% / 98%);\n --image-overlay-border: hsl(0 0% 100% / 12%);\n --image-overlay-foreground: hsl(0 0% 100%);\n --image-overlay-muted: hsl(0 0% 100% / 65%);\n --image-overlay-thumb: hsl(0 0% 0% / 35%);\n --image-checker-a: hsl(0 0% 22%);\n --image-checker-b: hsl(0 0% 39%);\n\n --foreground: 0 0% 90%;\n --card: var(--surface-raised);\n --card-foreground: hsl(0 0% 98%);\n --popover: hsl(0 0% 7%);\n --popover-foreground: hsl(0 0% 98%);\n --primary: hsl(0 0% 90%);\n --primary-foreground: hsl(0 0% 5%);\n --secondary: hsl(0 0% 12%);\n --secondary-foreground: hsl(0 0% 98%);\n --muted: hsl(0 0% 9%);\n --muted-foreground: hsl(0 0% 52%);\n --accent: hsl(0 0% 9%);\n --accent-foreground: hsl(0 0% 98%);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.723 0.191 142.542);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.815 0.152 78.2);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.7 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --inline-code: oklch(0.75 0.12 25);\n --inline-code-bg: hsl(0 0% 17.5%);\n --border: oklch(0.9296 0.007 106.53 / 0.08);\n --border-secondary: oklch(0.9296 0.007 106.53 / 0.03);\n --input: oklch(1 0 0 / 10%);\n --ring: oklch(0.552 0.016 285.938);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n\n /* Capability tokens — what KIND of work a step or node does.\n A closed set of peer kinds needs distinguishable hues, but `chart-N` is\n reserved for data visualisation: a reader who has learnt that chart-1 is\n one series should not meet chart-1 again as the fill behind an icon. These\n start from the categorical ramp's values and are free to move without\n touching a single chart. */\n --capability-data: #5b8dee;\n --capability-run: #3dab82;\n --capability-reach: #e8883e;\n --capability-act: #9b7ef5;\n --capability-compute: #e05c78;\n --capability-flow: #3ab5cc;\n --sidebar: oklch(0.21 0.006 285.885);\n --sidebar-foreground: oklch(0.985 0 0);\n --sidebar-primary: oklch(0.488 0.243 264.376);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.274 0.006 286.033);\n --sidebar-accent-foreground: oklch(0.985 0 0);\n --sidebar-border: oklch(1 0 0 / 10%);\n --sidebar-ring: oklch(0.552 0.016 285.938);\n}\n\n@layer base {\n * {\n border-color: var(--border);\n }\n body {\n background: var(--app-canvas, var(--surface));\n color: var(--foreground);\n font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);\n -webkit-font-smoothing: antialiased;\n }\n}\n\n/* ── Make it the CUSTOMER'S app, not ours ────────────────────────────────\n *\n * Everything above is a DEFAULT, not a house style. A Frontera app should\n * look like the product it belongs to, so override any token below in your\n * own stylesheet, imported after this file:\n *\n * :root {\n * --primary: #0b5fff; [your brand]\n * --radius: 0.25rem; [your shape]\n * --font-sans: \"Inter\", sans-serif;\n * --app-canvas: #f7f8fa; [page background]\n * --app-gutter: 2rem; [page padding]\n * --app-radius: 0.25rem; [card and tile corners]\n * --app-section-gap: 2rem; [vertical rhythm]\n * }\n *\n * The components read tokens, never literals, so redefining these restyles\n * the whole app without forking a single component. The app-* tokens are the\n * layout knobs the app-kit exposes; the rest are the shared semantic set.\n */\n"
33
+ "theme.css": "/* GENERATED from packages/web/src/app/globals.css — do not edit.\n * Refresh with `frontera app add theme`.\n *\n * Gives an app the same Tailwind utilities and design tokens the platform\n * uses, so copied components look native rather than unstyled. Import this\n * once from your entry (`import './theme.css'`).\n */\n@import \"tailwindcss\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n --color-background: hsl(var(--background));\n --color-foreground: hsl(var(--foreground));\n --color-dot: var(--dot-foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-inline-code: var(--inline-code);\n --color-inline-code-bg: var(--inline-code-bg);\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);\n --color-info: var(--info);\n --color-info-foreground: var(--info-foreground);\n --color-border: var(--border);\n --color-border-secondary: var(--border-secondary);\n --color-border-strong: var(--border-strong);\n --color-input: var(--input);\n --color-ring: var(--ring);\n --color-sidebar: var(--sidebar);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n --color-chart-6: var(--chart-6);\n --color-chart-7: var(--chart-7);\n --color-chart-8: var(--chart-8);\n --color-chart-9: var(--chart-9);\n --color-chart-10: var(--chart-10);\n --color-chart-11: var(--chart-11);\n --color-chart-12: var(--chart-12);\n /* Stable application identities, independent of the selected accent/theme. */\n --color-application-chat: var(--application-chat);\n --color-application-agents: var(--application-agents);\n --color-application-workflows: var(--application-workflows);\n --color-application-functions: var(--application-functions);\n --color-application-blueprint: var(--application-blueprint);\n --color-application-apps: var(--application-apps);\n --color-application-console: var(--application-console);\n --color-application-forge: var(--application-forge);\n --color-application-home: var(--application-home);\n --color-application-foreground: var(--application-foreground);\n --color-application-highlight: var(--application-highlight);\n --color-application-shade: var(--application-shade);\n /* Capability tints — UI chrome, kept out of the chart ramp on purpose. */\n --color-capability-data: var(--capability-data);\n --color-capability-run: var(--capability-run);\n --color-capability-reach: var(--capability-reach);\n --color-capability-act: var(--capability-act);\n --color-capability-compute: var(--capability-compute);\n --color-capability-flow: var(--capability-flow);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n --radius-2xl: calc(var(--radius) + 8px);\n\n /* next/font variables live on <html>. Inline so `font-mono` and preflight\n `code`/`pre` use Geist (then a real mono stack) rather than Tailwind's\n default, and never a proportional face. */\n --font-mono: var(--font-brand-mono, var(--font-geist-mono)), ui-monospace,\n SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\",\n monospace;\n\n /* Shadow elevation tokens — Attio-inspired multi-layer */\n /* --shadow-xs: var(--elevation-xs);\n --shadow-sm: var(--elevation-sm);\n --shadow-md: var(--elevation-md);\n --shadow-lg: var(--elevation-lg);\n --shadow-xl: var(--elevation-xl); */\n\n /* Surface elevation tokens */\n --color-surface-inset-deep: var(--surface-inset-deep);\n --color-surface-inset-deep-hover: var(--surface-inset-deep-hover);\n --color-surface-inset-deep-active: var(--surface-inset-deep-active);\n --color-surface-inset: var(--surface-inset);\n --color-surface-inset-hover: var(--surface-inset-hover);\n --color-surface-inset-active: var(--surface-inset-active);\n --color-surface: var(--surface);\n --color-surface-chat: var(--surface-chat);\n --color-surface-hover: var(--surface-hover);\n --color-surface-active: var(--surface-active);\n --color-surface-raised: var(--surface-raised);\n --color-surface-raised-hover: var(--surface-raised-hover);\n --color-surface-raised-active: var(--surface-raised-active);\n /* --color-surface-overlay: var(--surface-raised);\n --color-surface-overlay-hover: var(--surface-raised-hover);\n --color-surface-overlay-active: var(--surface-raised-active); */\n --color-surface-overlay: var(--surface-overlay);\n --color-surface-overlay-hover: var(--surface-overlay-hover);\n --color-surface-overlay-active: var(--surface-overlay-active);\n /* The modal scrim, shared by every Dialog and Sheet. */\n --color-scrim: var(--scrim);\n /* Quint-out. For transitions long enough (250ms+) that Tailwind's `ease-out`\n — cubic-bezier(0, 0, 0.2, 1) — reads as near-constant motion: this covers\n 58% of the distance in the first 15% of the time against ease-out's 37%,\n so the element commits immediately and then settles. Use it for a surface\n opening or expanding; keep `ease-out` for short state changes, where the\n difference isn't perceptible. */\n --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);\n\n /* Image lightbox chrome sits over arbitrary pixels while still matching the\n active light/dark theme. */\n --color-image-overlay-scrim: var(--image-overlay-scrim);\n --color-image-overlay-control: var(--image-overlay-control);\n --color-image-overlay-control-hover: var(--image-overlay-control-hover);\n --color-image-overlay-border: var(--image-overlay-border);\n --color-image-overlay-foreground: var(--image-overlay-foreground);\n --color-image-overlay-muted: var(--image-overlay-muted);\n --color-image-overlay-thumb: var(--image-overlay-thumb);\n --color-image-checker-a: var(--image-checker-a);\n --color-image-checker-b: var(--image-checker-b);\n}\n\n:root {\n --radius: 0.625rem;\n\n /* Widest a config-row chip (skill / pack / app / context) may grow before\n its label truncates. Roughly 28 characters at text-xs — long enough to\n read a name, short enough that one chip can't monopolise the row. */\n --chip-max-width: 13rem;\n\n /* Bottom padding every `/console` page ends with, so a scrolled list never\n stops flush against the panel edge and the last row clears the home\n indicator on a notched phone. ONE value at every breakpoint — \"the same\n bottom padding on every console page\" is the whole point of it, and\n `env()` resolves to 0 off a notched device.\n\n Reach for the `console-page` utility below, which already applies this.\n Use `pb-(--console-page-tail)` directly only where the container sets no\n padding shorthand of its own — a bare `pb-*` is emitted with the\n unprefixed utilities and so LOSES to a later `sm:p-*`/`md:py-*` on the\n same element. That is measured, not assumed: in the built stylesheet the\n unprefixed padding block sits around 250kB and `sm:p-4` at 402kB. */\n --console-page-tail: calc(2rem + env(safe-area-inset-bottom));\n\n --background: 0 0% 100%;\n\n /* React flow canvas tokens */\n --dot-foreground: #c9c9c9;\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0 0% 93%);\n --surface-inset-deep-hover: hsl(0 0% 89%);\n --surface-inset-deep-active: hsl(0 0% 86%);\n --surface-inset: hsl(0 0% 96%);\n --surface-inset-hover: hsl(0 0% 91%);\n --surface-inset-active: hsl(0 0% 88%);\n --surface: hsl(0 0% 98%);\n /* Chat-view backdrop — slightly warmer off-white than --surface, light only. */\n --surface-chat: #f9f9f9;\n --surface-hover: hsl(0 0% 96%);\n --surface-active: hsl(0 0% 94%);\n --surface-raised: hsl(0 0% 100%);\n --surface-raised-hover: hsl(0 0% 96%);\n --surface-raised-active: hsl(0 0% 94%);\n --surface-overlay: hsl(0 0% 100%);\n --surface-overlay-hover: hsl(0 0% 95%);\n --surface-overlay-active: hsl(0 0% 91%);\n /* ── Modal scrim ────────────────────────────────────────────────\n ONE value for every Dialog and Sheet. Was `bg-surface-inset-deep/50`\n written inline in both primitives, which in light mode is a 93% grey at\n half alpha — a wash, not a scrim, and it left surfaces that had opted into\n something darker looking like a different app. Black in both themes: what\n a scrim has to do is push the page back, and only black does that at a\n usable alpha in light. */\n --scrim: hsl(0 0% 0% / 60%);\n\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: var(--surface-overlay);\n --image-overlay-control-hover: var(--surface-overlay-hover);\n --image-overlay-border: var(--border);\n --image-overlay-foreground: hsl(var(--foreground));\n --image-overlay-muted: var(--muted-foreground);\n --image-overlay-thumb: var(--surface-inset);\n /* Transparency checkerboard, following the theme (.dark overrides below).\n The scrim is 70% black, which composites over the page rather than\n replacing it, so the lightbox backdrop lands near 30% lightness in light\n theme and near black in dark — a light checker reads against one and a\n dark checker against the other.\n\n Known tradeoff, chosen deliberately: a theme-following check sits close in\n lightness to same-polarity artwork, so a white-ink transparent logo is\n weak on the light check (and a black-ink one on the dark check). A neutral\n mid-grey avoids that but reads as theme-agnostic. If the washout ever\n matters more than the theme character, pull both pairs toward mid —\n 62/82 and 34/54 keep both polarities legible.\n\n Light is the Photoshop/Figma convention (#ccc on #fff). It matters that\n the lighter square is pure white: the chat surface is #f9f9f9 (97.6%), so\n a checker whose mean sits far below that reads as a dark patch inset into\n the page rather than as transparency. Keep the two squares ~20 lightness\n points apart in light and ~17 in dark. */\n --image-checker-a: hsl(0 0% 80%);\n --image-checker-b: hsl(0 0% 100%);\n\n /* ── Shadow elevations: (light) ───── */\n --elevation-xs: 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-sm:\n 0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-md:\n 0px 8px 12px 0px rgba(25, 25, 25, 0.027),\n 0px 2px 6px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-lg:\n 0px 20px 24px 0px rgba(25, 25, 25, 0.05),\n 0px 5px 8px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-xl:\n 0px 24px 48px 0px rgba(25, 25, 25, 0.24),\n 0px 4px 12px 0px rgba(25, 25, 25, 0.14),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --foreground: 47 13% 14%;\n --card: var(--surface-raised);\n --card-foreground: 47 13% 14%;\n --popover: oklch(1 0 0);\n --popover-foreground: 47 13% 14%;\n --primary: oklch(0.21 0.006 285.885);\n --primary-foreground: oklch(0.985 0 0);\n --secondary: oklch(0.967 0.001 286.375);\n --secondary-foreground: oklch(0.21 0.006 285.885);\n --muted: oklch(0.967 0.001 286.375);\n --muted-foreground: oklch(0.5 0.016 285.938);\n --accent: oklch(0.967 0.001 286.375);\n --accent-foreground: 47 13% 14%;\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.627 0.194 149.214);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.735 0.166 70.67);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.6 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n /* Inline code chips in rendered markdown (chat, previews): warm code accent\n on a fill one step off the bubble so the chip reads on raised surfaces. */\n --inline-code: oklch(0.55 0.17 25);\n --inline-code-bg: hsl(0 0% 95.5%);\n --border: hsl(240 100 6 / 0.05);\n --border-secondary: hsl(214 32% 96%);\n /* The edge of a top-level surface against the page ground, rather than a\n divider drawn ON a raised surface. `--border` is tuned for the latter and\n is too faint to read as an outer edge at 0.05/0.08. */\n --border-strong: hsl(240 100 6 / 0.11);\n --input: oklch(0.92 0.004 286.32 / 0.75);\n --ring: oklch(0.705 0.015 286.067);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n /* Dashboard analytics series — extends the fixed chart palette. */\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n\n /* Application glyph palette: darker accents on softly tinted light tiles. */\n --application-chat: oklch(0.55 0.15 155);\n --application-agents: oklch(0.54 0.20 292);\n --application-workflows: oklch(0.60 0.17 48);\n --application-functions: oklch(0.49 0.19 268);\n --application-blueprint: oklch(0.55 0.20 255);\n --application-apps: oklch(0.52 0.12 165);\n --application-console: oklch(0.40 0.025 260);\n --application-forge: oklch(0.53 0.19 25);\n --application-home: oklch(0.50 0.025 75);\n --application-foreground: oklch(0.99 0 0);\n --application-highlight: oklch(1 0 0);\n --application-shade: oklch(0 0 0);\n\n /* Capability tokens — what KIND of work a step or node does.\n A closed set of peer kinds needs distinguishable hues, but `chart-N` is\n reserved for data visualisation: a reader who has learnt that chart-1 is\n one series should not meet chart-1 again as the fill behind an icon. These\n start from the categorical ramp's values and are free to move without\n touching a single chart. */\n --capability-data: #5b8dee;\n --capability-run: #3dab82;\n --capability-reach: #e8883e;\n --capability-act: #9b7ef5;\n --capability-compute: #e05c78;\n --capability-flow: #3ab5cc;\n --sidebar: oklch(0.985 0 0);\n --sidebar-foreground: oklch(0.141 0.005 285.823);\n --sidebar-primary: oklch(0.21 0.006 285.885);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.967 0.001 286.375);\n --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n --sidebar-border: oklch(0.92 0.004 286.32);\n --sidebar-ring: oklch(0.705 0.015 286.067);\n}\n\n.dark {\n --background: 0 0% 2%;\n\n /* React flow canvas tokens */\n --dot-foreground: #363636;\n\n /* ── Shadow elevations (dark) ───────────────────── */\n --elevation-xs: 0 0 0 1px #383836;\n\n --elevation-sm: 0 0 0 1px #383836, 0 2px 4px 0 rgba(0, 0, 0, 0.18);\n\n --elevation-md:\n 0 0 0 1px #383836, 0 8px 16px -4px rgba(0, 0, 0, 0.36),\n 0 2px 6px -2px rgba(0, 0, 0, 0.28);\n\n --elevation-lg:\n 0 0 0 1px #383836, 0 14px 28px -6px rgba(0, 0, 0, 0.44),\n 0 2px 4px -1px rgba(0, 0, 0, 0.28);\n\n --elevation-xl:\n 0 0 0 1px #383836, 0 24px 48px 0 rgba(0, 0, 0, 0.56),\n 0 4px 12px 0 rgba(0, 0, 0, 0.4);\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0, 0%, 6%);\n --surface-inset-deep-hover: hsl(0 0% 8%);\n --surface-inset-deep-active: hsl(0 0% 11%);\n --surface-inset: hsl(0 0% 7.5%);\n --surface-inset-hover: hsl(0 0% 10%);\n --surface-inset-active: hsl(0 0% 12%);\n --surface: hsl(0 0% 9%);\n /* Dark mirrors --surface — chat backdrop is only retuned in light. */\n --surface-chat: hsl(0 0% 9%);\n --surface-hover: hsl(0 0% 14%);\n --surface-active: hsl(0 0% 17%);\n --surface-raised: hsl(0 0% 13%);\n --surface-raised-hover: hsl(0 0% 15%);\n --surface-raised-active: hsl(0 0% 17%);\n --surface-overlay: hsl(0 0% 9%);\n --surface-overlay-hover: hsl(0 0% 15%);\n --surface-overlay-active: hsl(0 0% 19%);\n --scrim: hsl(0 0% 0% / 60%);\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: hsl(0 0% 12% / 96%);\n --image-overlay-control-hover: hsl(0 0% 18% / 98%);\n --image-overlay-border: hsl(0 0% 100% / 12%);\n --image-overlay-foreground: hsl(0 0% 100%);\n --image-overlay-muted: hsl(0 0% 100% / 65%);\n --image-overlay-thumb: hsl(0 0% 0% / 35%);\n --image-checker-a: hsl(0 0% 22%);\n --image-checker-b: hsl(0 0% 39%);\n\n --foreground: 0 0% 90%;\n --card: var(--surface-raised);\n --card-foreground: hsl(0 0% 98%);\n --popover: hsl(0 0% 7%);\n --popover-foreground: hsl(0 0% 98%);\n --primary: hsl(0 0% 90%);\n --primary-foreground: hsl(0 0% 5%);\n --secondary: hsl(0 0% 12%);\n --secondary-foreground: hsl(0 0% 98%);\n --muted: hsl(0 0% 9%);\n --muted-foreground: hsl(0 0% 52%);\n --accent: hsl(0 0% 9%);\n --accent-foreground: hsl(0 0% 98%);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.723 0.191 142.542);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.815 0.152 78.2);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.7 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --inline-code: oklch(0.75 0.12 25);\n --inline-code-bg: hsl(0 0% 17.5%);\n --border: oklch(0.9296 0.007 106.53 / 0.08);\n --border-secondary: oklch(0.9296 0.007 106.53 / 0.03);\n --border-strong: oklch(0.9296 0.007 106.53 / 0.16);\n --input: oklch(1 0 0 / 10%);\n --ring: oklch(0.552 0.016 285.938);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n\n /* Same application hues, lifted for legible glyphs on dark surfaces. */\n --application-chat: oklch(0.74 0.14 155);\n --application-agents: oklch(0.72 0.16 292);\n --application-workflows: oklch(0.76 0.14 48);\n --application-functions: oklch(0.73 0.14 268);\n --application-blueprint: oklch(0.73 0.15 255);\n --application-apps: oklch(0.74 0.14 165);\n --application-console: oklch(0.73 0.025 260);\n --application-forge: oklch(0.73 0.16 25);\n --application-home: oklch(0.73 0.025 75);\n --application-foreground: oklch(0.99 0 0);\n --application-highlight: oklch(1 0 0);\n --application-shade: oklch(0 0 0);\n\n /* Capability tokens — what KIND of work a step or node does.\n A closed set of peer kinds needs distinguishable hues, but `chart-N` is\n reserved for data visualisation: a reader who has learnt that chart-1 is\n one series should not meet chart-1 again as the fill behind an icon. These\n start from the categorical ramp's values and are free to move without\n touching a single chart. */\n --capability-data: #5b8dee;\n --capability-run: #3dab82;\n --capability-reach: #e8883e;\n --capability-act: #9b7ef5;\n --capability-compute: #e05c78;\n --capability-flow: #3ab5cc;\n --sidebar: oklch(0.21 0.006 285.885);\n --sidebar-foreground: oklch(0.985 0 0);\n --sidebar-primary: oklch(0.488 0.243 264.376);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.274 0.006 286.033);\n --sidebar-accent-foreground: oklch(0.985 0 0);\n --sidebar-border: oklch(1 0 0 / 10%);\n --sidebar-ring: oklch(0.552 0.016 285.938);\n}\n\n@layer base {\n * {\n border-color: var(--border);\n }\n body {\n background: var(--app-canvas, var(--surface));\n color: var(--foreground);\n font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);\n -webkit-font-smoothing: antialiased;\n }\n}\n\n/* ── Make it the CUSTOMER'S app, not ours ────────────────────────────────\n *\n * Everything above is a DEFAULT, not a house style. A Frontera app should\n * look like the product it belongs to, so override any token below in your\n * own stylesheet, imported after this file:\n *\n * :root {\n * --primary: #0b5fff; [your brand]\n * --radius: 0.25rem; [your shape]\n * --font-sans: \"Inter\", sans-serif;\n * --app-canvas: #f7f8fa; [page background]\n * --app-gutter: 2rem; [page padding]\n * --app-radius: 0.25rem; [card and tile corners]\n * --app-section-gap: 2rem; [vertical rhythm]\n * }\n *\n * The components read tokens, never literals, so redefining these restyles\n * the whole app without forking a single component. The app-* tokens are the\n * layout knobs the app-kit exposes; the rest are the shared semantic set.\n */\n"
34
34
  }
35
35
  }