create-coline-app 2.2.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/AGENTS.md +86 -0
  2. package/README.md +60 -7
  3. package/assets/preview-host.ts +130 -0
  4. package/bin/_run.mjs +11 -2
  5. package/package.json +9 -4
  6. package/src/check.ts +46 -0
  7. package/src/cli.test.ts +154 -0
  8. package/src/cli.ts +12 -1
  9. package/src/client-build.ts +50 -0
  10. package/src/dev.ts +6 -5
  11. package/src/preview-capture.ts +101 -0
  12. package/src/preview.ts +189 -0
  13. package/src/push.ts +8 -47
  14. package/src/scaffold.ts +6 -0
  15. package/templates/backendless/.agents/skills/coline-app-development/SKILL.md +36 -0
  16. package/templates/backendless/.agents/skills/coline-app-development/references/live-records.md +53 -0
  17. package/templates/backendless/.agents/skills/coline-ui-design/SKILL.md +67 -0
  18. package/templates/backendless/.agents/skills/coline-ui-design/assets/board.png +0 -0
  19. package/templates/backendless/.agents/skills/coline-ui-design/assets/settings.png +0 -0
  20. package/templates/backendless/.agents/skills/coline-ui-design/assets/triage.png +0 -0
  21. package/templates/backendless/.agents/skills/coline-ui-review/SKILL.md +39 -0
  22. package/templates/backendless/AGENTS.md +79 -194
  23. package/templates/backendless/CLAUDE.md +1 -5
  24. package/templates/backendless/README.md +22 -27
  25. package/templates/backendless/app.config.ts +19 -11
  26. package/templates/backendless/app.css +84 -0
  27. package/templates/backendless/examples/board.tsx +173 -0
  28. package/templates/backendless/examples/settings.tsx +117 -0
  29. package/templates/backendless/examples/triage.tsx +253 -0
  30. package/templates/backendless/main.tsx +232 -0
  31. package/templates/backendless/package.json +16 -6
  32. package/templates/backendless/preview.seed.ts +26 -0
package/src/dev.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { watch } from "node:fs";
2
- import { join } from "node:path";
3
2
  import { pushAppVersion, resolvePushConfig } from "./push";
4
3
 
5
4
  const DEBOUNCE_MS = 400;
6
5
 
