@softr/sdk 0.0.56-alpha.0 → 0.0.56-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ import * as _tanstack_react_start0 from "@tanstack/react-start";
17
17
  * requestMiddleware: [softrAuthMiddleware()],
18
18
  * }))
19
19
  */
20
- declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
20
+ declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
21
21
  user: User | null;
22
22
  permissions: Permissions;
23
23
  }>;
@@ -4,6 +4,7 @@ import { sessionConfig } from "./config.js";
4
4
  import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
5
5
  import { makePlatform } from "./platform.js";
6
6
  import { bootErrorMiddleware } from "../boot-error.js";
7
+ import { pwaMiddleware } from "../pwa.js";
7
8
  import { createMiddleware, createServerFn, getGlobalStartContext } from "@tanstack/react-start";
8
9
  import { rootRouteId, useRouteContext } from "@tanstack/react-router";
9
10
 
@@ -21,7 +22,7 @@ import { rootRouteId, useRouteContext } from "@tanstack/react-router";
21
22
  * }))
22
23
  */
23
24
  function softrAuthMiddleware() {
24
- return createMiddleware({ type: "request" }).middleware([bootErrorMiddleware()]).server(async ({ next }) => {
25
+ return createMiddleware({ type: "request" }).middleware([bootErrorMiddleware(), pwaMiddleware()]).server(async ({ next }) => {
25
26
  async function getSession() {
26
27
  const token = readSessionToken();
27
28
  const cached = readSessionData(token);
@@ -1,4 +1,4 @@
1
- import * as react0 from "react";
1
+ import * as react1 from "react";
2
2
 
3
3
  //#region src/default-error-overlay.d.ts
4
4
  type Props = {
@@ -8,6 +8,6 @@ type Props = {
8
8
  declare function DefaultErrorOverlay({
9
9
  error,
10
10
  reset
11
- }: Props): react0.JSX.Element;
11
+ }: Props): react1.JSX.Element;
12
12
  //#endregion
13
13
  export { DefaultErrorOverlay };
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedErr
9
9
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
10
10
  import { softrMcpMiddleware } from "./mcp.js";
11
11
  import { useUserTimezone } from "./timezone.js";
12
+ import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
12
13
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
13
14
  import { Audience } from "./visibility/audience.js";
14
- export { type ActionPermissions, Audience, type CommentAttachmentInput, type CommentMention, type CommentThread, type CommentView, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
15
+ export { type ActionPermissions, Audience, type CommentAttachmentInput, type CommentMention, type CommentThread, type CommentView, DefaultErrorOverlay, type FileAttachment, type FileItem, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePwaInstall, useUserTimezone };
package/dist/index.js CHANGED
@@ -2,10 +2,11 @@ import { useFileUpload } from "./core/use-file-upload.js";
2
2
  import { defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
4
4
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
5
+ import { SoftrPwaHead, usePwaInstall } from "./pwa.js";
5
6
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
6
7
  import { softrMcpMiddleware } from "./mcp.js";
7
8
  import { useUserTimezone } from "./timezone.js";
8
9
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
9
10
  import { Audience } from "./visibility/audience.js";
10
11
 
11
- export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
12
+ export { Audience, DefaultErrorOverlay, SoftrPwaHead, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePwaInstall, useUserTimezone };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatasourceAction } from "./action.js";
2
- import * as _tanstack_react_start2 from "@tanstack/react-start";
2
+ import * as _tanstack_react_start4 from "@tanstack/react-start";
3
3
 
4
4
  //#region src/mcp.d.ts
5
5
 
@@ -37,6 +37,6 @@ type SoftrMcpOptions = {
37
37
  * ],
38
38
  * }))
39
39
  */
40
- declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start2.RequestMiddlewareAfterServer<{}, undefined, undefined>;
40
+ declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start4.RequestMiddlewareAfterServer<{}, undefined, undefined>;
41
41
  //#endregion
42
42
  export { softrMcpMiddleware };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.56-alpha.0";
2
+ var version = "0.0.56-alpha.1";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/pwa.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import * as react0 from "react";
2
+ import "@tanstack/react-start";
3
+
4
+ //#region src/pwa.d.ts
5
+
6
+ /**
7
+ * The links that make the app installable: its manifest and its home screen
8
+ * icon. Render it once, in `<head>` in the root route's `RootDocument`.
9
+ *
10
+ * @example
11
+ * function RootDocument({ children }: { children: React.ReactNode }) {
12
+ * return (
13
+ * <html lang="en">
14
+ * <head>
15
+ * <HeadContent />
16
+ * <SoftrPwaHead />
17
+ * </head>
18
+ * <body>
19
+ * {children}
20
+ * <Scripts />
21
+ * </body>
22
+ * </html>
23
+ * )
24
+ * }
25
+ */
26
+ declare function SoftrPwaHead(): react0.FunctionComponentElement<react0.FragmentProps>;
27
+ type PwaPlatform = "ios" | "android" | "desktop";
28
+ type PwaInstall = {
29
+ available: false;
30
+ } | {
31
+ available: true;
32
+ /** Which steps install the app on this device. */
33
+ platform: PwaPlatform;
34
+ /** The name the home screen will show. */
35
+ name: string;
36
+ /** The icon the home screen will show. */
37
+ iconUrl: string;
38
+ };
39
+ /**
40
+ * Whether to offer installing the app, and on which platform. Render an
41
+ * install button only while this says `available`: it is unavailable while
42
+ * loading, when the builder has not turned the PWA on, when Softr has switched
43
+ * it off, and when the app already runs installed.
44
+ *
45
+ * @example
46
+ * function InstallButton() {
47
+ * const install = usePwaInstall()
48
+ * if (!install.available) return null
49
+ * return <button onClick={() => openSteps(install.platform)}>Install {install.name}</button>
50
+ * }
51
+ */
52
+ declare function usePwaInstall(): PwaInstall;
53
+ //#endregion
54
+ export { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall };
package/dist/pwa.js ADDED
@@ -0,0 +1,152 @@
1
+ import { logger } from "./logger.js";
2
+ import { fetch } from "./fetch.js";
3
+ import { sessionConfig } from "./auth/config.js";
4
+ import { platformApiUrl } from "./auth/platform.js";
5
+ import { Fragment, createElement, useEffect, useState } from "react";
6
+ import { createMiddleware } from "@tanstack/react-start";
7
+ import { z } from "zod";
8
+
9
+ //#region src/pwa.ts
10
+ const MANIFEST_PATH = "/manifest.webmanifest";
11
+ const ICON_PATH = "/apple-touch-icon.png";
12
+ const INSTALL_PATH = "/_softr/pwa.json";
13
+ const TTL_MS = 6e4;
14
+ const CACHE_CONTROL = "public, max-age=60";
15
+ const pwaSchema = z.object({
16
+ manifest: z.record(z.string(), z.unknown()),
17
+ install: z.object({
18
+ name: z.string(),
19
+ iconUrl: z.string()
20
+ })
21
+ });
22
+ let cached;
23
+ /**
24
+ * The app's PWA, or `null` when it is not installable right now. Never
25
+ * rejects: a failure reads as not installable and is not remembered, so the
26
+ * next request tries again.
27
+ */
28
+ function readPwa() {
29
+ const now = Date.now();
30
+ if (cached !== void 0 && cached.expires > now) return cached.pwa;
31
+ const entry = {
32
+ expires: now + TTL_MS,
33
+ pwa: fetchPwa().catch((error) => {
34
+ logger.warn("Could not read the PWA settings:", error);
35
+ if (cached === entry) cached = void 0;
36
+ return null;
37
+ })
38
+ };
39
+ cached = entry;
40
+ return entry.pwa;
41
+ }
42
+ async function fetchPwa() {
43
+ const { applicationId } = sessionConfig();
44
+ const response = await fetch(`${await platformApiUrl()}/v1/vibe/applications/${applicationId}/pwa`, { headers: { accept: "application/json" } });
45
+ if (response.status === 404) return null;
46
+ if (!response.ok) throw new Error(`The platform answered ${response.status}`);
47
+ return pwaSchema.parse(await response.json());
48
+ }
49
+ /**
50
+ * Serves the app's web app manifest, its home screen icon, and what an install
51
+ * button needs to know, from the app's own origin. Every other request passes
52
+ * through untouched.
53
+ *
54
+ * Answers 404, never an error page, when the app is not installable: a browser
55
+ * silently drops a manifest it cannot parse, so a 500 there would be invisible.
56
+ */
57
+ function pwaMiddleware() {
58
+ return createMiddleware({ type: "request" }).server(async ({ next, pathname }) => {
59
+ if (pathname !== MANIFEST_PATH && pathname !== ICON_PATH && pathname !== INSTALL_PATH) return next();
60
+ const pwa = await readPwa();
61
+ if (pwa === null) return new Response(null, { status: 404 });
62
+ if (pathname === MANIFEST_PATH) return new Response(JSON.stringify(pwa.manifest), { headers: {
63
+ "content-type": "application/manifest+json",
64
+ "cache-control": CACHE_CONTROL
65
+ } });
66
+ if (pathname === INSTALL_PATH) return Response.json(pwa.install, { headers: { "cache-control": CACHE_CONTROL } });
67
+ try {
68
+ const iconResponse = await fetch(pwa.install.iconUrl);
69
+ if (!iconResponse.ok) return new Response(null, { status: 404 });
70
+ return new Response(iconResponse.body, { headers: {
71
+ "content-type": "image/png",
72
+ "cache-control": CACHE_CONTROL
73
+ } });
74
+ } catch (error) {
75
+ logger.warn("Could not fetch the PWA icon:", error);
76
+ return new Response(null, { status: 404 });
77
+ }
78
+ });
79
+ }
80
+ /**
81
+ * The links that make the app installable: its manifest and its home screen
82
+ * icon. Render it once, in `<head>` in the root route's `RootDocument`.
83
+ *
84
+ * @example
85
+ * function RootDocument({ children }: { children: React.ReactNode }) {
86
+ * return (
87
+ * <html lang="en">
88
+ * <head>
89
+ * <HeadContent />
90
+ * <SoftrPwaHead />
91
+ * </head>
92
+ * <body>
93
+ * {children}
94
+ * <Scripts />
95
+ * </body>
96
+ * </html>
97
+ * )
98
+ * }
99
+ */
100
+ function SoftrPwaHead() {
101
+ return createElement(Fragment, null, createElement("link", {
102
+ rel: "manifest",
103
+ href: MANIFEST_PATH
104
+ }), createElement("link", {
105
+ rel: "apple-touch-icon",
106
+ href: ICON_PATH
107
+ }));
108
+ }
109
+ const installSchema = pwaSchema.shape.install;
110
+ /**
111
+ * Whether to offer installing the app, and on which platform. Render an
112
+ * install button only while this says `available`: it is unavailable while
113
+ * loading, when the builder has not turned the PWA on, when Softr has switched
114
+ * it off, and when the app already runs installed.
115
+ *
116
+ * @example
117
+ * function InstallButton() {
118
+ * const install = usePwaInstall()
119
+ * if (!install.available) return null
120
+ * return <button onClick={() => openSteps(install.platform)}>Install {install.name}</button>
121
+ * }
122
+ */
123
+ function usePwaInstall() {
124
+ const [install, setInstall] = useState({ available: false });
125
+ useEffect(() => {
126
+ if (window.matchMedia("(display-mode: standalone)").matches || "standalone" in navigator && navigator.standalone === true) return;
127
+ let cancelled = false;
128
+ fetch(INSTALL_PATH, { headers: { accept: "application/json" } }).then(async (response) => {
129
+ if (!response.ok) return;
130
+ const parsed = installSchema.safeParse(await response.json());
131
+ if (!parsed.success || cancelled) return;
132
+ setInstall({
133
+ available: true,
134
+ platform: detectPlatform(),
135
+ ...parsed.data
136
+ });
137
+ }).catch(() => {});
138
+ return () => {
139
+ cancelled = true;
140
+ };
141
+ }, []);
142
+ return install;
143
+ }
144
+ function detectPlatform() {
145
+ const userAgent = navigator.userAgent;
146
+ if (/iPhone|iPad|iPod/.test(userAgent) || /Macintosh/.test(userAgent) && navigator.maxTouchPoints > 1) return "ios";
147
+ if (/Android/.test(userAgent)) return "android";
148
+ return "desktop";
149
+ }
150
+
151
+ //#endregion
152
+ export { SoftrPwaHead, pwaMiddleware, usePwaInstall };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.56-alpha.0",
3
+ "version": "0.0.56-alpha.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.56-alpha.0
14
+ library_version: 0.0.56-alpha.1
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.56-alpha.0
19
+ library_version: 0.0.56-alpha.1
20
20
  ---
21
21
 
22
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.56-alpha.0
15
+ library_version: 0.0.56-alpha.1
16
16
  ---
17
17
 
18
18
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -11,14 +11,14 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.56-alpha.0
14
+ library_version: 0.0.56-alpha.1
15
15
  ---
16
16
 
17
17
  **Boundary.** `comments` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler. Importing it into a component fails the build. `useCommentComposer` lives on `@softr/sdk/client` and runs in components. `defineComments` comes from `@softr/sdk` and can be imported anywhere.
18
18
 
19
19
  # Comments on records
20
20
 
21
- Every record of a commented datasource has one thread. The SDK reads and writes it. The app owns everything you see: layout, copy, sort order, dates.
21
+ Each record of a commented datasource has one thread. The SDK reads and writes it. The app owns what you see: layout, copy, order, dates.
22
22
 
23
23
  ## Setup
24
24
 
@@ -38,10 +38,10 @@ export const orderComments = defineComments({
38
38
  })
39
39
  ```
40
40
 
41
- - `id` is a name you choose, unique across the app. `datasourceId` is the table whose records get threads.
42
- - There is no `permissions`. Who may read, post and delete is set in Softr under Settings → Comments, the same settings native comment blocks use.
43
- - Declare it once per datasource. Two declarations on the same datasource are the **same** threads, so "internal notes" next to "customer comments" on one table is not possible.
44
- - Write the object inline. Passing it through a variable hides it from the build.
41
+ - `id` is your name for it, unique in the app. `datasourceId` is the table that gets threads.
42
+ - No `permissions`. Who can read, post and delete is set in Settings → Comments, shared with native blocks.
43
+ - One declaration per datasource. Two on the same table are the **same** threads, so "internal notes" plus "customer comments" on one table isn't possible.
44
+ - Write the object inline. A variable hides it from the build.
45
45
 
46
46
  ## Read a thread
47
47
 
@@ -51,26 +51,27 @@ const getThread = createServerFn({ method: 'GET' })
51
51
  .handler(({ data }) => comments.list(orderComments, { recordId: data.recordId, offset: data.offset }))
52
52
  ```
53
53
 
54
- `comments.list` answers `null` when this visitor cannot see comments on the record: comments are off for the app, the table is not set up yet, or the visitor's group may not read them. Render nothing for `null`, not an error.
54
+ `comments.list` returns `null` when this visitor can't see comments on the record (comments are off, the table isn't set up, or their group can't read). Render nothing for `null`, not an error.
55
55
 
56
- Otherwise it answers:
56
+ Otherwise it returns:
57
57
 
58
58
  | Field | What it is |
59
59
  | --- | --- |
60
- | `items` | The comments for this page, newest first. Each is a `CommentView` |
61
- | `canPost` | Whether this visitor may post. Hide the composer when false |
62
- | `allowMentions` | Whether `@` opens a mention menu. Do not call `searchMentions` when false |
63
- | `quickReactions` | The five `{ emoji, label }` a comment can be reacted with. There are no others |
64
- | `total`, `nextOffset` | Paging. The first page is the newest comments; each further page, fetched by passing `nextOffset` back as `offset`, goes further back in time. `null` on the last page |
65
-
66
- A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeactivated }, createdAt, canDelete, canEdit, edited, source, reactions }`. `createdAt` is ISO 8601. Format it with `useUserTimezone`. Each reaction is `{ emoji, label, count, isSelected }`.
67
-
68
- - `allowAttachments` (on the thread): whether the composer may attach files.
69
- - `attachments[].type` (per attachment): the file's content type, or `null` when the platform does not know it.
70
- - `notificationsEnabled` (on the thread): whether to show the notifications switch at all. Hide it when false, even for a visitor who may post.
71
- - `canEdit` (per comment): whether this visitor may edit it. Show Edit only then.
72
- - `edited` (per comment): whether it was changed since it was posted. Show "(edited)" beside its date.
73
- - `source` (per comment): `{ text, mentions }` to fill an edit box, or `null` when `canEdit` is false. It is the body as the visitor typed it, with each mention as its own `@name` and range, the same shape the composer works with.
60
+ | `items` | This page's comments, newest first. Each is a `CommentView` |
61
+ | `canPost` | Whether the visitor can post. Hide the composer when false |
62
+ | `allowMentions` | Whether `@` opens a mention menu. Don't call `searchMentions` when false |
63
+ | `allowAttachments` | Whether the composer can attach files |
64
+ | `notificationsEnabled` | Whether to show the notifications switch at all |
65
+ | `quickReactions` | The five `{ emoji, label }` reactions. There are no others |
66
+ | `total`, `nextOffset` | Paging. Pass `nextOffset` back as `offset` to get the next, older page. `null` on the last page |
67
+
68
+ A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeactivated }, createdAt, canDelete, canEdit, edited, source, reactions }`.
69
+
70
+ - `createdAt` is ISO 8601. Format it with `useUserTimezone`.
71
+ - `attachments` are `{ url, filename, type }`. `type` is `null` when unknown.
72
+ - `edited` is true once the comment changed after posting.
73
+ - `source` is `{ text, mentions }` for the edit box, or `null` when `canEdit` is false.
74
+ - Each reaction is `{ emoji, label, count, isSelected }`.
74
75
 
75
76
  `bodyHtml` is already sanitised. Render it with `dangerouslySetInnerHTML` and nothing else:
76
77
 
@@ -78,7 +79,7 @@ A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeact
78
79
  <div className="comment-body" dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} />
79
80
  ```
80
81
 
81
- It holds `p`, `br`, `strong`, `em`, `u`, `s`, `del`, `a`, `ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `h1`–`h3` and mention `span[data-type="mention"]`. Links open in a new tab. Style those tags under the body's class, because the reset strips list bullets, link colour and spacing.
82
+ It holds `p`, `br`, `strong`, `em`, `u`, `s`, `del`, `a`, `ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `h1`–`h3` and mentions (`span[data-type="mention"]`). Links open in a new tab. Style these tags under the body's class: the reset strips bullets, link colour and spacing.
82
83
 
83
84
  ## Post a comment
84
85
 
@@ -95,43 +96,43 @@ const postComment = createServerFn({ method: 'POST' })
95
96
  .handler(({ data }) => comments.create(orderComments, data))
96
97
  ```
97
98
 
98
- Pass the composer's `text` as `body` and its `mentions` as they are. The SDK stores the mentions in the format native blocks read, and the people mentioned get an email. A mention whose `@name` was edited is posted as plain text.
99
-
100
- `attachments` are the files the visitor uploaded with the post, from `useFileUpload(orderComments).files.filter((f) => f.status === 'done')`. Send them only when the thread's `allowAttachments` is true.
99
+ Pass the composer's `text` and `mentions` as they are. Mentioned people get an email. A mention whose `@name` was edited posts as plain text.
101
100
 
102
- `useFileUpload` takes the comments declaration as it takes a mutation. No extra declaration.
101
+ `attachments` are the finished uploads: `useFileUpload(orderComments).files.filter((f) => f.status === 'done')`. `useFileUpload` takes the comments declaration like a mutation. Send files only when `allowAttachments` is true. A comment needs text or at least one file.
103
102
 
104
103
  ## The composer
105
104
 
106
105
  ```tsx
107
106
  const composer = useCommentComposer()
107
+ const { files, isUploading, upload, remove } = useFileUpload(orderComments)
108
+ const done = files.filter((f) => f.status === 'done')
108
109
 
109
110
  <textarea {...composer.textareaProps} placeholder="Write a comment" />
110
111
  {/* While composer.query is not null, list findPeople's answer for it. Picking a person calls composer.pick(person). */}
111
112
  <button
112
- disabled={composer.text.trim() === ''}
113
+ disabled={(composer.text.trim() === '' && done.length === 0) || isUploading}
113
114
  onClick={async () => {
114
- await postComment({ data: { recordId, body: composer.text, mentions: composer.mentions } })
115
+ await postComment({ data: { recordId, body: composer.text, mentions: composer.mentions, attachments: done } })
115
116
  composer.reset()
117
+ done.forEach((f) => remove(f.id))
116
118
  }}
117
119
  >
118
120
  Post
119
121
  </button>
120
122
  ```
121
123
 
122
- - Spread `textareaProps` onto the `<textarea>`. It carries the `ref`, `value`, `onChange` and `onSelect` the composer needs, so do not replace them.
123
- - `query` is what follows `@` before the caret, or `null`. It is `""` the moment `@` is typed.
124
- - `pick({ name, email })` writes `@name`, records the mention and puts the caret after it.
125
- - `reset()` empties the box. Call it after the post succeeds, never before.
124
+ - Spread `textareaProps` onto the `<textarea>` and don't override its handlers.
125
+ - `query` is the text after `@` before the caret, or `null`. It's `""` right after `@` is typed.
126
+ - `pick({ name, email })` writes `@name`, records the mention and moves the caret after it.
127
+ - `reset()` empties the box. Call it only after the post succeeds.
126
128
 
127
129
  ## Editing a comment
128
130
 
129
- Offer Edit only where the comment's `canEdit` is true.
131
+ Offer Edit only where `canEdit` is true. Mount the edit box only while editing.
130
132
 
131
- - Fill the edit box with `useCommentComposer({ initial: comment.source })`, mounted only while editing.
132
- - Seed the edit's `useFileUpload` with the comment's kept files: `useFileUpload(orderComments, { initialAttachments: comment.attachments.map((a) => ({ ...a, id: a.url })) })`. A comment's attachment has no `id` of its own, so the url stands in for one.
133
- - Pass `comment.attachments` (the files kept) plus any new uploads to `comments.update` as `attachments`.
134
- - Re-read the thread after the write. `update` answers nothing.
133
+ - Text: `useCommentComposer({ initial: comment.source })`.
134
+ - Files: `useFileUpload(orderComments, { initialAttachments: comment.attachments.map((a) => ({ ...a, id: a.url })) })`. Attachments have no `id`, so the url stands in.
135
+ - Save with `comments.update`, passing the full file list: the kept files plus new uploads. With `allowAttachments` false the server keeps the old files whatever you send.
135
136
 
136
137
  ## Mentions
137
138
 
@@ -141,7 +142,7 @@ const findPeople = createServerFn({ method: 'GET' })
141
142
  .handler(({ data }) => comments.searchMentions(orderComments, data))
142
143
  ```
143
144
 
144
- It answers `[{ name, email, avatar }]`, the people this visitor may mention whose name or email matches. Who is mentionable is set in Settings → Comments, per user group.
145
+ It returns `[{ name, email, avatar }]`: people the visitor can mention whose name or email matches. Who's mentionable is set per user group in Settings → Comments.
145
146
 
146
147
  ## Delete, update, react, subscribe
147
148
 
@@ -153,29 +154,26 @@ const subscribed = await comments.subscription(orderComments, { recordId })
153
154
  await comments.setSubscription(orderComments, { recordId, subscribed: !subscribed })
154
155
  ```
155
156
 
156
- Each runs inside its own server function, like `list`.
157
+ Each runs in its own server function, like `list`. Writes return nothing, so re-read the thread after each one.
157
158
 
158
159
  - Offer delete only where `canDelete` is true.
159
- - `update` saves an edit; see "Editing a comment" above for `attachments`.
160
- - `react` adds the visitor's reaction when `selected` is true and removes it when false. `emoji` must be one of `quickReactions`.
161
- - A subscription is whether the visitor gets an email when someone comments on this record. It needs a
162
- signed-in visitor. Posting subscribes the author, and a mention subscribes the person mentioned.
163
- - Mails go out a few minutes after a comment, never to its own author, and only while notifications are
164
- on in Settings → Comments.
165
-
166
- Re-read the thread after each write. The writes answer nothing.
160
+ - `react` adds the reaction when `selected` is true and removes it when false. `emoji` must be one of `quickReactions`.
161
+ - A subscription means the visitor gets an email about new comments on the record. It needs a signed-in visitor. Posting subscribes the author; a mention subscribes the person mentioned.
162
+ - Mails go out a few minutes after a comment, never to its author, and only while notifications are on in Settings → Comments.
167
163
 
168
164
  ## Failure modes
169
165
 
170
- **Keying the thread on the URL instead of the record.** Pass the fetched `record.id` as `recordId`, never a route param or a search param. A param can be a slug, stale, or a different record's id, and the thread then shows another record's comments.
166
+ **Keying the thread on the URL.** Pass the fetched `record.id`, never a route or search param. A param can be a slug or stale, and the thread shows another record's comments.
167
+
168
+ **Treating `null` as an error.** Render nothing. An error box shows every such visitor a broken page.
171
169
 
172
- **Treating `null` as an error.** A visitor who may not read comments gets `null`. Render nothing. An error box for it shows every such visitor a broken page.
170
+ **Rendering a body as text, or sanitising it again.** Use `bodyHtml` with `dangerouslySetInnerHTML`. As text it shows raw tags; a second sanitiser strips the mentions.
173
171
 
174
- **Rendering a body as text, or sanitising it again.** Use `bodyHtml` with `dangerouslySetInnerHTML`. As text it shows raw tags, and a second sanitiser strips the mention markup.
172
+ **Inventing reactions.** Only `quickReactions` can be stored. `react` throws for anything else.
175
173
 
176
- **Inventing reactions.** Only the five in `quickReactions` can be stored. `react` throws for any other emoji.
174
+ **Sending only new files on edit.** `update` replaces the whole list, so kept files you leave out get deleted.
177
175
 
178
- **Calling `comments.*` from a component.** It is server only. Wrap each call in a `createServerFn`.
176
+ **Calling `comments.*` from a component.** It's server only. Wrap each call in a `createServerFn`.
179
177
 
180
178
  ## References
181
179
 
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.56-alpha.0
12
+ library_version: 0.0.56-alpha.1
13
13
  ---
14
14
 
15
15
  # The current user
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.56-alpha.0
13
+ library_version: 0.0.56-alpha.1
14
14
  ---
15
15
 
16
16
  **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.56-alpha.0
13
+ library_version: 0.0.56-alpha.1
14
14
  ---
15
15
 
16
16
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.56-alpha.0
12
+ library_version: 0.0.56-alpha.1
13
13
  ---
14
14
 
15
15
  **Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: pwa
3
+ description: >-
4
+ Let visitors install the app on their home screen: SoftrPwaHead for the
5
+ manifest and icon links, usePwaInstall to decide whether to show an install
6
+ button and which platform's steps to show, the PwaInstall and PwaPlatform
7
+ types, and how to check that the app is installable. The manifest and icon
8
+ are already served; never write them.
9
+ metadata:
10
+ type: sub-skill
11
+ library: softr-sdk
12
+ library_version: 0.0.56-alpha.1
13
+ ---
14
+
15
+ # Installing the app
16
+
17
+ The builder turns installing on in Studio, under Settings → Mobile app, and sets the name, colour and icon there. The app already answers with its manifest and icon once they do. What the app adds is two things: the links that point the browser at them, and a button that tells visitors how to install.
18
+
19
+ > **CRITICAL**: Never write a web app manifest, icon files, a service worker, or `manifest` / `apple-touch-icon` links of your own, in `public/` or in `head()`. The app already serves them from Studio's settings, and a file of your own shadows those settings.
20
+ > **CRITICAL**: Render an install button only while `usePwaInstall()` returns `available: true`. Never show it unconditionally, never cache the answer, never replace the check with your own. Softr can switch installing off for an app, and this check is the only way that reaches the button.
21
+
22
+ ## Setup
23
+
24
+ ```ts
25
+ import { SoftrPwaHead, usePwaInstall } from '@softr/sdk'
26
+ import type { PwaInstall, PwaPlatform } from '@softr/sdk'
27
+ ```
28
+
29
+ ```ts
30
+ type PwaPlatform = 'ios' | 'android' | 'desktop'
31
+
32
+ type PwaInstall =
33
+ | { available: false }
34
+ | {
35
+ available: true
36
+ platform: PwaPlatform // which steps install the app on this device
37
+ name: string // what the home screen will show
38
+ iconUrl: string // the icon the home screen will show
39
+ }
40
+ ```
41
+
42
+ ## Is the app installable?
43
+
44
+ The builder switches installing on in Studio, and `usePwaInstall()` reads that switch at runtime. To check it before writing any code, ask the running app for `/_softr/pwa.json`: `200` means installable, `404` means installing is off or there is no icon yet. Do not build an install button against a `404`: installing has to be turned on first. A change in Studio can take up to two minutes to reach the app.
45
+
46
+ ## Core Patterns
47
+
48
+ ### The links: `<SoftrPwaHead />`
49
+
50
+ Render it once, in `<head>` next to `<HeadContent />`, in `RootDocument` in `src/routes/__root.tsx`.
51
+
52
+ ```tsx
53
+ function RootDocument({ children }: { children: React.ReactNode }) {
54
+ return (
55
+ <html lang="en">
56
+ <head>
57
+ <HeadContent />
58
+ <SoftrPwaHead />
59
+ </head>
60
+ <body>
61
+ {children}
62
+ <Scripts />
63
+ </body>
64
+ </html>
65
+ )
66
+ }
67
+ ```
68
+
69
+ ### Whether to show a button: `usePwaInstall()`
70
+
71
+ It fetches after mount, so the first render, the server render included, always says `available: false`. It stays unavailable when the app is not installable, when the request fails, and when the app already runs installed from the home screen. Inside the Studio preview it says available, so the builder can see the button; installing itself only works on the published app.
72
+
73
+ There is no `install()` to call. No browser opens its install dialog for this app on request, so the button opens steps for `platform` instead: Share, then Add to Home Screen on iOS; the browser menu's Install app on Android and desktop. `name` and `iconUrl` tell the visitor what they are adding.
74
+
75
+ ## References
76
+
77
+ - [SKILL.md](../SKILL.md): the entrypoints and which one each export lives on
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.56-alpha.0
14
+ library_version: 0.0.56-alpha.1
15
15
  ---
16
16
 
17
17
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.56-alpha.0
16
+ library_version: 0.0.56-alpha.1
17
17
  ---
18
18
 
19
19
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.56-alpha.0
12
+ library_version: 0.0.56-alpha.1
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.56-alpha.0
16
+ library_version: 0.0.56-alpha.1
17
17
  ---
18
18
 
19
19
  **Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.56-alpha.0
12
+ library_version: 0.0.56-alpha.1
13
13
  ---
14
14
 
15
15
  **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.