create-coline-app 2.3.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 +26 -2
  2. package/README.md +51 -4
  3. package/assets/preview-host.ts +130 -0
  4. package/bin/_run.mjs +11 -2
  5. package/package.json +8 -4
  6. package/src/check.ts +46 -0
  7. package/src/cli.test.ts +112 -6
  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 +30 -64
  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 +70 -158
  23. package/templates/backendless/CLAUDE.md +1 -6
  24. package/templates/backendless/README.md +22 -29
  25. package/templates/backendless/app.config.ts +12 -8
  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 +198 -66
  31. package/templates/backendless/package.json +10 -5
  32. package/templates/backendless/preview.seed.ts +26 -0
@@ -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:
@@ -1,70 +1,36 @@
1
1
  ---
2
2
  name: coline-app-development
3
- description: Build and debug a Coline App using the public SDK, hosted capabilities, tree UI, and sandboxed React UI.
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
4
  ---
5
5
 
6
6
  # Coline App development
7
7
 
8
- Use this skill for any task that changes a generated Coline App.
9
-
10
- ## Start from the shipped contract
11
-
12
- Read the repository `AGENTS.md`, `app.config.ts`, `main.tsx`, `app.test.ts`,
13
- and `package.json`. The generated project is intentionally self-contained.
14
- Do not inspect `/Users/radin/coline-app`, private worktrees, first-party apps,
15
- or product internals to learn an app-authoring API. Use the installed package
16
- types, README files, and this skill.
17
-
18
- The project has two halves:
19
-
20
- - `app.config.ts` is the hosted logic bundle: manifest, permissions, tools,
21
- file types, tree handlers, and capability calls.
22
- - `main.tsx` is the React client entry. The starter includes `@colineapp/ui`,
23
- React, ReactDOM, and the CSS export; do not add a second bridge or fetch
24
- data from a Coline API route.
25
-
26
- ## Choose the right surface
27
-
28
- - Use `ui.*` tree nodes for previews, inline surfaces, mobile fallbacks, and
29
- simple Kairo cards.
30
- - Use a React surface when the app needs local interaction, filtering, forms,
31
- or an editor. Declare `{ tier: "react", entry: "main.tsx" }` and keep the
32
- root wrapped in `<ColineAppProvider>`.
33
- - Use `useColine()` for capability calls and `useColineQuery()` for a bounded
34
- loading/error/refetch loop. Never use `fetch`, localStorage, cookies, or
35
- credentials in the sandbox.
36
-
37
- ## Public SDK rules
38
-
39
- - Import logic from `@colineapp/sdk/v2` and tests from `@colineapp/sdk/testing`.
40
- - Prefix every tool name with the app key.
41
- - Validate inputs with `zod/v4` and declare honest effects (`read`, `write`,
42
- `destructive`, or `external`).
43
- - Ask for the minimum permissions. Capability calls still require both the
44
- declared permission and the tool effect ceiling.
45
- - Collections are typed record envelopes. `where` is exact-match on top-level
46
- fields; query bounded pages and filter more complex predicates in memory.
47
- - Use `ui.*` for hosted handlers. Chat tool cards must stay within the
48
- validated chat subset and remain small.
49
-
50
- ## UI rules
51
-
52
- - Import components from `@colineapp/ui` and `@colineapp/ui/styles.css`.
53
- - Use the host theme tokens (`bg-background`, `text-foreground`,
54
- `text-muted-foreground`, and related tokens); dark mode is supplied by the
55
- host.
56
- - Render explicit loading, empty, and error states.
57
- - Keep components accessible and use the provided controls before writing
58
- custom primitives.
59
- - Do not add a state library or network client for the first implementation.
60
-
61
- ## Verify before pushing
62
-
63
- ```sh
64
- npm run typecheck
65
- npm test
66
- ```
67
-
68
- Then push with `npx coline-app dev --internal` or `npx coline-app push --internal`
69
- when the user has supplied `COLINE_BASE_URL` and `COLINE_API_KEY`. The local
70
- test workspace is the first proof; the hosted app is the second.
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.