@mh-gg/templates 0.1.1-alpha.20260708T105530106Z
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/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +32 -0
- package/templates/minimal/README.md +11 -0
- package/templates/minimal/_gitignore +4 -0
- package/templates/minimal/package.json +19 -0
- package/templates/minimal/scripts/build.js +4 -0
- package/templates/minimal/scripts/typecheck.js +5 -0
- package/templates/minimal/src/app.js +143 -0
- package/templates/minimal/test/app.test.js +37 -0
- package/templates/react/README.md +11 -0
- package/templates/react/_gitignore +4 -0
- package/templates/react/app.cjs +169 -0
- package/templates/react/index.html +5 -0
- package/templates/react/package.json +33 -0
- package/templates/react/scripts/build.js +4 -0
- package/templates/react/scripts/typecheck.js +6 -0
- package/templates/react/src/main.tsx +190 -0
- package/templates/react/src/styles.css +472 -0
- package/templates/react/src/vite-env.d.ts +6 -0
- package/templates/react/test/app.test.js +37 -0
- package/templates/react/tsconfig.json +14 -0
- package/templates/react/types/.gitkeep +1 -0
- package/templates/react/vite.config.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matterhorn contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/templates",
|
|
3
|
+
"version": "0.1.1-alpha.20260708T105530106Z",
|
|
4
|
+
"description": "Matterhorn app templates used by the Matterhorn CLI create flow.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./package.json": "./package.json"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"templates/minimal/_gitignore",
|
|
12
|
+
"templates/minimal/package.json",
|
|
13
|
+
"templates/minimal/README.md",
|
|
14
|
+
"templates/minimal/scripts",
|
|
15
|
+
"templates/minimal/src",
|
|
16
|
+
"templates/minimal/test",
|
|
17
|
+
"templates/react/_gitignore",
|
|
18
|
+
"templates/react/app.cjs",
|
|
19
|
+
"templates/react/index.html",
|
|
20
|
+
"templates/react/package.json",
|
|
21
|
+
"templates/react/README.md",
|
|
22
|
+
"templates/react/scripts",
|
|
23
|
+
"templates/react/src",
|
|
24
|
+
"templates/react/test",
|
|
25
|
+
"templates/react/types",
|
|
26
|
+
"templates/react/tsconfig.json",
|
|
27
|
+
"templates/react/vite.config.js",
|
|
28
|
+
"README.md",
|
|
29
|
+
"package.json"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A Matterhorn app scaffolded with `create-matterhorn`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install
|
|
7
|
+
npm test
|
|
8
|
+
npm run dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The app definition in `src/app.js` uses only the public `matterhorn-sdk` API. Tests use `matterhorn-sdk/testing` so you can iterate without starting a relay or browser host.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "matterhorn dev ./src/app.js __APP_NAME__",
|
|
8
|
+
"build": "node scripts/build.js",
|
|
9
|
+
"typecheck": "node scripts/typecheck.js",
|
|
10
|
+
"test": "node --test test/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@mh-gg/cli": "__CLI_SPECIFIER__",
|
|
14
|
+
"matterhorn-sdk": "__SDK_SPECIFIER__"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.12"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import app from "../src/app.js";
|
|
2
|
+
|
|
3
|
+
const composition = app.toJSON();
|
|
4
|
+
if (!Array.isArray(composition.actions) || composition.actions.length === 0) throw new Error("Matterhorn app actions are invalid.");
|
|
5
|
+
console.log("Typecheck placeholder passed. Add TypeScript when your app needs static types.");
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { action, defineApp, defineModel, p, query } from "matterhorn-sdk";
|
|
2
|
+
|
|
3
|
+
const model = defineModel({
|
|
4
|
+
state: {
|
|
5
|
+
initial: {
|
|
6
|
+
todos: {},
|
|
7
|
+
activity: {}
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const { todos, activity } = model.collections;
|
|
13
|
+
|
|
14
|
+
const app = defineApp({
|
|
15
|
+
id: "com.example.__APP_NAME__",
|
|
16
|
+
name: "__APP_NAME__",
|
|
17
|
+
model: model.withOperations({
|
|
18
|
+
"todo.add": action({
|
|
19
|
+
authorize: { roles: ["member"] },
|
|
20
|
+
payload: {
|
|
21
|
+
text: p.string({ min: 1, max: 300 })
|
|
22
|
+
}
|
|
23
|
+
}, ({ ref, fx }) => ({
|
|
24
|
+
effects: [
|
|
25
|
+
fx.create(todos, {
|
|
26
|
+
id: ref.newId("todo"),
|
|
27
|
+
fields: {
|
|
28
|
+
text: ref.payload("text"),
|
|
29
|
+
done: false,
|
|
30
|
+
authorId: ref.actor("memberId"),
|
|
31
|
+
createdAt: ref.now(),
|
|
32
|
+
completedAt: null,
|
|
33
|
+
archivedAt: null
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
fx.create(activity, {
|
|
37
|
+
id: ref.newId("activity"),
|
|
38
|
+
fields: {
|
|
39
|
+
type: "created",
|
|
40
|
+
todoText: ref.payload("text"),
|
|
41
|
+
todoId: null,
|
|
42
|
+
actorId: ref.actor("memberId"),
|
|
43
|
+
createdAt: ref.now()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
]
|
|
47
|
+
})),
|
|
48
|
+
"todo.complete": action({
|
|
49
|
+
authorize: { roles: ["member"] },
|
|
50
|
+
payload: {
|
|
51
|
+
id: p.string()
|
|
52
|
+
}
|
|
53
|
+
}, ({ ref, fx }) => ({
|
|
54
|
+
effects: [
|
|
55
|
+
fx.update(todos, {
|
|
56
|
+
id: ref.payload("id"),
|
|
57
|
+
set: {
|
|
58
|
+
done: true,
|
|
59
|
+
completedAt: ref.now()
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
fx.create(activity, {
|
|
63
|
+
id: ref.newId("activity"),
|
|
64
|
+
fields: {
|
|
65
|
+
type: "completed",
|
|
66
|
+
todoText: "",
|
|
67
|
+
todoId: ref.payload("id"),
|
|
68
|
+
actorId: ref.actor("memberId"),
|
|
69
|
+
createdAt: ref.now()
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
]
|
|
73
|
+
})),
|
|
74
|
+
"todo.reopen": action({
|
|
75
|
+
authorize: { roles: ["member"] },
|
|
76
|
+
payload: {
|
|
77
|
+
id: p.string()
|
|
78
|
+
}
|
|
79
|
+
}, ({ ref, fx }) => ({
|
|
80
|
+
effects: [
|
|
81
|
+
fx.update(todos, {
|
|
82
|
+
id: ref.payload("id"),
|
|
83
|
+
set: {
|
|
84
|
+
done: false,
|
|
85
|
+
completedAt: null
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
fx.create(activity, {
|
|
89
|
+
id: ref.newId("activity"),
|
|
90
|
+
fields: {
|
|
91
|
+
type: "reopened",
|
|
92
|
+
todoText: "",
|
|
93
|
+
todoId: ref.payload("id"),
|
|
94
|
+
actorId: ref.actor("memberId"),
|
|
95
|
+
createdAt: ref.now()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
]
|
|
99
|
+
})),
|
|
100
|
+
"todo.archive": action({
|
|
101
|
+
authorize: { roles: ["member"] },
|
|
102
|
+
payload: {
|
|
103
|
+
id: p.string()
|
|
104
|
+
}
|
|
105
|
+
}, ({ ref, fx }) => ({
|
|
106
|
+
effects: [
|
|
107
|
+
fx.update(todos, {
|
|
108
|
+
id: ref.payload("id"),
|
|
109
|
+
set: {
|
|
110
|
+
archivedAt: ref.now()
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
fx.create(activity, {
|
|
114
|
+
id: ref.newId("activity"),
|
|
115
|
+
fields: {
|
|
116
|
+
type: "deleted",
|
|
117
|
+
todoText: "",
|
|
118
|
+
todoId: ref.payload("id"),
|
|
119
|
+
actorId: ref.actor("memberId"),
|
|
120
|
+
createdAt: ref.now()
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
]
|
|
124
|
+
}))
|
|
125
|
+
}),
|
|
126
|
+
actions: {
|
|
127
|
+
addTodo: { plugin: "primary", type: "todo.add" },
|
|
128
|
+
completeTodo: { plugin: "primary", type: "todo.complete" },
|
|
129
|
+
reopenTodo: { plugin: "primary", type: "todo.reopen" },
|
|
130
|
+
deleteTodo: { plugin: "primary", type: "todo.archive" }
|
|
131
|
+
},
|
|
132
|
+
queries: {
|
|
133
|
+
todos: query.collection(todos),
|
|
134
|
+
activity: query.collection(activity)
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export default app;
|
|
139
|
+
|
|
140
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
141
|
+
console.log(`${app.toJSON().app.name} (${app.toJSON().app.id}) is ready.`);
|
|
142
|
+
console.log("Run npm test to exercise it with matterhorn-sdk/testing.");
|
|
143
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createTestRoom, testActor } from "matterhorn-sdk/testing";
|
|
4
|
+
import app from "../src/app.js";
|
|
5
|
+
|
|
6
|
+
test("shares todo lifecycle and ledger entries", async () => {
|
|
7
|
+
const room = createTestRoom(app, { now: () => 1000 });
|
|
8
|
+
const alice = testActor("alice");
|
|
9
|
+
const guest = testActor("guest", { role: "none" });
|
|
10
|
+
|
|
11
|
+
await assert.rejects(
|
|
12
|
+
() => room.as(guest).actions.addTodo({ text: "Sneak in" }),
|
|
13
|
+
/requires member role/
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
await room.as(alice).actions.addTodo({ text: "Ship Matterhorn" });
|
|
17
|
+
const [todo] = await room.queries.todos();
|
|
18
|
+
assert.equal(todo.text, "Ship Matterhorn");
|
|
19
|
+
assert.equal(todo.authorId, "alice");
|
|
20
|
+
assert.equal(todo.completedAt, null);
|
|
21
|
+
|
|
22
|
+
await room.as(alice).actions.completeTodo({ id: todo.id });
|
|
23
|
+
const [updated] = await room.queries.todos();
|
|
24
|
+
assert.equal(updated.done, true);
|
|
25
|
+
assert.equal(updated.completedAt, 1000);
|
|
26
|
+
|
|
27
|
+
await room.as(alice).actions.reopenTodo({ id: todo.id });
|
|
28
|
+
const [reopened] = await room.queries.todos();
|
|
29
|
+
assert.equal(reopened.done, false);
|
|
30
|
+
assert.equal(reopened.completedAt, null);
|
|
31
|
+
|
|
32
|
+
await room.as(alice).actions.deleteTodo({ id: todo.id });
|
|
33
|
+
assert.deepEqual(await room.queries.todos(), []);
|
|
34
|
+
|
|
35
|
+
const activity = await room.queries.activity();
|
|
36
|
+
assert.deepEqual(activity.map((item) => item.type), ["created", "completed", "reopened", "deleted"]);
|
|
37
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A React Matterhorn app scaffolded with `create-matterhorn`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm run dev
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The dev script runs `matterhorn dev` through the local project dependency. It performs the local preflight, installs missing project dependencies when needed, emits generated Matterhorn artifacts, runs app diagnostics, starts the Matterhorn runtime, and serves the React frontend through the real Matterhorn client path.
|
|
10
|
+
|
|
11
|
+
The source-of-truth app definition is `app.cjs`. The React shell in `src/main.tsx` uses `matterhorn-sdk/react` with generated app types from `types/matterhorn.types.d.ts`. Fast app-author tests can still use the in-memory harness in `test/app.test.js`.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const { action, defineApp, defineModel, p, query } = require("matterhorn-sdk");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const packageRoot = __dirname;
|
|
5
|
+
const viteCommand = path.join(packageRoot, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
|
|
6
|
+
|
|
7
|
+
const model = defineModel({
|
|
8
|
+
state: {
|
|
9
|
+
initial: {
|
|
10
|
+
todos: {},
|
|
11
|
+
activity: {}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const { todos, activity } = model.collections;
|
|
17
|
+
|
|
18
|
+
const app = defineApp({
|
|
19
|
+
id: "com.example.__APP_NAME__",
|
|
20
|
+
name: "__APP_NAME__",
|
|
21
|
+
version: "0.1.0",
|
|
22
|
+
namespace: "TodoApp",
|
|
23
|
+
pluginId: "com.example.__APP_NAME__.plugin",
|
|
24
|
+
slug: "__APP_NAME__",
|
|
25
|
+
packageName: "__APP_NAME__",
|
|
26
|
+
packageRoot,
|
|
27
|
+
frontend: {
|
|
28
|
+
root: packageRoot,
|
|
29
|
+
port: 42737,
|
|
30
|
+
devEntry: "src/main.tsx",
|
|
31
|
+
builtEntry: "matterhorn-__APP_NAME__.js",
|
|
32
|
+
dist: "dist",
|
|
33
|
+
locations: [{ type: "local-path", ref: ".", label: "__APP_NAME__ frontend" }],
|
|
34
|
+
dev: {
|
|
35
|
+
command: viteCommand,
|
|
36
|
+
args: ["--host", "127.0.0.1", "--port", "${bundlePort}", "--strictPort"]
|
|
37
|
+
},
|
|
38
|
+
build: {
|
|
39
|
+
command: viteCommand,
|
|
40
|
+
args: ["build"]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
model: model.withOperations({
|
|
44
|
+
"todo.add": action({
|
|
45
|
+
authorize: { roles: ["member"] },
|
|
46
|
+
payload: {
|
|
47
|
+
text: p.string({ min: 1, max: 300 })
|
|
48
|
+
}
|
|
49
|
+
}, ({ ref, fx }) => ({
|
|
50
|
+
effects: [
|
|
51
|
+
fx.create(todos, {
|
|
52
|
+
id: ref.newId("todo"),
|
|
53
|
+
fields: {
|
|
54
|
+
text: ref.payload("text"),
|
|
55
|
+
done: false,
|
|
56
|
+
authorId: ref.actor("memberId"),
|
|
57
|
+
createdAt: ref.now(),
|
|
58
|
+
completedAt: null,
|
|
59
|
+
archivedAt: null
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
fx.create(activity, {
|
|
63
|
+
id: ref.newId("activity"),
|
|
64
|
+
fields: {
|
|
65
|
+
type: "created",
|
|
66
|
+
todoText: ref.payload("text"),
|
|
67
|
+
todoId: null,
|
|
68
|
+
actorId: ref.actor("memberId"),
|
|
69
|
+
createdAt: ref.now()
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
]
|
|
73
|
+
})),
|
|
74
|
+
"todo.complete": action({
|
|
75
|
+
authorize: { roles: ["member"] },
|
|
76
|
+
payload: {
|
|
77
|
+
id: p.string()
|
|
78
|
+
}
|
|
79
|
+
}, ({ ref, fx }) => ({
|
|
80
|
+
effects: [
|
|
81
|
+
fx.update(todos, {
|
|
82
|
+
id: ref.payload("id"),
|
|
83
|
+
set: {
|
|
84
|
+
done: true,
|
|
85
|
+
completedAt: ref.now()
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
fx.create(activity, {
|
|
89
|
+
id: ref.newId("activity"),
|
|
90
|
+
fields: {
|
|
91
|
+
type: "completed",
|
|
92
|
+
todoText: "",
|
|
93
|
+
todoId: ref.payload("id"),
|
|
94
|
+
actorId: ref.actor("memberId"),
|
|
95
|
+
createdAt: ref.now()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
]
|
|
99
|
+
})),
|
|
100
|
+
"todo.reopen": action({
|
|
101
|
+
authorize: { roles: ["member"] },
|
|
102
|
+
payload: {
|
|
103
|
+
id: p.string()
|
|
104
|
+
}
|
|
105
|
+
}, ({ ref, fx }) => ({
|
|
106
|
+
effects: [
|
|
107
|
+
fx.update(todos, {
|
|
108
|
+
id: ref.payload("id"),
|
|
109
|
+
set: {
|
|
110
|
+
done: false,
|
|
111
|
+
completedAt: null
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
fx.create(activity, {
|
|
115
|
+
id: ref.newId("activity"),
|
|
116
|
+
fields: {
|
|
117
|
+
type: "reopened",
|
|
118
|
+
todoText: "",
|
|
119
|
+
todoId: ref.payload("id"),
|
|
120
|
+
actorId: ref.actor("memberId"),
|
|
121
|
+
createdAt: ref.now()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
]
|
|
125
|
+
})),
|
|
126
|
+
"todo.archive": action({
|
|
127
|
+
authorize: { roles: ["member"] },
|
|
128
|
+
payload: {
|
|
129
|
+
id: p.string()
|
|
130
|
+
}
|
|
131
|
+
}, ({ ref, fx }) => ({
|
|
132
|
+
effects: [
|
|
133
|
+
fx.update(todos, {
|
|
134
|
+
id: ref.payload("id"),
|
|
135
|
+
set: {
|
|
136
|
+
archivedAt: ref.now()
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
fx.create(activity, {
|
|
140
|
+
id: ref.newId("activity"),
|
|
141
|
+
fields: {
|
|
142
|
+
type: "deleted",
|
|
143
|
+
todoText: "",
|
|
144
|
+
todoId: ref.payload("id"),
|
|
145
|
+
actorId: ref.actor("memberId"),
|
|
146
|
+
createdAt: ref.now()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
]
|
|
150
|
+
}))
|
|
151
|
+
}),
|
|
152
|
+
actions: {
|
|
153
|
+
addTodo: { plugin: "primary", type: "todo.add" },
|
|
154
|
+
completeTodo: { plugin: "primary", type: "todo.complete" },
|
|
155
|
+
reopenTodo: { plugin: "primary", type: "todo.reopen" },
|
|
156
|
+
deleteTodo: { plugin: "primary", type: "todo.archive" }
|
|
157
|
+
},
|
|
158
|
+
queries: {
|
|
159
|
+
todos: query.collection(todos),
|
|
160
|
+
activity: query.collection(activity)
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
module.exports = app;
|
|
165
|
+
|
|
166
|
+
if (require.main === module) {
|
|
167
|
+
console.log(`${app.name} (${app.id}) is ready.`);
|
|
168
|
+
console.log("Run your package manager's dev script to start the Matterhorn runtime and frontend.");
|
|
169
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><link rel="stylesheet" href="/src/styles.css" /><title>__APP_NAME__</title></head>
|
|
4
|
+
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
|
5
|
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "matterhorn dev ./app.cjs __APP_NAME__",
|
|
8
|
+
"dev:frontend": "vite --host 127.0.0.1",
|
|
9
|
+
"build:frontend": "vite build",
|
|
10
|
+
"emit": "matterhorn app emit ./app.cjs --types types/matterhorn.types.d.ts",
|
|
11
|
+
"doctor": "matterhorn app doctor ./app.cjs",
|
|
12
|
+
"build": "matterhorn app emit ./app.cjs --types types/matterhorn.types.d.ts && vite build && node scripts/build.js",
|
|
13
|
+
"typecheck": "tsc --noEmit && node scripts/typecheck.js",
|
|
14
|
+
"test": "node --test test/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@mh-gg/cli": "__CLI_SPECIFIER__",
|
|
18
|
+
"matterhorn-sdk": "__SDK_SPECIFIER__",
|
|
19
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
20
|
+
"vite": "^8.0.16",
|
|
21
|
+
"typescript": "^5.6.3",
|
|
22
|
+
"@types/react": "^19.2.17",
|
|
23
|
+
"@types/react-dom": "^19.2.3",
|
|
24
|
+
"react": "^19.2.7",
|
|
25
|
+
"react-dom": "^19.2.7"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.12"
|
|
29
|
+
},
|
|
30
|
+
"matterhorn-sdk": {
|
|
31
|
+
"app": "./app.cjs"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import app from "../app.cjs";
|
|
2
|
+
|
|
3
|
+
const composition = app.toJSON();
|
|
4
|
+
if (!Array.isArray(composition.actions) || composition.actions.length === 0) throw new Error("Matterhorn app actions are invalid.");
|
|
5
|
+
if (!Array.isArray(composition.views) || !composition.views.some((view) => view.name === "todos")) throw new Error("Matterhorn app todos query is invalid.");
|
|
6
|
+
console.log("Typecheck placeholder passed. Add TypeScript when your app needs static types.");
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { useRoom, useRoomQuery } from "matterhorn-sdk/react";
|
|
4
|
+
import type { MatterhornRoom } from "matterhorn-sdk/client";
|
|
5
|
+
import type { TodoApp } from "../types/matterhorn.types";
|
|
6
|
+
|
|
7
|
+
const appName = "__APP_NAME__";
|
|
8
|
+
const EMPTY_TODOS: TodoApp.Todo[] = [];
|
|
9
|
+
const EMPTY_ACTIVITY: TodoApp.Activity[] = [];
|
|
10
|
+
|
|
11
|
+
function displayTime(value?: number | null) {
|
|
12
|
+
if (!value) return "Not yet";
|
|
13
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
14
|
+
hour: "numeric",
|
|
15
|
+
minute: "2-digit",
|
|
16
|
+
month: "short",
|
|
17
|
+
day: "numeric"
|
|
18
|
+
}).format(new Date(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function activityLabel(item: TodoApp.Activity, todosById: Map<string, TodoApp.Todo>) {
|
|
22
|
+
const todo = item.todoId ? todosById.get(item.todoId) : undefined;
|
|
23
|
+
const name = item.todoText || todo?.text || "a task";
|
|
24
|
+
return { name, verb: item.type };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function useTodos(room: MatterhornRoom<TodoApp>) {
|
|
28
|
+
const todos = useRoomQuery(room, "todos", EMPTY_TODOS);
|
|
29
|
+
|
|
30
|
+
return useMemo(() => {
|
|
31
|
+
const todosById = new Map(todos.map((todo) => [todo.id, todo]));
|
|
32
|
+
const openTodos = todos.filter((todo) => !todo.done);
|
|
33
|
+
const doneTodos = todos.filter((todo) => todo.done);
|
|
34
|
+
const completionRate = todos.length ? Math.round((doneTodos.length / todos.length) * 100) : 0;
|
|
35
|
+
|
|
36
|
+
return { todosById, openTodos, doneTodos, completionRate };
|
|
37
|
+
}, [todos]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function useActivity(room: MatterhornRoom<TodoApp>) {
|
|
41
|
+
const activity = useRoomQuery(room, "activity", EMPTY_ACTIVITY);
|
|
42
|
+
|
|
43
|
+
return useMemo(() => ({
|
|
44
|
+
recentActivity: [...activity].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 8)
|
|
45
|
+
}), [activity]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function TodoRow({
|
|
49
|
+
todo,
|
|
50
|
+
author,
|
|
51
|
+
onToggle,
|
|
52
|
+
onDelete
|
|
53
|
+
}: {
|
|
54
|
+
todo: TodoApp.Todo;
|
|
55
|
+
author: string;
|
|
56
|
+
onToggle: (todo: TodoApp.Todo) => void | Promise<void>;
|
|
57
|
+
onDelete: (todo: TodoApp.Todo) => void | Promise<void>;
|
|
58
|
+
}) {
|
|
59
|
+
return <li className={todo.done ? "todo-row todo-row-done" : "todo-row"}>
|
|
60
|
+
<label className="todo-check">
|
|
61
|
+
<input type="checkbox" checked={Boolean(todo.done)} onChange={() => void onToggle(todo)} />
|
|
62
|
+
</label>
|
|
63
|
+
<div className="todo-main">
|
|
64
|
+
<strong>{todo.text}</strong>
|
|
65
|
+
<small>
|
|
66
|
+
Added by {author} at {displayTime(todo.createdAt)}
|
|
67
|
+
{todo.completedAt ? `, completed ${displayTime(todo.completedAt)}` : ""}
|
|
68
|
+
</small>
|
|
69
|
+
</div>
|
|
70
|
+
<button className="icon-button" type="button" onClick={() => void onDelete(todo)} aria-label={`Delete ${todo.text}`}>
|
|
71
|
+
Delete
|
|
72
|
+
</button>
|
|
73
|
+
</li>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function App() {
|
|
77
|
+
const room = useRoom<TodoApp>();
|
|
78
|
+
const [text, setText] = useState("");
|
|
79
|
+
const [error, setError] = useState<string | null>(null);
|
|
80
|
+
const [isAdding, setIsAdding] = useState(false);
|
|
81
|
+
const { todosById, openTodos, doneTodos, completionRate } = useTodos(room);
|
|
82
|
+
const { recentActivity } = useActivity(room);
|
|
83
|
+
const actorLabel = room.displayName();
|
|
84
|
+
|
|
85
|
+
async function addTodo(event: React.FormEvent<HTMLFormElement>) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
const nextText = text.trim();
|
|
88
|
+
if (!nextText) return;
|
|
89
|
+
|
|
90
|
+
setIsAdding(true);
|
|
91
|
+
setError(null);
|
|
92
|
+
try {
|
|
93
|
+
await room.actions.addTodo({ text: nextText });
|
|
94
|
+
setText("");
|
|
95
|
+
} catch {
|
|
96
|
+
setError("Could not add the task. Try again.");
|
|
97
|
+
} finally {
|
|
98
|
+
setIsAdding(false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function toggleTodo(todo: TodoApp.Todo) {
|
|
103
|
+
if (todo.done) await room.actions.reopenTodo({ id: todo.id });
|
|
104
|
+
else await room.actions.completeTodo({ id: todo.id });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function deleteTodo(todo: TodoApp.Todo) {
|
|
108
|
+
await room.actions.deleteTodo({ id: todo.id });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return <main className="app-shell">
|
|
112
|
+
<div className="app-wrap">
|
|
113
|
+
<section className="workspace-panel">
|
|
114
|
+
<header className="workspace-header">
|
|
115
|
+
<div>
|
|
116
|
+
<p className="eyebrow">Shared Matterhorn Room</p>
|
|
117
|
+
<h1>{appName}</h1>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="actor-pill"><span></span>{actorLabel}</div>
|
|
120
|
+
</header>
|
|
121
|
+
|
|
122
|
+
<form className="composer" onSubmit={addTodo}>
|
|
123
|
+
<input value={text} onChange={(event) => setText(event.target.value)} placeholder="Add a task for the room" />
|
|
124
|
+
<button type="submit" disabled={isAdding}>{isAdding ? "Adding..." : "Add task"}</button>
|
|
125
|
+
</form>
|
|
126
|
+
{error ? <p role="alert" className="error-message">{error}</p> : null}
|
|
127
|
+
|
|
128
|
+
<div className="stats-strip" aria-label="Todo summary">
|
|
129
|
+
<div><strong>{openTodos.length}</strong><span>Open</span></div>
|
|
130
|
+
<div><strong>{doneTodos.length}</strong><span>Done</span></div>
|
|
131
|
+
<div><strong>{completionRate}%</strong><span>Complete</span></div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<section className="task-section">
|
|
135
|
+
<div className="section-heading">
|
|
136
|
+
<h2>Open tasks</h2>
|
|
137
|
+
<span>{openTodos.length}</span>
|
|
138
|
+
</div>
|
|
139
|
+
{openTodos.length === 0
|
|
140
|
+
? <p className="empty-state">Nothing open. Add a task or reopen one from Done.</p>
|
|
141
|
+
: <ul className="todo-list">{openTodos.map((todo) => (
|
|
142
|
+
<TodoRow
|
|
143
|
+
key={todo.id}
|
|
144
|
+
todo={todo}
|
|
145
|
+
author={room.displayName(todo.authorId)}
|
|
146
|
+
onToggle={toggleTodo}
|
|
147
|
+
onDelete={deleteTodo}
|
|
148
|
+
/>
|
|
149
|
+
))}</ul>}
|
|
150
|
+
</section>
|
|
151
|
+
|
|
152
|
+
<section className="task-section">
|
|
153
|
+
<div className="section-heading">
|
|
154
|
+
<h2>Done</h2>
|
|
155
|
+
<span>{doneTodos.length}</span>
|
|
156
|
+
</div>
|
|
157
|
+
{doneTodos.length === 0
|
|
158
|
+
? <p className="empty-state">Completed tasks land here with timestamps.</p>
|
|
159
|
+
: <ul className="todo-list">{doneTodos.map((todo) => (
|
|
160
|
+
<TodoRow
|
|
161
|
+
key={todo.id}
|
|
162
|
+
todo={todo}
|
|
163
|
+
author={room.displayName(todo.authorId)}
|
|
164
|
+
onToggle={toggleTodo}
|
|
165
|
+
onDelete={deleteTodo}
|
|
166
|
+
/>
|
|
167
|
+
))}</ul>}
|
|
168
|
+
</section>
|
|
169
|
+
</section>
|
|
170
|
+
|
|
171
|
+
<aside className="ledger-panel">
|
|
172
|
+
<div className="ledger-heading">
|
|
173
|
+
<h2>Room ledger</h2>
|
|
174
|
+
<span>{recentActivity.length}</span>
|
|
175
|
+
</div>
|
|
176
|
+
{recentActivity.length === 0
|
|
177
|
+
? <p className="empty-state">Every shared change will appear here.</p>
|
|
178
|
+
: <ol className="ledger-list">{recentActivity.map((item) => {
|
|
179
|
+
const label = activityLabel(item, todosById);
|
|
180
|
+
return <li key={item.id}>
|
|
181
|
+
<p><span>{room.displayName(item.actorId)}</span> {label.verb} <q>{label.name}</q></p>
|
|
182
|
+
<time>{displayTime(item.createdAt)}</time>
|
|
183
|
+
</li>;
|
|
184
|
+
})}</ol>}
|
|
185
|
+
</aside>
|
|
186
|
+
</div>
|
|
187
|
+
</main>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
createRoot(document.getElementById("root")!).render(<App />);
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
@import url("https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600;6..72,700&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--paper: #f3efe6;
|
|
5
|
+
--paper2: #ece6d9;
|
|
6
|
+
--sheet: #fbf9f4;
|
|
7
|
+
--ink: #2b2722;
|
|
8
|
+
--ink2: #6f685d;
|
|
9
|
+
--ink3: #9a9183;
|
|
10
|
+
--line: #e0d8c8;
|
|
11
|
+
--line2: #d4cab7;
|
|
12
|
+
--accent: #9c3b2e;
|
|
13
|
+
--accent-soft: #f0e3df;
|
|
14
|
+
--seal: #4f6a52;
|
|
15
|
+
--seal-soft: #e6ede4;
|
|
16
|
+
color: var(--ink);
|
|
17
|
+
background: var(--paper);
|
|
18
|
+
font-family: "Newsreader", ui-serif, Georgia, serif;
|
|
19
|
+
font-synthesis: none;
|
|
20
|
+
text-rendering: optimizeLegibility;
|
|
21
|
+
-webkit-font-smoothing: antialiased;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
* {
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
margin: 0;
|
|
30
|
+
min-width: 320px;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
background: var(--paper);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
button,
|
|
36
|
+
input {
|
|
37
|
+
font: inherit;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
button {
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
button:disabled {
|
|
45
|
+
cursor: not-allowed;
|
|
46
|
+
opacity: 0.65;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
h1,
|
|
50
|
+
h2,
|
|
51
|
+
p {
|
|
52
|
+
margin: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.app-shell {
|
|
56
|
+
min-height: 100vh;
|
|
57
|
+
padding: 38px 34px 60px;
|
|
58
|
+
background-color: var(--paper);
|
|
59
|
+
background-image:
|
|
60
|
+
linear-gradient(180deg, #f6f2ea, var(--paper) 220px),
|
|
61
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.app-wrap {
|
|
65
|
+
display: grid;
|
|
66
|
+
grid-template-columns: minmax(0, 1fr) 332px;
|
|
67
|
+
gap: 26px;
|
|
68
|
+
align-items: start;
|
|
69
|
+
max-width: 1180px;
|
|
70
|
+
margin: 0 auto;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.workspace-panel,
|
|
74
|
+
.ledger-panel {
|
|
75
|
+
position: relative;
|
|
76
|
+
background: var(--sheet);
|
|
77
|
+
border: 1px solid var(--line);
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
box-shadow:
|
|
80
|
+
0 1px 0 #fefbf5 inset,
|
|
81
|
+
0 18px 40px -22px rgba(43, 39, 34, 0.32),
|
|
82
|
+
0 2px 6px -3px rgba(43, 39, 34, 0.18);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.workspace-panel::before,
|
|
86
|
+
.ledger-panel::before {
|
|
87
|
+
content: "";
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 5px;
|
|
90
|
+
border: 1px solid var(--line);
|
|
91
|
+
border-radius: 2px;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
opacity: 0.55;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.workspace-panel {
|
|
97
|
+
padding: 30px 32px 34px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.ledger-panel {
|
|
101
|
+
align-self: start;
|
|
102
|
+
padding: 24px 24px 26px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.workspace-header {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: flex-start;
|
|
108
|
+
justify-content: space-between;
|
|
109
|
+
gap: 18px;
|
|
110
|
+
padding-bottom: 18px;
|
|
111
|
+
border-bottom: 1px solid var(--line);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.eyebrow {
|
|
115
|
+
margin: 0 0 7px;
|
|
116
|
+
color: var(--accent);
|
|
117
|
+
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
118
|
+
font-size: 11px;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
letter-spacing: 0.22em;
|
|
121
|
+
text-transform: uppercase;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
h1 {
|
|
125
|
+
font-size: 40px;
|
|
126
|
+
font-weight: 600;
|
|
127
|
+
letter-spacing: 0;
|
|
128
|
+
line-height: 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
h2 {
|
|
132
|
+
font-size: 16px;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
letter-spacing: 0.02em;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.actor-pill {
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
flex: 0 0 auto;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: 7px;
|
|
142
|
+
max-width: 260px;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
border: 1px solid #c7d6c9;
|
|
145
|
+
border-radius: 2px;
|
|
146
|
+
padding: 7px 12px;
|
|
147
|
+
color: var(--seal);
|
|
148
|
+
background: var(--seal-soft);
|
|
149
|
+
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
150
|
+
font-size: 12px;
|
|
151
|
+
font-weight: 500;
|
|
152
|
+
letter-spacing: 0.04em;
|
|
153
|
+
text-overflow: ellipsis;
|
|
154
|
+
white-space: nowrap;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.actor-pill span {
|
|
158
|
+
width: 6px;
|
|
159
|
+
height: 6px;
|
|
160
|
+
flex: 0 0 auto;
|
|
161
|
+
border-radius: 50%;
|
|
162
|
+
background: var(--seal);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.composer {
|
|
166
|
+
display: grid;
|
|
167
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
168
|
+
gap: 12px;
|
|
169
|
+
margin-top: 22px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.composer input {
|
|
173
|
+
min-width: 0;
|
|
174
|
+
border: none;
|
|
175
|
+
border-bottom: 1.5px solid var(--line2);
|
|
176
|
+
padding: 11px 4px;
|
|
177
|
+
color: var(--ink);
|
|
178
|
+
background: transparent;
|
|
179
|
+
font-size: 17px;
|
|
180
|
+
transition: border-color 0.2s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.composer input::placeholder {
|
|
184
|
+
color: var(--ink3);
|
|
185
|
+
font-style: italic;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.composer input:focus {
|
|
189
|
+
border-color: var(--accent);
|
|
190
|
+
outline: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.composer button {
|
|
194
|
+
border: 1px solid var(--ink);
|
|
195
|
+
border-radius: 2px;
|
|
196
|
+
padding: 0 20px;
|
|
197
|
+
color: var(--sheet);
|
|
198
|
+
background: var(--ink);
|
|
199
|
+
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
200
|
+
font-size: 12px;
|
|
201
|
+
font-weight: 600;
|
|
202
|
+
letter-spacing: 0.1em;
|
|
203
|
+
text-transform: uppercase;
|
|
204
|
+
transition:
|
|
205
|
+
background 0.15s,
|
|
206
|
+
transform 0.05s;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.composer button:hover {
|
|
210
|
+
background: #3c372f;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.composer button:active {
|
|
214
|
+
transform: translateY(1px);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.error-message {
|
|
218
|
+
margin-top: 12px;
|
|
219
|
+
color: var(--accent);
|
|
220
|
+
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
221
|
+
font-size: 12px;
|
|
222
|
+
font-weight: 600;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.stats-strip {
|
|
226
|
+
display: grid;
|
|
227
|
+
grid-template-columns: repeat(3, 1fr);
|
|
228
|
+
margin: 26px 0 6px;
|
|
229
|
+
border-top: 1px solid var(--line);
|
|
230
|
+
border-bottom: 1px solid var(--line);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.stats-strip div {
|
|
234
|
+
padding: 16px 4px 16px 18px;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.stats-strip div + div {
|
|
238
|
+
border-left: 1px solid var(--line);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.stats-strip strong {
|
|
242
|
+
display: block;
|
|
243
|
+
font-size: 30px;
|
|
244
|
+
font-weight: 600;
|
|
245
|
+
letter-spacing: 0;
|
|
246
|
+
line-height: 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.stats-strip span,
|
|
250
|
+
.section-heading span,
|
|
251
|
+
.todo-main small,
|
|
252
|
+
.ledger-list time,
|
|
253
|
+
.empty-state {
|
|
254
|
+
color: var(--ink2);
|
|
255
|
+
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
256
|
+
font-size: 11px;
|
|
257
|
+
font-weight: 500;
|
|
258
|
+
letter-spacing: 0.04em;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.stats-strip span,
|
|
262
|
+
.section-heading span {
|
|
263
|
+
font-size: 10px;
|
|
264
|
+
letter-spacing: 0.16em;
|
|
265
|
+
text-transform: uppercase;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.task-section {
|
|
269
|
+
margin-top: 28px;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.section-heading,
|
|
273
|
+
.ledger-heading {
|
|
274
|
+
display: flex;
|
|
275
|
+
align-items: baseline;
|
|
276
|
+
justify-content: space-between;
|
|
277
|
+
gap: 10px;
|
|
278
|
+
margin-bottom: 12px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.ledger-heading {
|
|
282
|
+
padding-bottom: 14px;
|
|
283
|
+
border-bottom: 1px solid var(--line);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.ledger-heading h2 {
|
|
287
|
+
font-size: 17px;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.todo-list,
|
|
291
|
+
.ledger-list {
|
|
292
|
+
display: flex;
|
|
293
|
+
flex-direction: column;
|
|
294
|
+
gap: 0;
|
|
295
|
+
margin: 0;
|
|
296
|
+
padding: 0;
|
|
297
|
+
list-style: none;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.todo-row {
|
|
301
|
+
display: grid;
|
|
302
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
303
|
+
align-items: center;
|
|
304
|
+
gap: 16px;
|
|
305
|
+
padding: 15px 4px;
|
|
306
|
+
border-top: 1px solid var(--line);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.todo-row:last-child {
|
|
310
|
+
border-bottom: 1px solid var(--line);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.todo-check {
|
|
314
|
+
flex: 0 0 auto;
|
|
315
|
+
cursor: pointer;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.todo-check input {
|
|
319
|
+
width: 18px;
|
|
320
|
+
height: 18px;
|
|
321
|
+
margin: 0;
|
|
322
|
+
cursor: pointer;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.todo-main {
|
|
326
|
+
min-width: 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.todo-main strong {
|
|
330
|
+
display: block;
|
|
331
|
+
overflow-wrap: anywhere;
|
|
332
|
+
font-size: 18px;
|
|
333
|
+
font-weight: 600;
|
|
334
|
+
letter-spacing: 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.todo-main small {
|
|
338
|
+
display: block;
|
|
339
|
+
margin-top: 3px;
|
|
340
|
+
overflow-wrap: anywhere;
|
|
341
|
+
color: var(--ink2);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.todo-row-done {
|
|
345
|
+
animation: slideIntoDone 0.42s cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.todo-row-done .todo-main strong {
|
|
349
|
+
color: var(--ink3);
|
|
350
|
+
text-decoration: line-through;
|
|
351
|
+
text-decoration-color: var(--line2);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@keyframes slideIntoDone {
|
|
355
|
+
0% {
|
|
356
|
+
opacity: 0;
|
|
357
|
+
transform: translateX(-26px);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
60% {
|
|
361
|
+
opacity: 1;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
100% {
|
|
365
|
+
opacity: 1;
|
|
366
|
+
transform: translateX(0);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.icon-button {
|
|
371
|
+
border: none;
|
|
372
|
+
padding: 6px 4px;
|
|
373
|
+
color: var(--accent);
|
|
374
|
+
background: transparent;
|
|
375
|
+
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
|
376
|
+
font-size: 11px;
|
|
377
|
+
font-weight: 500;
|
|
378
|
+
letter-spacing: 0.06em;
|
|
379
|
+
text-transform: uppercase;
|
|
380
|
+
opacity: 0.65;
|
|
381
|
+
transition: opacity 0.15s;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.icon-button:hover {
|
|
385
|
+
opacity: 1;
|
|
386
|
+
text-decoration: underline;
|
|
387
|
+
text-underline-offset: 3px;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.empty-state {
|
|
391
|
+
padding: 16px 2px;
|
|
392
|
+
color: var(--ink3);
|
|
393
|
+
font-size: 12px;
|
|
394
|
+
font-style: normal;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.ledger-list li {
|
|
398
|
+
position: relative;
|
|
399
|
+
padding: 13px 0 13px 18px;
|
|
400
|
+
border-bottom: 1px solid var(--line);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.ledger-list li::before {
|
|
404
|
+
content: "";
|
|
405
|
+
position: absolute;
|
|
406
|
+
left: 2px;
|
|
407
|
+
top: 18px;
|
|
408
|
+
width: 5px;
|
|
409
|
+
height: 5px;
|
|
410
|
+
border-radius: 50%;
|
|
411
|
+
background: var(--line2);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.ledger-list li:last-child {
|
|
415
|
+
border-bottom: none;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.ledger-list p {
|
|
419
|
+
color: var(--ink);
|
|
420
|
+
font-size: 15px;
|
|
421
|
+
line-height: 1.35;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.ledger-list p span {
|
|
425
|
+
font-weight: 600;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.ledger-list q {
|
|
429
|
+
color: var(--accent);
|
|
430
|
+
font-style: italic;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.ledger-list time {
|
|
434
|
+
display: block;
|
|
435
|
+
margin-top: 3px;
|
|
436
|
+
color: var(--ink3);
|
|
437
|
+
font-size: 10.5px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
@media (max-width: 880px) {
|
|
441
|
+
.app-shell {
|
|
442
|
+
padding: 38px 14px 60px;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.app-wrap {
|
|
446
|
+
grid-template-columns: 1fr;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.workspace-panel {
|
|
450
|
+
padding: 24px 22px;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
h1 {
|
|
454
|
+
font-size: 32px;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@media (max-width: 560px) {
|
|
459
|
+
.workspace-header,
|
|
460
|
+
.composer,
|
|
461
|
+
.todo-row {
|
|
462
|
+
grid-template-columns: 1fr;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.workspace-header {
|
|
466
|
+
display: grid;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.actor-pill {
|
|
470
|
+
max-width: 100%;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createTestRoom, testActor } from "matterhorn-sdk/testing";
|
|
4
|
+
import app from "../app.cjs";
|
|
5
|
+
|
|
6
|
+
test("shares todo lifecycle and ledger entries", async () => {
|
|
7
|
+
const room = createTestRoom(app, { now: () => 1000 });
|
|
8
|
+
const alice = testActor("alice");
|
|
9
|
+
const guest = testActor("guest", { role: "none" });
|
|
10
|
+
|
|
11
|
+
await assert.rejects(
|
|
12
|
+
() => room.as(guest).actions.addTodo({ text: "Sneak in" }),
|
|
13
|
+
/requires member role/
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
await room.as(alice).actions.addTodo({ text: "Ship Matterhorn" });
|
|
17
|
+
const [todo] = await room.queries.todos();
|
|
18
|
+
assert.equal(todo.text, "Ship Matterhorn");
|
|
19
|
+
assert.equal(todo.authorId, "alice");
|
|
20
|
+
assert.equal(todo.completedAt, null);
|
|
21
|
+
|
|
22
|
+
await room.as(alice).actions.completeTodo({ id: todo.id });
|
|
23
|
+
const [updated] = await room.queries.todos();
|
|
24
|
+
assert.equal(updated.done, true);
|
|
25
|
+
assert.equal(updated.completedAt, 1000);
|
|
26
|
+
|
|
27
|
+
await room.as(alice).actions.reopenTodo({ id: todo.id });
|
|
28
|
+
const [reopened] = await room.queries.todos();
|
|
29
|
+
assert.equal(reopened.done, false);
|
|
30
|
+
assert.equal(reopened.completedAt, null);
|
|
31
|
+
|
|
32
|
+
await room.as(alice).actions.deleteTodo({ id: todo.id });
|
|
33
|
+
assert.deepEqual(await room.queries.todos(), []);
|
|
34
|
+
|
|
35
|
+
const activity = await room.queries.activity();
|
|
36
|
+
assert.deepEqual(activity.map((item) => item.type), ["created", "completed", "reopened", "deleted"]);
|
|
37
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowSyntheticDefaultImports": true,
|
|
4
|
+
"jsx": "react-jsx",
|
|
5
|
+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"target": "ES2022"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src", "types"]
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import { matterhorn } from "matterhorn-sdk/vite";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
base: process.env.MATTERHORN_BUNDLE_BASE || "/",
|
|
7
|
+
plugins: [matterhorn({ typesFile: "types/matterhorn.types.d.ts" }), react()],
|
|
8
|
+
define: {
|
|
9
|
+
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
10
|
+
"process.env": "{}"
|
|
11
|
+
},
|
|
12
|
+
build: {
|
|
13
|
+
rollupOptions: {
|
|
14
|
+
output: {
|
|
15
|
+
entryFileNames: "matterhorn-__APP_NAME__.js",
|
|
16
|
+
chunkFileNames: "matterhorn-__APP_NAME__-[hash].js",
|
|
17
|
+
assetFileNames: "matterhorn-__APP_NAME__-[name][extname]"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|