7
6
  export async function runDev(argv: string[]): Promise<number> {
8
7
  const config = resolvePushConfig(argv);
9
- console.log(`Watching ${config.dir} — every change pushes a new dev version.`);
8
+ console.log(
9
+ `Watching ${config.dir} — changes update the same dev version; reload the app to see them.`,
10
+ );
10
11
 
11
12
  let timer: ReturnType<typeof setTimeout> | null = null;
12
13
  let pushing = false;
@@ -42,9 +43,9 @@ export async function runDev(argv: string[]): Promise<number> {
42
43
  return;
43
44
  }
44
45
  if (
45
- fileName.includes("node_modules") ||
46
- fileName.includes(".git") ||
47
- fileName.includes(join(".cache", ""))
46
+ fileName
47
+ .split(/[\\/]/)
48
+ .some((part) => ["node_modules", ".git", ".cache", "dist", ".coline"].includes(part))
48
49
  ) {
49
50
  return;
50
51
  }
@@ -0,0 +1,101 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { chromium } from "playwright";
4
+
5
+ /** Mechanical browser checks supplement, never replace, looking at the images. */
6
+ export async function capturePreview(origin: string, directory: string): Promise<number> {
7
+ await mkdir(directory, { recursive: true });
8
+ const browser = await chromium.launch().catch((cause: unknown) => {
9
+ throw new Error(
10
+ `Chromium could not start. Run npx playwright install chromium. ${String(cause)}`,
11
+ );
12
+ });
13
+ const results: Array<{ name: string; failures: string[] }> = [];
14
+ try {
15
+ for (const theme of ["light", "dark"]) {
16
+ for (const viewport of [
17
+ { name: "desktop", width: 1280, height: 800 },
18
+ { name: "mobile", width: 390, height: 844 },
19
+ ]) {
20
+ for (const state of ["ready", "empty", "error", "loading", "denied"]) {
21
+ const name = `${theme}-${viewport.name}-${state}`;
22
+ const failures: string[] = [];
23
+ const page = await browser.newPage({ viewport });
24
+ page.on("pageerror", (error) => failures.push(error.message));
25
+ page.on("console", (message) => {
26
+ if (message.type() === "error") failures.push(message.text());
27
+ });
28
+ try {
29
+ await page.goto(`${origin}/?theme=${theme}&state=${state}`);
30
+ await page.waitForFunction(
31
+ () =>
32
+ document.querySelector('iframe[data-ready="true"]') ||
33
+ document.body.dataset.previewError === "true",
34
+ undefined,
35
+ { timeout: 30_000 },
36
+ );
37
+ if (await page.locator("body").getAttribute("data-preview-error")) {
38
+ throw new Error(await page.locator("#notice").innerText());
39
+ }
40
+ // Let capabilities settle and fonts/layout paint; loading is deliberately unresolved.
41
+ await page.waitForTimeout(350);
42
+ const frame = page.frames().find((candidate) => candidate.url().includes("/app?"));
43
+ if (!frame) throw new Error("App iframe did not load.");
44
+ failures.push(
45
+ ...(await frame.evaluate(() => {
46
+ const issues: string[] = [];
47
+ if (document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)
48
+ issues.push("Page overflows horizontally");
49
+ const controls = document.querySelectorAll<HTMLInputElement>(
50
+ "input:not([type=hidden]),textarea,select",
51
+ );
52
+ for (const control of controls) {
53
+ if (
54
+ !control.labels?.length &&
55
+ !control.getAttribute("aria-label") &&
56
+ !control.getAttribute("aria-labelledby")
57
+ )
58
+ issues.push(`Unlabelled ${control.tagName.toLowerCase()}`);
59
+ }
60
+ return issues;
61
+ })),
62
+ );
63
+ const contentHeight = await frame.evaluate(() => document.documentElement.scrollHeight);
64
+ // Include content below the fold inside the iframe, especially stacked details.
65
+ await page.locator("iframe").evaluate((element, height) => {
66
+ element.style.flex = "none";
67
+ element.style.height = `${Math.min(height, 12000)}px`;
68
+ document.body.style.height = "auto";
69
+ }, contentHeight);
70
+ await page.screenshot({ path: join(directory, `${name}.png`), fullPage: true });
71
+ } catch (error) {
72
+ failures.push(String(error));
73
+ } finally {
74
+ await page.close();
75
+ }
76
+ results.push({ name, failures });
77
+ }
78
+ }
79
+ }
80
+ } finally {
81
+ await browser.close();
82
+ }
83
+ await writeFile(
84
+ join(directory, "report.json"),
85
+ JSON.stringify(
86
+ {
87
+ origin,
88
+ checkedAt: new Date().toISOString(),
89
+ note: "Simulated data. Checks cover browser errors, page overflow, and form labels. Inspect screenshots and test interactions; this is not a visual quality or full accessibility certification.",
90
+ results,
91
+ },
92
+ null,
93
+ 2,
94
+ ),
95
+ );
96
+ const failed = results.filter((result) => result.failures.length);
97
+ console.log(
98
+ `${results.length} preview captures; ${failed.length} with issues. ${join(directory, "report.json")}`,
99
+ );
100
+ return failed.length ? 1 : 0;
101
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { createServer } from "node:http";
2
+ import { stat, mkdir, writeFile } from "node:fs/promises";
3
+ import { watch } from "node:fs";
4
+ import { resolve, join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { build } from "esbuild";
7
+ import { buildClientSource } from "./client-build";
8
+ import { argValue } from "./args";
9
+
10
+ const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "assets");
11
+ const scriptSafe = (value: string) => value.replace(/<\/script/gi, "<\\/script");
12
+ const hostHtml = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Coline app preview</title><style>*{box-sizing:border-box}body{display:flex;flex-direction:column;height:100dvh;margin:0;font:12px system-ui;background:#f6f6f6;color:#222}header{flex-shrink:0;display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid #ddd}label{display:flex;gap:5px;align-items:center}select{font:inherit;padding:3px}iframe{display:block;border:0;width:100%;flex:1;min-height:0;background:white}output{color:#666}strong{font-weight:600}</style></head><body><header><strong>Local preview · simulated data</strong><label>Theme<select data-param="theme"><option value="light">Light</option><option value="dark">Dark</option></select></label><label>State<select data-param="state"><option value="ready">Ready</option><option value="empty">Empty</option><option value="error">Error</option><option value="loading">Loading</option><option value="denied">Permission denied</option></select></label><output id="notice">Edits reset on reload. No credentials or uploads.</output></header><iframe title="App preview" sandbox="allow-scripts allow-forms"></iframe><script type="module">import("/host.js").catch(error => { document.querySelector("#notice").textContent = "Preview could not start. Run npm run check and inspect the browser error: " + error.message; document.body.dataset.previewError = "true"; console.error(error); });</script></body></html>`;
13
+
14
+ export async function startPreview(dir: string, options: { port?: number; pattern?: string } = {}) {
15
+ const appDir = resolve(dir);
16
+ const pattern = options.pattern ?? "app";
17
+ if (!["app", "triage", "board", "settings"].includes(pattern))
18
+ throw new Error("Pattern must be app, triage, board, or settings.");
19
+ let revision = 0;
20
+ let cached: Promise<{ host: string; client: string }> | null = null;
21
+ const watcher = watch(appDir, { recursive: true }, (_event, name) => {
22
+ if (
23
+ !name ||
24
+ name
25
+ .split(/[\\/]/)
26
+ .some((part) => ["node_modules", ".git", ".coline", ".cache", "dist"].includes(part))
27
+ )
28
+ return;
29
+ revision++;
30
+ cached = null;
31
+ });
32
+ async function bundles() {
33
+ if (cached) return cached;
34
+ cached = (async () => {
35
+ let entry = "main.tsx";
36
+ if (pattern !== "app") {
37
+ await mkdir(join(appDir, ".coline"), { recursive: true });
38
+ entry = `.coline/preview-${pattern}.tsx`;
39
+ await writeFile(
40
+ join(appDir, entry),
41
+ `import * as React from "react"; import {createRoot} from "react-dom/client"; import {ColineAppProvider} from "@colineapp/ui"; import "@colineapp/ui/styles.css"; import "../app.css"; import {Example} from "../examples/${pattern}"; createRoot(document.getElementById("root")!).render(<ColineAppProvider><Example state={new URLSearchParams(location.search).get("state") ?? "ready"}/></ColineAppProvider>);`,
42
+ );
43
+ }
44
+ const seedPath = join(appDir, "preview.seed.ts");
45
+ const hasSeed = await stat(seedPath).then(
46
+ () => true,
47
+ () => false,
48
+ );
49
+ const host = await build({
50
+ entryPoints: [join(sourceRoot, "preview-host.ts")],
51
+ bundle: true,
52
+ write: false,
53
+ format: "esm",
54
+ platform: "browser",
55
+ target: "es2022",
56
+ jsx: "automatic",
57
+ logLevel: "silent",
58
+ define: { "process.env.NODE_ENV": '"production"' },
59
+ plugins: [
60
+ {
61
+ name: "preview-app",
62
+ setup(builder) {
63
+ builder.onResolve({ filter: /^coline-preview-app$/ }, () => ({
64
+ path: join(appDir, "app.config.ts"),
65
+ }));
66
+ builder.onResolve({ filter: /^coline-preview-seed$/ }, () =>
67
+ hasSeed ? { path: seedPath } : { path: "seed", namespace: "preview" },
68
+ );
69
+ builder.onLoad({ filter: /.*/, namespace: "preview" }, () => ({
70
+ contents: "export async function seed() {}",
71
+ loader: "js",
72
+ }));
73
+ builder.onResolve({ filter: /^@colineapp\// }, (args) =>
74
+ args.pluginData?.resolved
75
+ ? undefined
76
+ : builder
77
+ .resolve(args.path, {
78
+ kind: "import-statement",
79
+ resolveDir: appDir,
80
+ pluginData: { resolved: true },
81
+ })
82
+ .then((result) => ({ path: result.path, errors: result.errors })),
83
+ );
84
+ },
85
+ },
86
+ ],
87
+ });
88
+ const client = await buildClientSource(appDir, entry);
89
+ return { host: host.outputFiles[0]!.text, client: client.files[client.entry]! };
90
+ })().catch((error) => {
91
+ cached = null;
92
+ throw error;
93
+ });
94
+ return cached;
95
+ }
96
+ const server = createServer(async (request, response) => {
97
+ const host = request.headers.host ?? "";
98
+ if (!/^127\.0\.0\.1:\d+$/.test(host) && !/^localhost:\d+$/.test(host)) {
99
+ response.writeHead(403);
100
+ response.end("Local preview only");
101
+ return;
102
+ }
103
+ const url = new URL(request.url ?? "/", `http://${host}`);
104
+ response.setHeader("Cache-Control", "no-store");
105
+ try {
106
+ if (url.pathname === "/revision") {
107
+ response.end(String(revision));
108
+ return;
109
+ }
110
+ if (url.pathname === "/") {
111
+ response.setHeader("Content-Type", "text/html");
112
+ response.end(hostHtml);
113
+ return;
114
+ }
115
+ if (url.pathname === "/host.js") {
116
+ response.setHeader("Content-Type", "text/javascript");
117
+ response.end((await bundles()).host);
118
+ return;
119
+ }
120
+ if (url.pathname === "/app") {
121
+ response.setHeader("Content-Type", "text/html");
122
+ response.setHeader(
123
+ "Content-Security-Policy",
124
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'",
125
+ );
126
+ response.end(
127
+ `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script>${scriptSafe((await bundles()).client)}</script></body></html>`,
128
+ );
129
+ return;
130
+ }
131
+ response.writeHead(404);
132
+ response.end("Not found");
133
+ } catch (error) {
134
+ response.writeHead(500, { "Content-Type": "text/plain" });
135
+ response.end(error instanceof Error ? error.message : String(error));
136
+ }
137
+ });
138
+ try {
139
+ await new Promise<void>((resolveReady, reject) => {
140
+ server.once("error", reject);
141
+ server.listen(options.port ?? 0, "127.0.0.1", resolveReady);
142
+ });
143
+ } catch (error) {
144
+ watcher.close();
145
+ throw error;
146
+ }
147
+ const address = server.address();
148
+ if (!address || typeof address === "string") throw new Error("Preview failed to bind.");
149
+ return {
150
+ origin: `http://127.0.0.1:${address.port}`,
151
+ server,
152
+ close: async () => {
153
+ watcher.close();
154
+ server.closeAllConnections();
155
+ await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
156
+ },
157
+ };
158
+ }
159
+
160
+ export async function runPreview(argv: string[]) {
161
+ const port = argValue(argv, "--port");
162
+ if (port && (!/^\d+$/.test(port) || Number(port) > 65535))
163
+ throw new Error("Port must be 0–65535.");
164
+ const preview = await startPreview(resolve(argValue(argv, "--dir") ?? "."), {
165
+ port: port ? Number(port) : 0,
166
+ pattern: argValue(argv, "--pattern") ?? "app",
167
+ });
168
+ console.log(
169
+ `Local preview: ${preview.origin}\nSimulated data. No credentials, uploads, or production writes. Reloading resets fixtures.`,
170
+ );
171
+ const screenshots = argValue(argv, "--screenshots");
172
+ if (screenshots) {
173
+ try {
174
+ const { capturePreview } = await import("./preview-capture");
175
+ return await capturePreview(preview.origin, resolve(screenshots));
176
+ } finally {
177
+ await preview.close();
178
+ }
179
+ }
180
+ return new Promise<number>((resolveExit) => {
181
+ const stop = () => {
182
+ process.off("SIGINT", stop);
183
+ process.off("SIGTERM", stop);
184
+ void preview.close().then(() => resolveExit(0));
185
+ };
186
+ process.on("SIGINT", stop);
187
+ process.on("SIGTERM", stop);
188
+ });
189
+ }
package/src/push.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { buildClientSource } from "./client-build";
1
2
  import { build } from "esbuild";
2
- import { readFile, readdir, stat } from "node:fs/promises";
3
- import { join, relative, resolve } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import { tmpdir } from "node:os";
6
7
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -29,9 +30,6 @@ export interface PushResult {
29
30
  versionId: string;
30
31
  }
31
32
 
32
- const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".css"]);
33
- const IGNORED_DIRECTORIES = new Set(["node_modules", "dist", ".git", ".cache", ".coline"]);
34
-
35
33
  export function resolvePushConfig(argv: readonly string[]): PushConfig {
36
34
  const apiKey = argValue(argv, "--api-key") ?? process.env.COLINE_API_KEY ?? null;
37
35
  if (!apiKey) {
@@ -53,7 +51,7 @@ export function resolvePushConfig(argv: readonly string[]): PushConfig {
53
51
  };
54
52
  }
55
53
 
56
- async function buildLogicBundle(appDir: string): Promise<string> {
54
+ export async function buildLogicBundle(appDir: string): Promise<string> {
57
55
  const result = await build({
58
56
  entryPoints: [join(appDir, "app.config.ts")],
59
57
  bundle: true,
@@ -78,7 +76,7 @@ async function buildLogicBundle(appDir: string): Promise<string> {
78
76
  * (plan §6.2 two-artifact model). The app's SDK version travels inside
79
77
  * the bundle, so the CLI never needs to agree with it.
80
78
  */
81
- async function extractManifest(logicBundleJs: string): Promise<ManifestSummary> {
79
+ export async function extractManifest(logicBundleJs: string): Promise<ManifestSummary> {
82
80
  const dir = await mkdtemp(join(tmpdir(), "coline-app-"));
83
81
  const bundlePath = join(dir, "app.config.mjs");
84
82
  try {
@@ -88,9 +86,7 @@ async function extractManifest(logicBundleJs: string): Promise<ManifestSummary>
88
86
  };
89
87
  const manifest = imported.default?.manifest;
90
88
  if (!manifest || typeof manifest !== "object") {
91
- throw new Error(
92
- "app.config.ts must default-export defineApp(...); no manifest was found.",
93
- );
89
+ throw new Error("app.config.ts must default-export defineApp(...); no manifest was found.");
94
90
  }
95
91
  return JSON.parse(JSON.stringify(manifest)) as ManifestSummary;
96
92
  } finally {
@@ -108,39 +104,6 @@ function manifestHasReactSurfaces(manifest: ManifestSummary): boolean {
108
104
  return tiers.includes("react");
109
105
  }
110
106
 
111
- async function collectClientSource(
112
- appDir: string,
113
- ): Promise<{ entry: string; files: Record<string, string> } | null> {
114
- const entry = "main.tsx";
115
- const entryExists = await stat(join(appDir, entry)).then(
116
- (item) => item.isFile(),
117
- () => false,
118
- );
119
- if (!entryExists) {
120
- return null;
121
- }
122
-
123
- const files: Record<string, string> = {};
124
- async function walk(dir: string): Promise<void> {
125
- for (const item of await readdir(dir, { withFileTypes: true })) {
126
- if (item.isDirectory()) {
127
- if (!IGNORED_DIRECTORIES.has(item.name)) {
128
- await walk(join(dir, item.name));
129
- }
130
- continue;
131
- }
132
- const extension = item.name.slice(item.name.lastIndexOf("."));
133
- if (!SOURCE_EXTENSIONS.has(extension) || item.name.endsWith(".test.ts")) {
134
- continue;
135
- }
136
- const filePath = join(dir, item.name);
137
- files[relative(appDir, filePath)] = await readFile(filePath, "utf8");
138
- }
139
- }
140
- await walk(appDir);
141
- return { entry, files };
142
- }
143
-
144
107
  async function resolveVersion(appDir: string, explicit: string | null): Promise<string> {
145
108
  if (explicit) {
146
109
  return explicit;
@@ -160,13 +123,11 @@ export async function pushAppVersion(config: PushConfig): Promise<PushResult> {
160
123
  const manifest = await extractManifest(logicBundleJs);
161
124
  const version = await resolveVersion(config.dir, config.version);
162
125
  const clientSource = manifestHasReactSurfaces(manifest)
163
- ? await collectClientSource(config.dir)
126
+ ? await buildClientSource(config.dir)
164
127
  : null;
165
128
 
166
129
  if (manifestHasReactSurfaces(manifest) && !clientSource) {
167
- throw new Error(
168
- "The manifest declares react surfaces but no main.tsx client entry was found.",
169
- );
130
+ throw new Error("The manifest declares react surfaces but no main.tsx client entry was found.");
170
131
  }
171
132
 
172
133
  const response = await fetch(
package/src/scaffold.ts CHANGED
@@ -62,6 +62,7 @@ export async function runCreate(argv: string[]): Promise<number> {
62
62
  await rename(shippedGitignore, join(targetDir, ".gitignore")).catch(() => undefined);
63
63
 
64
64
  for (const relative of await walkFiles(targetDir)) {
65
+ if (/\.(png|jpg|jpeg|webp|gif|woff2?)$/i.test(relative)) continue;
65
66
  const filePath = join(targetDir, relative);
66
67
  const content = await readFile(filePath, "utf8");
67
68
  if (content.includes("__APP_KEY__") || content.includes("__APP_NAME__")) {
@@ -79,6 +80,11 @@ Next steps:
79
80
  cd ${target}
80
81
  npm install
81
82
  npm test
83
+ npm run check
84
+ npm run preview
85
+
86
+ Preview uses disposable local data, with no credentials required.
87
+ Read AGENTS.md for design examples and the bundled experimental UI skills.
82
88
 
83
89
  Then create a workspace API key with the apps.write scope in
84
90
  Workspace Settings → API, and push your first version:
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: coline-app-development
3
+ description: Build and debug a Coline App using its public SDK, capability permissions, hosted logic, sandboxed React, and local preview. Use for changes to app data, tools, surfaces, or integration.
4
+ ---
5
+
6
+ # Coline App development
7
+
8
+ Read `AGENTS.md`, `app.config.ts`, and the installed package declarations.
9
+ Do not inspect private Coline repositories or invent capability signatures.
10
+
11
+ 1. Keep logic in `app.config.ts` and the React entry inside `ColineAppProvider`.
12
+ Import `@colineapp/ui/styles.css` followed by `./app.css`.
13
+ 2. Choose persistence deliberately: files for user documents; app collections
14
+ for structured records. Use `useLiveCollection` for collaborative record views,
15
+ `useColineQuery` for bounded other reads, and React state for transient controls.
16
+ 3. Declare the minimum permission and honest tool effect. Validate tool input
17
+ with `zod/v4`; tools use the app-key prefix. Share actions through
18
+ `coline.tools.invoke` rather than reimplementing a tool in the UI.
19
+ 4. Save files with `expectedVersion`. Catch rejected mutations and preserve
20
+ unsaved input. Permission-denied and conflict errors need different recovery;
21
+ never turn a conflict into a silent overwrite.
22
+ 5. Keep network access behind `coline.net.fetch` and the manifest allowlist.
23
+ No direct fetch, credentials, localStorage, or private API routes in the iframe.
24
+ 6. Test logic with `createTestWorkspace(app)`. Use explicit members and
25
+ `clientAs(userId)` for actor cases. Check installed types for query operators,
26
+ pagination, ACLs, and capability argument shapes.
27
+ 7. Run `npm test`, `npm run check`, and local preview. For UI work, also follow
28
+ the adjacent design and review skills. Only use upload commands when in scope.
29
+
30
+ Preview is an in-memory workspace, not a hosted permission/performance test.
31
+ Its native prompt-based pickers are intentionally simple. External service
32
+ calls need explicit test handlers in logic tests; they are not real integrations.
33
+
34
+ When replacing example React state with persistent records, read
35
+ [the live-record recipe](references/live-records.md). It covers envelope data,
36
+ nullable mutation results, draft preservation, pending states, and fixture seeding.
@@ -0,0 +1,53 @@
1
+ # Connect a screen to saved records
2
+
3
+ The manifest needs `storage.app`. Add `realtime.subscribe` for live hints.
4
+ In `ColineAppProvider`, use the public hook:
5
+
6
+ ```tsx
7
+ type Story = { title: string; stage: string; description: string };
8
+ const stories = useLiveCollection<Story>("stories", { limit: 100 });
9
+ // Each envelope has id, data, version, and access. Render record.data.title.
10
+ const visible = stories.records.filter(record => record.data.stage === stage);
11
+
12
+ async function create() {
13
+ setError(null);
14
+ setSaving(true);
15
+ try {
16
+ const record = await stories.insert(
17
+ { title: title.trim(), stage: "Exploring", description },
18
+ { workspace: "write", readers: [], writers: [] },
19
+ );
20
+ if (!record) throw new Error("The story was not saved. Your draft is still here.");
21
+ setTitle(""); // Clear drafts only after success.
22
+ } catch (cause) {
23
+ setError(cause instanceof Error ? cause.message : String(cause));
24
+ } finally { setSaving(false); }
25
+ }
26
+ async function move(id: string, stage: string) {
27
+ try {
28
+ if (!await stories.update(id, { stage })) throw new Error("The story is no longer available.");
29
+ } catch (cause) {
30
+ setError(cause instanceof Error ? cause.message : String(cause));
31
+ }
32
+ }
33
+ ```
34
+
35
+ `title`, `description`, `saving`, and `error` above are ordinary React form state.
36
+ Keep the draft on failure; don't blindly retry a conflict. `stories.pending` is
37
+ a count; `stories.status === "loading"` is initial loading. Show
38
+ `stories.error.message` and offer `stories.refresh()` for read recovery.
39
+ A bounded view's search only covers loaded records. Disclose `nextCursor` or
40
+ implement pagination rather than claiming the entire collection was searched.
41
+
42
+ Seed in `preview.seed.ts` with the direct capability client. Its options shape
43
+ differs from the hook's second argument:
44
+
45
+ ```ts
46
+ await workspace.client().storage.collection<Story>("stories").insert(
47
+ { title: "A calmer workday", stage: "Exploring", description: "A useful story." },
48
+ { access: { workspace: "write", readers: [], writers: [] } },
49
+ );
50
+ ```
51
+
52
+ Preview fixtures reset on reload. Real hosted collections persist; a local
53
+ preview passing does not prove hosted persistence or multi-user permissions.
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: coline-ui-design
3
+ description: Design or improve a Coline App interface using working screen examples, host theme tokens, responsive patterns, and realistic states. Use when building pages, forms, boards, queues, editors, or improving unattractive UI.
4
+ ---
5
+
6
+ # Coline UI design — experimental
7
+
8
+ Make the user's main task obvious before adding decoration. Preserve an existing
9
+ product design when present. This is a starting path, not a mandatory visual style.
10
+
11
+ ## Choose one useful starting point
12
+
13
+ Write three short decisions: who uses this, what they do most, and what belongs
14
+ on the first screen. Then inspect the nearest working example:
15
+
16
+ | Main job | Start from | Preview command |
17
+ | --- | --- | --- |
18
+ | Review many items and act on one | `examples/triage.tsx` | `npm run preview -- --pattern triage` |
19
+ | Move work through stages | `examples/board.tsx` | `npm run preview -- --pattern board` |
20
+ | Configure preferences | `examples/settings.tsx` | `npm run preview -- --pattern settings` |
21
+ | Read/write documents | `main.tsx` | `npm run preview` |
22
+
23
+ Paths are relative to the app root. Reference screenshots live in this skill's
24
+ `assets/` directory. View them if your model supports images. The source examples
25
+ are the complete reference for text-only agents.
26
+
27
+ For a small context budget: copy ONE example, keep its layout and state handling,
28
+ change the domain objects and copy, then connect SDK data. For a distinctive
29
+ product: compose the exported patterns or plain CSS deliberately. Do not force
30
+ an editorial website, calendar, map, or canvas into a queue just because it exists.
31
+
32
+ ## Compose the screen
33
+
34
+ - `AppPage`: title, short description, one main action; wide or reading width.
35
+ - `PageToolbar`: search/filter controls near the content they change.
36
+ - `RecordTable` + `SplitView` + `DetailPanel`: scan, select, inspect, act.
37
+ - `Board` + `BoardColumn` + `BoardCard`: visible stages; explicit move controls
38
+ work with keyboard and touch. These components do not implement drag and drop.
39
+ - `SettingsSection`: separate explanation from labelled fields.
40
+ - `EmptyState`, `AppLoadingState`, `AppErrorState`: useful non-happy paths.
41
+ - `StatusBadge`: restrained semantic status with text, never color alone.
42
+
43
+ See installed `@colineapp/ui` declarations for props. Keep tables semantic; the
44
+ first cell becomes a button when `onOpen` is supplied, so don't nest controls there.
45
+ Move focus to opened details and return it when closing. When a move remounts a
46
+ record in another column, restore focus to that record after rendering (and after
47
+ pending controls are enabled). Keep horizontal scrolling
48
+ inside boards/tables, not on the whole page.
49
+
50
+ Use host tokens and exported controls. `app.css` compiles Tailwind automatically;
51
+ write complete class names or a fixed map, not dynamically assembled utilities.
52
+ Choose a compact consistent spacing scale, readable line lengths, and muted
53
+ secondary text. Use color for meaning. Use one clear control for each action: a stage selector
54
+ does not also need previous/next arrows on every card. Keep controls compact
55
+ without shrinking touch targets or letting field labels stretch adjacent buttons.
56
+ Don't add stat cards, giant greetings,
57
+ gradients, decorative charts, or sidebars unless the user's job calls for them.
58
+
59
+ ## Make it real
60
+
61
+ Replace example state with capabilities before calling the app complete. Seed
62
+ believable names, long titles, and varied statuses in `preview.seed.ts`. Every
63
+ visible action must work; preserve input on errors and show saving/disabled
64
+ states. No pretend search, dead menus, invented metrics, or static success toasts.
65
+
66
+ Run the adjacent `coline-ui-review` skill before finishing. If your model cannot
67
+ inspect images, say so; mechanical checks alone do not establish visual quality.
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: coline-ui-review
3
+ description: Review and finish a Coline App UI with local browser previews, screenshots, keyboard interaction, and light/dark/mobile states. Use after UI changes or when fixing clipping, generic layouts, broken styling, or inaccessible controls.
4
+ ---
5
+
6
+ # Coline UI review — experimental
7
+
8
+ 1. Run `npm test` and `npm run check`. Fix new failures before visual review.
9
+ 2. Run `npm run preview`. Exercise the main action, search/filter, open/close,
10
+ save/discard, and recovery. Tab through the UI; confirm visible focus and
11
+ labelled fields. A screenshot does not prove an action works.
12
+ 3. Install Chromium if needed: `npx playwright install chromium`. Then run
13
+ `npm run screenshots`. For a reference pattern append `-- --pattern triage`
14
+ (or board/settings). Read `.coline/screenshots/report.json`.
15
+ 4. View at least ready desktop light/dark and mobile, plus affected empty/error
16
+ states. Check title/action hierarchy, awkward wrapping, clipped content,
17
+ cramped controls, contrast, and whether repeated cards should be rows.
18
+ Compare against the selected example or the user's visual reference.
19
+ 5. Fix the largest concrete problem, recapture the affected view, and inspect
20
+ again. Keep local scrolling intentional. Long content must not push actions
21
+ off screen. Respect reduced motion; don't animate routine data refreshes.
22
+ 6. Report what was actually tested and any limitation. The screenshot command
23
+ catches browser errors, whole-page overflow, and unlabelled form fields;
24
+ it does not grade beauty, certify accessibility, or prove hosted behavior.
25
+
26
+ The preview's data resets on reload. Its state selector injects capability faults;
27
+ if your page bypasses capabilities or swallows errors, that state may expose a
28
+ bug rather than a useful screen. Example layouts handle state locally to make
29
+ reference states inspectable. Test permissions/persistence on the real host
30
+ separately when that work is in scope.
31
+
32
+ Do not claim visual review if you cannot view the images. Give the image paths
33
+ and explain what remains for someone with vision/browser tools to check.
34
+
35
+
36
+ Keep preview cleanup scoped. The screenshot command closes its own server. For
37
+ interactive QA, retain the terminal session or exact child-process PID you started
38
+ and stop only that process. Never use broad `pkill -f` or `killall` commands: other
39
+ apps may be running previews at the same time.