create-feltkit 0.3.2 → 0.3.3
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/README.md +7 -0
- package/package.json +1 -1
- package/scaffold.js +3 -3
- package/template/README.md +102 -35
- package/template/feltkit/_generated/api.ts +2 -1
- package/template/feltkit/app.ts +12 -8
- package/template/feltkit/inbox.ts +21 -0
- package/template/feltkit/projects.ts +19 -0
- package/template/feltkit/tasks.ts +11 -1
- package/template/index.html +4 -1
- package/template/package.json +2 -2
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon.png +0 -0
- package/template/public/feltkit-mark.png +0 -0
- package/template/public/theme.js +7 -0
- package/template/src/App.tsx +31 -7
- package/template/src/styles.css +31 -31
- package/template/src/theme.tsx +58 -0
- package/template/tests/tasks.test.ts +42 -0
- package/template-managed/package.json +3 -3
- package/template/public/favicon.svg +0 -1
package/README.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rkendel1/feltkit/main/docs/assets/feltkit-logo-dark.png">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/rkendel1/feltkit/main/docs/assets/feltkit-logo.png" alt="FeltKit" width="160">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
1
8
|
# create-feltkit
|
|
2
9
|
|
|
3
10
|
```bash
|
package/package.json
CHANGED
package/scaffold.js
CHANGED
|
@@ -86,9 +86,9 @@ export function scaffold({ target, name, sdkSpec, template = "tasks" }) {
|
|
|
86
86
|
readmePath,
|
|
87
87
|
readme
|
|
88
88
|
.replace(`# ${starterName}\n`, `# ${appName}\n`)
|
|
89
|
-
.replaceAll(`docker build -t ${starterName}
|
|
90
|
-
.replaceAll(`-v ${starterName}-data:/data`, `-v ${appName}-data:/data`)
|
|
91
|
-
.replaceAll(`--
|
|
89
|
+
.replaceAll(`docker build -t ${starterName} `, `docker build -t ${appName} `)
|
|
90
|
+
.replaceAll(`-v ${starterName}-data:/data ${starterName}`, `-v ${appName}-data:/data ${appName}`)
|
|
91
|
+
.replaceAll(`--name ${starterName} --namespace ${starterName} `, `--name ${appName} --namespace ${appName} `),
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
}
|
package/template/README.md
CHANGED
|
@@ -39,7 +39,7 @@ start over.
|
|
|
39
39
|
| `feltkit/app.ts` | The application: its name, roles and permissions, dev users, enabled services, collections |
|
|
40
40
|
| `feltkit/projects.ts`, `feltkit/tasks.ts` | Functions: queries (read) and mutations (write). A function is `<module>:<export>`, e.g. `tasks:create` |
|
|
41
41
|
| `feltkit/_generated/api.ts` | Typed references to every function. Regenerated by `feltkit dev` and `feltkit codegen`; don't edit it |
|
|
42
|
-
| `src/` | The React frontend |
|
|
42
|
+
| `src/` | The React frontend. Colors are tokens in `src/styles.css`, each `light-dark(light, dark)`; the top bar's switch (`src/theme.tsx`) picks light, dark, or the system setting, remembered per browser and applied before first paint by `public/theme.js` |
|
|
43
43
|
| `tests/` | Tests, each against a fresh FeltDB instance |
|
|
44
44
|
| `.feltkit/` | Local state, keys and builds, per environment. Never commit it |
|
|
45
45
|
|
|
@@ -92,19 +92,44 @@ operation there is authorized as the dev user you choose with **Act as**. From a
|
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
94
|
await ctx.services.files.upload({ name: "notes.txt", content: text });
|
|
95
|
-
await ctx.services.notifications.send({ recipient: "grace", type: "task.
|
|
95
|
+
await ctx.services.notifications.send({ recipient: "grace", type: "task.completed", title: "Done" });
|
|
96
|
+
await ctx.services.webhooks?.emit("task.created", { id }); // mutations only
|
|
97
|
+
await ctx.services.jobs?.enqueue("projects.clear-completed", {}); // mutations only
|
|
96
98
|
const greeting = await ctx.services.configuration.get("GREETING");
|
|
97
|
-
await ctx.services.jobs?.enqueue("report.build", { week }); // mutations only
|
|
98
99
|
```
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
The starter already uses them: creating and completing a task emits `task.created` and
|
|
102
|
+
`task.completed` to any registered webhook endpoint; completing someone else's task notifies
|
|
103
|
+
its creator; and the job `projects.clear-completed` runs `projects:clearCompleted`.
|
|
104
|
+
|
|
105
|
+
Declare what your app sends in `feltkit/app.ts`, so Studio's forms offer it in dropdowns:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
services: {
|
|
109
|
+
notifications: { types: ["task.completed"] },
|
|
110
|
+
webhooks: { events: ["task.created", "task.completed"] },
|
|
111
|
+
jobs: { handlers: { "projects.clear-completed": "projects:clearCompleted" } },
|
|
112
|
+
// …
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A job type runs the mutation you name, as the user who queued it, with retries. To turn a
|
|
117
|
+
service off, remove it from `services`.
|
|
118
|
+
|
|
119
|
+
## Connecting an API
|
|
120
|
+
|
|
121
|
+
**Studio → Integrations → Connect an API** reads an OpenAPI document (JSON) or lets you
|
|
122
|
+
describe the operations yourself, then writes `feltkit/<name>.ts` and grants `<name>.*` to the
|
|
123
|
+
roles you choose. Each operation becomes a capability your functions, the agent and other apps
|
|
124
|
+
can call (`<name>.<operation>`), with its permission, and every call recorded. The API key is
|
|
125
|
+
stored in FeltKit's custody; in production set `<NAME>_URL` and `FELTKIT_CREDENTIAL_<NAME>`.
|
|
126
|
+
Then narrow each operation's `output` schema in that file to what the API returns.
|
|
103
127
|
|
|
104
128
|
## Choosing a model for the agent
|
|
105
129
|
|
|
106
|
-
Open **Studio → Models**, pick a provider and model, type its API key, **Save
|
|
107
|
-
**Test**.
|
|
130
|
+
Open **Studio → Models**, pick a provider and model, type its API key, then **Save and use**
|
|
131
|
+
and **Test**. **Save for later** keeps more models (other providers too) under **Saved
|
|
132
|
+
models**, each with its own key; **Use** switches the agent to one. Or from a terminal:
|
|
108
133
|
|
|
109
134
|
```bash
|
|
110
135
|
npx feltkit model set --provider anthropic --model <model> --api-key-stdin
|
|
@@ -131,8 +156,18 @@ await t.close();
|
|
|
131
156
|
|
|
132
157
|
## Deploying
|
|
133
158
|
|
|
134
|
-
FeltKit doesn't host your app
|
|
135
|
-
|
|
159
|
+
FeltKit doesn't host your app; it builds what you run, for the way you want to run it:
|
|
160
|
+
|
|
161
|
+
| Target | What runs | Data |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `container` (default) | Everything in one container | FeltDB state on a volume |
|
|
164
|
+
| `hosted` | A separate app container | Managed FeltDB, hosted for you, from the artifact's contract (it declares AppPort Services' collections too) |
|
|
165
|
+
| `self-hosted` | A separate app container | A FeltDB server you operate (`feltdb-server`), in this app's namespace |
|
|
166
|
+
|
|
167
|
+
Each target's `DEPLOY.md` spells out its contract: what to set, where state lives and how to
|
|
168
|
+
recover it, what stops startup, and how to upgrade and roll back. A target never changes where
|
|
169
|
+
state is stored: the container target refuses `FELTDB_URL`, and the others refuse to start
|
|
170
|
+
without their FeltDB settings.
|
|
136
171
|
|
|
137
172
|
1. **Check what production needs.**
|
|
138
173
|
|
|
@@ -140,50 +175,82 @@ container.
|
|
|
140
175
|
npx feltkit readiness --env production
|
|
141
176
|
```
|
|
142
177
|
|
|
143
|
-
|
|
144
|
-
integrations), with the fix for each. It exits with 1 while anything fails.
|
|
145
|
-
|
|
146
|
-
2. **Build and verify the artifact.**
|
|
178
|
+
2. **Build and verify.** It asks which target (or pass `--target container`, `hosted` or `self-hosted`):
|
|
147
179
|
|
|
148
180
|
```bash
|
|
149
181
|
npm run deploy
|
|
150
182
|
```
|
|
151
183
|
|
|
152
|
-
This builds the backend and
|
|
153
|
-
|
|
154
|
-
|
|
184
|
+
This builds the backend and frontend, boots the production build against throwaway state
|
|
185
|
+
to prove it serves, and writes `.feltkit/deploy/` with a `Dockerfile`, `docker-compose.yml`,
|
|
186
|
+
`fly.toml`, `.env.example` (exactly the variables it needs) and `DEPLOY.md`. Each deploy
|
|
187
|
+
replaces it, keeping production state a previous run kept inside it. It prints the commands
|
|
188
|
+
to run next.
|
|
189
|
+
|
|
190
|
+
3. **Run it.** Everything in one container:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
docker compose -f .feltkit/deploy/docker-compose.yml up --build
|
|
194
|
+
```
|
|
155
195
|
|
|
156
|
-
|
|
196
|
+
Or with Node, from the artifact (data in its `.feltkit/production`):
|
|
157
197
|
|
|
158
198
|
```bash
|
|
159
|
-
|
|
160
|
-
PORT=3000 npm start
|
|
199
|
+
cd .feltkit/deploy
|
|
161
200
|
```
|
|
162
201
|
|
|
163
|
-
|
|
202
|
+
```bash
|
|
203
|
+
npm ci --omit=dev
|
|
204
|
+
```
|
|
164
205
|
|
|
165
206
|
```bash
|
|
166
|
-
|
|
207
|
+
npm start
|
|
167
208
|
```
|
|
168
209
|
|
|
169
|
-
|
|
210
|
+
With Managed FeltDB (`--target hosted`), create the app and its key from the artifact's
|
|
211
|
+
contract (`npx feltdb init-managed --contract .feltkit/deploy/feltdb.contract.json
|
|
212
|
+
--runtime-scopes minimal`), put `FELTDB_URL`, `FELTDB_APPLICATION_ID` and `FELTDB_TOKEN` in
|
|
213
|
+
`.feltkit/deploy/.env`, then run the same `docker compose` command. With your own FeltDB
|
|
214
|
+
server (`--target self-hosted`), create a key for the app's namespace (`feltdb-server keys
|
|
215
|
+
create --keys <keys.json> --name tasks --namespace tasks --scope state:read,state:write`) and
|
|
216
|
+
put `FELTDB_URL` and `FELTDB_TOKEN` in `.env`. Either container refuses to start without its
|
|
217
|
+
settings, and checks the database answers, so it never keeps data on its own disk. For
|
|
218
|
+
Fly.io, plain Docker, or other hosts, follow `.feltkit/deploy/DEPLOY.md`.
|
|
219
|
+
|
|
220
|
+
Open <http://localhost:3000>.
|
|
221
|
+
|
|
222
|
+
4. **Sign in.** Production has no development sign-in (Ada and Grace are for development).
|
|
223
|
+
Until you connect your own login, create a session token, *with the app stopped* (only one
|
|
224
|
+
process may open the state), and paste it into the app's sign-in screen:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npx feltkit session create --env production --type user --id you --role member
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Run it in `.feltkit/deploy` for the Node run (or anywhere with the target's `FELTDB_*`
|
|
231
|
+
variables set: then the app may keep running, since its sessions are in FeltDB). For the one-container Docker run, use a one-off container on the same
|
|
232
|
+
volume while the app's container is stopped:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
docker run --rm -v tasks-data:/data tasks node node_modules/@feltdb/feltkit/bin/feltkit.js session create --env production --type user --id you --role member
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
For real users, add `auth.authenticate` to `feltkit({ ... })` to verify requests with your
|
|
239
|
+
identity provider.
|
|
240
|
+
|
|
241
|
+
**Production settings** (from your secret manager, never in the repository). With one
|
|
242
|
+
container, none is required:
|
|
170
243
|
|
|
171
244
|
| Variable | What it's for |
|
|
172
245
|
|---|---|
|
|
173
|
-
| `
|
|
174
|
-
| `FELTDB_URL`, `FELTDB_TOKEN` |
|
|
175
|
-
| `
|
|
246
|
+
| `FELTDB_URL`, `FELTDB_APPLICATION_ID`, `FELTDB_TOKEN` | Managed FeltDB (`--target hosted`) |
|
|
247
|
+
| `FELTDB_URL`, `FELTDB_TOKEN` | Your FeltDB server (`--target self-hosted`) |
|
|
248
|
+
| `FELTKIT_WEBHOOK_PRIVATE_NETWORKS` | `1` lets webhooks reach receivers on private networks (refused by default outside development) |
|
|
249
|
+
| `FELTKIT_API_KEY_VERIFIER_KEY` | Hosted and self-hosted: 32+ random bytes (`openssl rand -base64 32`). In one container it is generated once and kept beside the state on the volume |
|
|
176
250
|
| `FELTKIT_CREDENTIAL_<ID>` | Credential values, e.g. `FELTKIT_CREDENTIAL_ANTHROPIC` for `credential-ref:anthropic`. Keys you entered in Studio are development-only and are not deployed |
|
|
251
|
+
| `FELTKIT_STATE_DIR` | Where local state lives (the container image uses `/data/state`, on the volume) |
|
|
177
252
|
| `FELTKIT_OPERATOR_KEY` | Optional: signs operator sign-ins for production Studio |
|
|
178
|
-
| `PORT` | The port to listen on |
|
|
179
|
-
|
|
180
|
-
**Users in production.** Development sign-in (Ada, Grace) is off in production. Verify
|
|
181
|
-
requests with your identity provider by adding `auth.authenticate` to `feltkit({ ... })`,
|
|
182
|
-
or issue sessions to known principals:
|
|
183
|
-
|
|
184
|
-
```bash
|
|
185
|
-
npx feltkit session create --env production --id ops-bot --type agent --role agent
|
|
186
|
-
```
|
|
253
|
+
| `PORT` | The port to listen on (default 3000) |
|
|
187
254
|
|
|
188
255
|
**Production Studio.** Studio isn't served in production unless `FELTKIT_DASHBOARD=1`. Then
|
|
189
256
|
operators sign in with a one-time grant you create on the host
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
import { createApi } from "@feltdb/feltkit/client";
|
|
4
4
|
import type * as app_module from "../app.js";
|
|
5
|
+
import type * as inbox_module from "../inbox.js";
|
|
5
6
|
import type * as projects_module from "../projects.js";
|
|
6
7
|
import type * as tasks_module from "../tasks.js";
|
|
7
8
|
|
|
8
9
|
/** Typed references to every query and mutation. Type-only imports: no backend code reaches the browser. */
|
|
9
|
-
export const api = createApi<{ "app": typeof app_module; "projects": typeof projects_module; "tasks": typeof tasks_module }>();
|
|
10
|
+
export const api = createApi<{ "app": typeof app_module; "inbox": typeof inbox_module; "projects": typeof projects_module; "tasks": typeof tasks_module }>();
|
package/template/feltkit/app.ts
CHANGED
|
@@ -13,15 +13,18 @@ export const app = feltkit({
|
|
|
13
13
|
roles: {
|
|
14
14
|
// Members use the app and Studio's agent (feltkit.agent.*: create, approve, and follow agent tasks).
|
|
15
15
|
// notifications.*: a principal reads, marks, and dismisses only its own notifications.
|
|
16
|
-
|
|
16
|
+
// webhooks.emit + webhooks.deliver: their changes notify registered endpoints (task.created,
|
|
17
|
+
// task.completed); each delivery runs as the person whose change emitted it.
|
|
18
|
+
member: ["projects.*", "tasks.*", "files.*", "notifications.*", "webhooks.emit", "webhooks.deliver", "feltkit.agent.*"],
|
|
17
19
|
// Viewers read. They can see agent tasks but not start or approve them.
|
|
18
20
|
viewer: ["projects.read", "tasks.read", "files.read", "notifications.read", "feltkit.agent.read"],
|
|
19
21
|
// Agents can read, complete tasks, and leave notes. They cannot create or delete.
|
|
20
|
-
agent: ["projects.read", "tasks.read", "tasks.complete", "tasks.note", "files.read", "files.write"],
|
|
22
|
+
agent: ["projects.read", "tasks.read", "tasks.complete", "tasks.note", "files.read", "files.write", "notifications.send", "webhooks.emit", "webhooks.deliver"],
|
|
21
23
|
// Admins configure the app in Studio → Services: the model (intelligence.*), stored
|
|
22
24
|
// credentials (credential.*), configuration, API keys, webhooks, jobs, and schedules.
|
|
23
25
|
// Credentials are stored by reference; the value never enters FeltDB.
|
|
24
|
-
|
|
26
|
+
// feltkit.operations.*: in production, admins are Studio's operators (npx feltkit operator grant).
|
|
27
|
+
admin: ["intelligence.*", "credential.*", "configuration.*", "apikeys.*", "webhooks.*", "jobs.*", "schedules.*", "feltkit.operations.*"],
|
|
25
28
|
},
|
|
26
29
|
// Development-only identities. Sign in as them from the app or the dashboard.
|
|
27
30
|
dev: {
|
|
@@ -34,14 +37,15 @@ export const app = feltkit({
|
|
|
34
37
|
// Services. Intelligence is the model the agent decides with (Studio → Models).
|
|
35
38
|
services: {
|
|
36
39
|
files: true,
|
|
37
|
-
|
|
40
|
+
// The types this app sends; Studio offers them when you send one by hand.
|
|
41
|
+
notifications: { types: ["task.completed"] },
|
|
38
42
|
configuration: true,
|
|
39
43
|
// A request authenticated by an API key holds these roles.
|
|
40
44
|
apiKeys: { roles: ["member"] },
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
jobs:
|
|
45
|
+
// The events this app emits (feltkit/tasks.ts); Studio offers them when you register an endpoint.
|
|
46
|
+
webhooks: { events: ["task.created", "task.completed"] },
|
|
47
|
+
// Job type → the mutation that runs it, as the principal that queued it, with retries.
|
|
48
|
+
jobs: { handlers: { "projects.clear-completed": "projects:clearCompleted" } },
|
|
45
49
|
schedules: true,
|
|
46
50
|
intelligence: true,
|
|
47
51
|
},
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { s } from "@feltdb/feltkit";
|
|
2
|
+
import { app } from "./app.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The signed-in person's notifications (AppPort Services Notifications, e.g. "task.completed"
|
|
6
|
+
* from tasks:setDone). Each principal reads and marks only its own; the service enforces it.
|
|
7
|
+
*/
|
|
8
|
+
export const list = app.query({
|
|
9
|
+
access: "notifications.read",
|
|
10
|
+
args: { unread: s.optional(s.boolean()) },
|
|
11
|
+
handler: async (ctx, { unread }) =>
|
|
12
|
+
(await ctx.services.notifications.list(unread ? { unread: true } : {})).map((n) => ({ id: n.id, type: n.type, title: n.title, data: n.data ?? null, createdAt: n.createdAt, read: Boolean(n.readAt) })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const markRead = app.mutation({
|
|
16
|
+
access: "notifications.update",
|
|
17
|
+
args: { id: s.string() },
|
|
18
|
+
handler: async (ctx, { id }) => {
|
|
19
|
+
await ctx.services.notifications.markRead(id);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -45,6 +45,25 @@ export const rename = app.mutation({
|
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Removes completed tasks (and their notes) from one project, or from every project. Runs as
|
|
50
|
+
* the background job "projects.clear-completed" (services.jobs.handlers in app.ts), queued
|
|
51
|
+
* from Studio → Services → Jobs or on a schedule, as the person who queued it.
|
|
52
|
+
*/
|
|
53
|
+
export const clearCompleted = app.mutation({
|
|
54
|
+
access: "projects.write",
|
|
55
|
+
args: { projectId: s.optional(s.string()) },
|
|
56
|
+
returns: s.object({ removed: s.integer() }),
|
|
57
|
+
handler: async (ctx, { projectId }) => {
|
|
58
|
+
const done = await ctx.db.find(tasks, projectId ? { projectId, done: true } : { done: true });
|
|
59
|
+
for (const task of done) {
|
|
60
|
+
for (const file of await ctx.services.files.list({ metadata: { taskId: task.id } })) await ctx.services.files.remove(file.id);
|
|
61
|
+
await ctx.db.delete(tasks, task.id);
|
|
62
|
+
}
|
|
63
|
+
return { removed: done.length };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
48
67
|
/** Deletes a project and all of its tasks in one atomic commit. */
|
|
49
68
|
export const remove = app.mutation({
|
|
50
69
|
access: "projects.write",
|
|
@@ -33,7 +33,7 @@ export const create = app.mutation({
|
|
|
33
33
|
capability: { name: "tasks.create", description: "Add a task to a project." },
|
|
34
34
|
handler: async (ctx, { projectId, title, details }) => {
|
|
35
35
|
if (!(await ctx.db.get(projects, projectId))) fail("NOT_FOUND", "That project does not exist.");
|
|
36
|
-
|
|
36
|
+
const id = await ctx.db.insert(tasks, {
|
|
37
37
|
projectId,
|
|
38
38
|
title: title.trim(),
|
|
39
39
|
...(details?.trim() ? { details: details.trim() } : {}),
|
|
@@ -41,6 +41,9 @@ export const create = app.mutation({
|
|
|
41
41
|
createdAt: Date.now(),
|
|
42
42
|
createdBy: ctx.auth.requirePrincipal().id,
|
|
43
43
|
});
|
|
44
|
+
// Delivered, signed, to every webhook endpoint registered for "task.created" (Studio → Services → Webhooks).
|
|
45
|
+
await ctx.services.webhooks?.emit("task.created", { id, projectId, title: title.trim() });
|
|
46
|
+
return id;
|
|
44
47
|
},
|
|
45
48
|
});
|
|
46
49
|
|
|
@@ -63,6 +66,13 @@ export const setDone = app.mutation({
|
|
|
63
66
|
handler: async (ctx, { id, done }) => {
|
|
64
67
|
const who = ctx.auth.requirePrincipal().id;
|
|
65
68
|
const task = await ctx.db.patch(tasks, id, done ? { done: true, completedAt: Date.now(), completedBy: who } : { done: false, completedAt: undefined, completedBy: undefined });
|
|
69
|
+
if (done) {
|
|
70
|
+
await ctx.services.webhooks?.emit("task.completed", { id: task.id, projectId: task.projectId, completedBy: who });
|
|
71
|
+
// Tell whoever created the task, when someone else completes it.
|
|
72
|
+
if (task.createdBy !== who) {
|
|
73
|
+
await ctx.services.notifications.send({ recipient: task.createdBy, type: "task.completed", title: `${who} completed "${task.title}"`, data: { taskId: task.id }, idempotencyKey: `task.completed:${task.id}:${task.completedAt}` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
66
76
|
return { id: task.id, done: task.done };
|
|
67
77
|
},
|
|
68
78
|
});
|
package/template/index.html
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<meta name="color-scheme" content="light dark" />
|
|
7
|
-
<link rel="icon" href="/favicon.
|
|
7
|
+
<link rel="icon" href="/favicon.png" type="image/png" />
|
|
8
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
8
9
|
<title>Tasks · FeltKit</title>
|
|
10
|
+
<!-- The saved light/dark choice, applied before first paint (src/theme.tsx). -->
|
|
11
|
+
<script vite-ignore src="/theme.js"></script>
|
|
9
12
|
</head>
|
|
10
13
|
<body>
|
|
11
14
|
<div id="root"></div>
|
package/template/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"typecheck": "tsc --noEmit"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@feltdb/feltkit": "0.3.
|
|
16
|
+
"@feltdb/feltkit": "0.3.3",
|
|
17
17
|
"react": "19.2.0",
|
|
18
18
|
"react-dom": "19.2.0"
|
|
19
19
|
},
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"overrides": {
|
|
29
29
|
"@appport/services": {
|
|
30
|
-
"@feltdb/core": "0.16.
|
|
30
|
+
"@feltdb/core": "0.16.1"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Applies the saved theme before the app renders, so there is no light flash in dark mode.
|
|
2
|
+
// Same key and values as src/theme.tsx. A file, not an inline script, so it also works under
|
|
3
|
+
// a Content-Security-Policy that allows only same-origin scripts.
|
|
4
|
+
try {
|
|
5
|
+
var theme = localStorage.getItem("app:theme");
|
|
6
|
+
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
|
7
|
+
} catch (e) {}
|
package/template/src/App.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
|
2
|
-
import { useConnectionState, useMutation, useQueryState, useSession, type FeltKitClientError } from "@feltdb/feltkit/react";
|
|
2
|
+
import { useConnectionState, useFeltKit, useMutation, useQueryState, useSession, type FeltKitClientError } from "@feltdb/feltkit/react";
|
|
3
3
|
import { api } from "../feltkit/_generated/api";
|
|
4
|
+
import { ThemeToggle } from "./theme";
|
|
4
5
|
|
|
5
6
|
type Notice = { tone: "error" | "info"; text: string } | null;
|
|
6
7
|
|
|
@@ -47,11 +48,7 @@ export function App() {
|
|
|
47
48
|
<div className="shell">
|
|
48
49
|
<header className="topbar">
|
|
49
50
|
<div className="brand">
|
|
50
|
-
<
|
|
51
|
-
<svg viewBox="0 0 32 32" width="22" height="22">
|
|
52
|
-
<path d="M11 7c-4 5-4 13 0 18M21 7c4 5 4 13 0 18" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" />
|
|
53
|
-
</svg>
|
|
54
|
-
</span>
|
|
51
|
+
<img className="mark" src="/feltkit-mark.png" width="30" height="30" alt="" />
|
|
55
52
|
<span className="brand-name">Tasks</span>
|
|
56
53
|
{session && <span className={`env ${session.environment.production ? "env-prod" : ""}`}>{session.environment.name}</span>}
|
|
57
54
|
</div>
|
|
@@ -76,6 +73,7 @@ export function App() {
|
|
|
76
73
|
</select>
|
|
77
74
|
</label>
|
|
78
75
|
)}
|
|
76
|
+
<ThemeToggle />
|
|
79
77
|
{session?.dashboard && (
|
|
80
78
|
<a className="dashboard-link" href={session.dashboard} target="_blank" rel="noreferrer">
|
|
81
79
|
Dashboard ↗
|
|
@@ -125,13 +123,39 @@ function SignedOut({ devUsers, onSignIn }: { devUsers: Array<{ id: string; name:
|
|
|
125
123
|
</div>
|
|
126
124
|
</>
|
|
127
125
|
) : (
|
|
128
|
-
<
|
|
126
|
+
<TokenSignIn />
|
|
129
127
|
)}
|
|
130
128
|
</div>
|
|
131
129
|
</div>
|
|
132
130
|
);
|
|
133
131
|
}
|
|
134
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Production has no development sign-in. Until auth.authenticate in feltkit/app.ts verifies your
|
|
135
|
+
* users with your identity provider, sign in with a session token created on the server.
|
|
136
|
+
*/
|
|
137
|
+
function TokenSignIn() {
|
|
138
|
+
const client = useFeltKit();
|
|
139
|
+
const [token, setToken] = useState("");
|
|
140
|
+
const submit = (e: FormEvent) => {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
if (token.trim()) client.setToken(token.trim());
|
|
143
|
+
};
|
|
144
|
+
return (
|
|
145
|
+
<form className="stack" onSubmit={submit}>
|
|
146
|
+
<p className="muted">
|
|
147
|
+
Sign in with a session token. Create one on the server (with the app stopped):
|
|
148
|
+
</p>
|
|
149
|
+
<code className="block">npx feltkit session create --env production --type user --id you --role member</code>
|
|
150
|
+
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="Session token (fks_…)" aria-label="Session token" autoComplete="off" />
|
|
151
|
+
<button type="submit" disabled={!token.trim()}>
|
|
152
|
+
Sign in
|
|
153
|
+
</button>
|
|
154
|
+
<p className="muted small-text">To sign people in with your own login, add auth.authenticate in feltkit/app.ts.</p>
|
|
155
|
+
</form>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
135
159
|
function Workspace({ canWrite, canComplete, role, onError }: { canWrite: boolean; canComplete: boolean; role: string; onError: (e: unknown) => void }) {
|
|
136
160
|
const projects = useQueryState(api.projects.list);
|
|
137
161
|
const [selected, setSelected] = useState<string | null>(null);
|
package/template/src/styles.css
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
2
|
+
/* Light or dark: the system's setting unless the top bar's theme switch chose one
|
|
3
|
+
(data-theme on <html>). Each color is light-dark(light, dark). */
|
|
4
|
+
color-scheme: light dark;
|
|
5
|
+
--bg: light-dark(#f7f7f5, #111312);
|
|
6
|
+
--surface: light-dark(#ffffff, #181b1a);
|
|
7
|
+
--ink: light-dark(#1b1d1c, #eceeed);
|
|
8
|
+
--muted: light-dark(#6b706d, #9aa19d);
|
|
9
|
+
--line: light-dark(#e4e5e1, #2a2f2c);
|
|
10
|
+
--accent: light-dark(#166534, #4ade80);
|
|
11
|
+
--accent-ink: light-dark(#ffffff, #052e16);
|
|
12
|
+
--accent-soft: light-dark(#e8f3ec, #15301f);
|
|
13
|
+
--warn-soft: light-dark(#fef3c7, #2b2210);
|
|
14
|
+
--danger-soft: light-dark(#fef3f2, #2a1513);
|
|
15
|
+
--danger-line: light-dark(#fecdca, #55201b);
|
|
16
|
+
--danger: light-dark(#b42318, #f97066);
|
|
17
|
+
--warn: light-dark(#92400e, #fbbf24);
|
|
12
18
|
--radius: 10px;
|
|
13
|
-
--shadow: 0 1px 2px rgba(16, 24, 20, 0.06), 0 2px 8px rgba(16, 24, 20, 0.04);
|
|
19
|
+
--shadow: 0 1px 2px light-dark(rgba(16, 24, 20, 0.06), transparent), 0 2px 8px light-dark(rgba(16, 24, 20, 0.04), transparent);
|
|
14
20
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
15
21
|
color: var(--ink);
|
|
16
22
|
background: var(--bg);
|
|
@@ -18,21 +24,8 @@
|
|
|
18
24
|
line-height: 1.5;
|
|
19
25
|
-webkit-font-smoothing: antialiased;
|
|
20
26
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
--bg: #111312;
|
|
24
|
-
--surface: #181b1a;
|
|
25
|
-
--ink: #eceeed;
|
|
26
|
-
--muted: #9aa19d;
|
|
27
|
-
--line: #2a2f2c;
|
|
28
|
-
--accent: #4ade80;
|
|
29
|
-
--accent-ink: #052e16;
|
|
30
|
-
--accent-soft: #15301f;
|
|
31
|
-
--danger: #f97066;
|
|
32
|
-
--warn: #fbbf24;
|
|
33
|
-
--shadow: none;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
27
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
28
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
36
29
|
* { box-sizing: border-box; }
|
|
37
30
|
body { margin: 0; }
|
|
38
31
|
button, input, select, textarea { font: inherit; color: inherit; }
|
|
@@ -42,9 +35,9 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
|
|
42
35
|
|
|
43
36
|
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 2; }
|
|
44
37
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
|
45
|
-
.mark { display:
|
|
38
|
+
.mark { display: block; width: 30px; height: 30px; }
|
|
46
39
|
.env { font-size: 12px; font-weight: 500; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); }
|
|
47
|
-
.env-prod { background:
|
|
40
|
+
.env-prod { background: var(--warn-soft); color: var(--warn); }
|
|
48
41
|
.topbar-right { display: flex; align-items: center; gap: 14px; }
|
|
49
42
|
.live { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
|
50
43
|
.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
|
|
@@ -52,11 +45,15 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
|
|
52
45
|
.identity select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
|
53
46
|
.dashboard-link { font-size: 13px; color: var(--muted); text-decoration: none; }
|
|
54
47
|
.dashboard-link:hover { color: var(--ink); }
|
|
48
|
+
.theme-toggle { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--line); border-radius: 8px; }
|
|
49
|
+
.theme-toggle button { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
|
|
50
|
+
.theme-toggle button:hover { color: var(--ink); }
|
|
51
|
+
.theme-toggle button.on { background: var(--accent-soft); color: var(--accent); }
|
|
52
|
+
.theme-toggle button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
55
53
|
|
|
56
54
|
.notice { margin: 12px 20px 0; padding: 10px 14px; border-radius: var(--radius); display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
57
|
-
.notice.error { background:
|
|
55
|
+
.notice.error { background: var(--danger-soft); color: var(--danger); border: 1px solid var(--danger-line); }
|
|
58
56
|
.notice.info { background: var(--accent-soft); color: var(--accent); }
|
|
59
|
-
@media (prefers-color-scheme: dark) { .notice.error { background: #2a1513; border-color: #55201b; } }
|
|
60
57
|
|
|
61
58
|
.workspace { flex: 1; display: grid; grid-template-columns: 260px 1fr; min-height: 0; }
|
|
62
59
|
.sidebar { border-right: 1px solid var(--line); padding: 20px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
@@ -108,6 +105,7 @@ button.icon { color: var(--muted); font-size: 14px; opacity: 0; }
|
|
|
108
105
|
.danger-link { background: none; border: 0; color: var(--danger); cursor: pointer; font-size: 13px; }
|
|
109
106
|
.wide { width: 100%; text-align: left; }
|
|
110
107
|
.stack { display: flex; flex-direction: column; gap: 8px; }
|
|
108
|
+
code.block { display: block; padding: 8px 10px; border-radius: 6px; background: rgba(127, 127, 127, 0.12); font-size: 12px; word-break: break-all; }
|
|
111
109
|
|
|
112
110
|
.empty { margin-top: 12vh; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
|
113
111
|
.empty.small { margin-top: 36px; }
|
|
@@ -124,6 +122,8 @@ button.icon { color: var(--muted); font-size: 14px; opacity: 0; }
|
|
|
124
122
|
.workspace { grid-template-columns: 1fr; }
|
|
125
123
|
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
126
124
|
.main { padding: 20px 16px 48px; }
|
|
127
|
-
.topbar { flex-wrap: wrap; }
|
|
125
|
+
.topbar { flex-wrap: wrap; padding: 10px 16px; }
|
|
126
|
+
.topbar-right { flex-wrap: wrap; gap: 10px; min-width: 0; }
|
|
127
|
+
.identity, .identity select { max-width: 100%; min-width: 0; }
|
|
128
128
|
.task-detail { padding-left: 16px; }
|
|
129
129
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark, or the system's setting. The choice is kept in this browser (localStorage) and
|
|
3
|
+
* applied as data-theme on <html>; every color in styles.css follows it. public/theme.js
|
|
4
|
+
* applies it before first paint.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
type Theme = "system" | "light" | "dark";
|
|
9
|
+
const KEY = "app:theme";
|
|
10
|
+
|
|
11
|
+
function stored(): Theme {
|
|
12
|
+
try {
|
|
13
|
+
const value = localStorage.getItem(KEY);
|
|
14
|
+
return value === "light" || value === "dark" ? value : "system";
|
|
15
|
+
} catch {
|
|
16
|
+
return "system";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function apply(theme: Theme) {
|
|
21
|
+
if (theme === "system") delete document.documentElement.dataset.theme;
|
|
22
|
+
else document.documentElement.dataset.theme = theme;
|
|
23
|
+
try {
|
|
24
|
+
if (theme === "system") localStorage.removeItem(KEY);
|
|
25
|
+
else localStorage.setItem(KEY, theme);
|
|
26
|
+
} catch {
|
|
27
|
+
// Storage is blocked (a private window): the choice lasts until the page reloads.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const OPTIONS: Array<{ theme: Theme; label: string; icon: ReactNode }> = [
|
|
32
|
+
{ theme: "system", label: "System", icon: <><rect x="3" y="4" width="14" height="10" rx="1.5" /><path d="M7.5 17h5M10 14v3" /></> },
|
|
33
|
+
{ theme: "light", label: "Light", icon: <><circle cx="10" cy="10" r="3.2" /><path d="M10 2.5v2M10 15.5v2M2.5 10h2M15.5 10h2M4.7 4.7l1.4 1.4M13.9 13.9l1.4 1.4M4.7 15.3l1.4-1.4M13.9 6.1l1.4-1.4" /></> },
|
|
34
|
+
{ theme: "dark", label: "Dark", icon: <path d="M15.5 12.2A6 6 0 0 1 7.8 4.5a6 6 0 1 0 7.7 7.7Z" /> },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** The light / dark / system switch in the top bar. */
|
|
38
|
+
export function ThemeToggle() {
|
|
39
|
+
const [theme, setTheme] = useState<Theme>(stored);
|
|
40
|
+
useEffect(() => apply(theme), [theme]);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onStorage = (e: StorageEvent) => e.key === KEY && setTheme(stored());
|
|
43
|
+
window.addEventListener("storage", onStorage);
|
|
44
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
45
|
+
}, []);
|
|
46
|
+
return (
|
|
47
|
+
<div className="theme-toggle" role="radiogroup" aria-label="Theme">
|
|
48
|
+
{OPTIONS.map((o) => (
|
|
49
|
+
<button key={o.theme} type="button" role="radio" aria-checked={theme === o.theme} className={theme === o.theme ? "on" : ""} onClick={() => setTheme(o.theme)} title={`${o.label} theme`}>
|
|
50
|
+
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
51
|
+
{o.icon}
|
|
52
|
+
</svg>
|
|
53
|
+
<span className="sr-only">{o.label}</span>
|
|
54
|
+
</button>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -57,6 +57,26 @@ test("notes are stored by the Files service and readable by viewers", async () =
|
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
test("completing someone else's task notifies them, in their own inbox only", async () => {
|
|
61
|
+
const t = await createTestApp();
|
|
62
|
+
const bob = { id: "bob", type: "user" as const, roles: ["member"] };
|
|
63
|
+
try {
|
|
64
|
+
const ada = t.as("ada");
|
|
65
|
+
const projectId = await ada.mutation(api.projects.create, { name: "Inbox" });
|
|
66
|
+
const taskId = await ada.mutation(api.tasks.create, { projectId, title: "Review the draft" });
|
|
67
|
+
await t.as(bob).mutation(api.tasks.setDone, { id: taskId, done: true });
|
|
68
|
+
const [notification, ...rest] = await ada.query(api.inbox.list, { unread: true });
|
|
69
|
+
assert.equal(rest.length, 0);
|
|
70
|
+
assert.equal(notification!.type, "task.completed");
|
|
71
|
+
assert.deepEqual(notification!.data, { taskId });
|
|
72
|
+
assert.deepEqual(await t.as(bob).query(api.inbox.list, {}), [], "bob sees only his own");
|
|
73
|
+
await ada.mutation(api.inbox.markRead, { id: notification!.id });
|
|
74
|
+
assert.deepEqual(await ada.query(api.inbox.list, { unread: true }), []);
|
|
75
|
+
} finally {
|
|
76
|
+
await t.close();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
60
80
|
test("deleting a project removes its tasks in one atomic commit", async () => {
|
|
61
81
|
const t = await createTestApp();
|
|
62
82
|
try {
|
|
@@ -99,3 +119,25 @@ test("state survives a restart", async () => {
|
|
|
99
119
|
await t.close();
|
|
100
120
|
}
|
|
101
121
|
});
|
|
122
|
+
|
|
123
|
+
test("completing someone else's task notifies its creator; the clear-completed job runs as whoever queued it", async () => {
|
|
124
|
+
const t = await createTestApp();
|
|
125
|
+
try {
|
|
126
|
+
const ada = t.as("ada");
|
|
127
|
+
const projectId = await ada.mutation(api.projects.create, { name: "Release" });
|
|
128
|
+
const taskId = await ada.mutation(api.tasks.create, { projectId, title: "Tag the build" });
|
|
129
|
+
await t.as({ id: "triage-bot", type: "agent", roles: ["agent"] }).mutation(api.tasks.setDone, { id: taskId, done: true });
|
|
130
|
+
const sent = await t.runtime.instance.handle("test").collection<{ recipient: string; type: string }>("notifications").find({});
|
|
131
|
+
assert.deepEqual(sent.map((n) => [n.recipient, n.type]), [["ada", "task.completed"]]);
|
|
132
|
+
|
|
133
|
+
// Studio → Services → Jobs queues this as the chosen identity; here, as Ada.
|
|
134
|
+
const principal = { id: "ada", type: "user" as const, roles: ["member", "admin"] };
|
|
135
|
+
await t.runtime.issueSession(principal, { kind: "test", via: "test" });
|
|
136
|
+
const services = t.runtime.services;
|
|
137
|
+
await services.asPrincipal(principal, (verified) => services.jobs!.enqueue({ type: "projects.clear-completed", payload: { projectId } }, verified));
|
|
138
|
+
assert.equal((await t.runtime.runServices()).jobs, 1);
|
|
139
|
+
assert.deepEqual(await ada.query(api.tasks.list, { projectId }), [], "the completed task was cleared");
|
|
140
|
+
} finally {
|
|
141
|
+
await t.close();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -20,16 +20,16 @@
|
|
|
20
20
|
"deploy:fly": "node scripts/deploy-fly.mjs"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@feltdb/feltkit": "0.3.
|
|
23
|
+
"@feltdb/feltkit": "0.3.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@feltdb/core": "0.16.
|
|
26
|
+
"@feltdb/core": "0.16.1",
|
|
27
27
|
"@types/node": "22.19.1",
|
|
28
28
|
"typescript": "5.9.3"
|
|
29
29
|
},
|
|
30
30
|
"overrides": {
|
|
31
31
|
"@appport/services": {
|
|
32
|
-
"@feltdb/core": "0.16.
|
|
32
|
+
"@feltdb/core": "0.16.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#14532d"/><path d="M11 7c-4 5-4 13 0 18M21 7c4 5 4 13 0 18" stroke="#ecfdf5" stroke-width="3" fill="none" stroke-linecap="round"/></svg>
|