Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The UI must be delightful, with FigJam as the bar: considered typography, micro-
## Conventions

- **Commits**: small Conventional Commits, `type(scope): what changed, in plain words`, lowercase, no trailing period, no attribution lines (e.g. `fix(stacks): a stack's name is a 16px heading, in proportion with the cards around it`). Work goes straight to `main`, which Workers Builds deploys to production; push a branch only for a Worker Preview or a risky change (such as a board schema change). Run `pnpm check`, `pnpm test` and `pnpm build` (and the site build if `site/` or docs changed) before pushing.
- **Comments**: plain sentences saying what something is and why, in British spelling; a block comment at the top of a module explains its part of the design, and the Yjs schemas are documented as tables at the top of their files (`src/shared/shapes.ts`, `src/shared/widgets.ts`). Cite issues as `(#60)` where they're a decision's history. Point at code, a site docs page or a section of this file, never at a document that may not exist.
- **Comments** are for a future reader of the code, not whoever reviews the change: plain sentences saying what something is and why, in British spelling. A block comment at the top of a module explains its part of the design, and the Yjs schemas are documented as tables at the top of their files (`src/shared/shapes.ts`, `src/shared/widgets.ts`). Anywhere else, a comment earns its place only when the code can't be made clearer, or to give a reason a reader would otherwise get wrong (an ordering constraint, a footgun, an invariant). A comment is never a summary of the change, a message to the reviewer, a justification for a decision ("intentionally", "for safety", "unlike X"), an account of what was tried and rejected, or a restatement of what the code plainly does. Never cite issues, pull requests or review threads, and never number comments: a decision's history goes in its commit message. Point at code, a site docs page or a section of this file, never at a document that may not exist. Tool directives (`oxlint-disable`, `@ts-expect-error`) keep the short reason that says why the rule doesn't apply there.
- **Code**: TypeScript throughout, formatted with tabs. Format, lint and type check are oxfmt, oxlint and tsgolint through `vp check` (no prettier or eslint). Import test APIs from `vite-plus/test`. Pure logic goes in `src/shared/`, so it's unit-tested in Node and shared by the server, the client, the playground and the demo.
- **Testing approach**: unit-test pure logic in Node; test server behaviour through the built Worker in Miniflare (`test/harness.ts`), with fakes for GitHub, other forges, AI and email; use Playwright (`e2e/`) only for what needs a browser. Every new `/play` scenario or gallery state goes in `e2e/play.spec.ts` or `e2e/gallery.spec.ts`, and new galleries in `e2e/a11y.spec.ts`'s list. See "Testing".
- **Parallel agents never run the full e2e suite.** Each run starts its own dev server and browsers, and several at once overload the machine until runs time out and flake. Run `pnpm check`, `pnpm test`, `pnpm --filter site build` and only the specs you touched (`pnpm e2e e2e/display.spec.ts --workers=2`, with your own `E2E_PORT` in a worktree), and stop any dev server you start. The full suite runs once on `main`, and in CI.
Expand Down Expand Up @@ -47,7 +47,7 @@ The UI must be delightful, with FigJam as the bar: considered typography, micro-
- **`cloudflare.config.ts` is the source of truth for the Worker**: name (`NAME`), entrypoint, compatibility, bindings, observability, via `defineConfig` from `cf/config` (no Wrangler dependency), which both `cf` and the Vite plugin read. The entrypoint uses the `with { type: "cf-worker" }` import attribute, which makes `Env` and `exports` inference work: keep that form.
- **Bindings** are declared there (`env: { MY_KV: bindings.kv() }`), and `Env` is inferred from them (`pnpm types`). Durable Object bindings to this same Worker are untyped; `src/server/namespaces.ts` restores the class types. Integration tests declare the same bindings for Miniflare in `test/harness.ts`: keep the two in step.
- **`compatibility.ts`** holds the compatibility date and flags, shared with the test runtime so tests match production. It's a separate file because `cloudflare.config.ts`'s exports are schema-validated.
- **Previews**: the function form of `defineConfig` receives `isPreview` and returns a complete config for both (previews inherit nothing). Previews get no secrets, so code must treat `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN` and `TACK_SECRET` as optional.
- **Previews**: the function form of `defineConfig` receives `isPreview` and returns a complete config for both (previews inherit nothing). Previews get no secrets and no cron triggers (Preview uploads refuse them), so code must treat `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN` and `TACK_SECRET` as optional.
- **Secrets**: one declared with `bindings.secret()` is _required_ on deploy, and deploys keep secrets that aren't declared. So production declares only `TACK_SECRET`; the optional `GITHUB_WEBHOOK_SECRET`, `GITHUB_TOKEN`, `TACK_FORGES` and `TACK_DEV_AUTH` are declared only in `mode === "development"` (so `.dev.vars` can set them) and kept in production when set on the Worker. **Never declare a new optional secret for production**: it breaks every new instance's first deploy.
- **The Deploy to Cloudflare button** reads only `wrangler.jsonc` (generated by `scripts/wrangler-config.ts` with the converter `cf` uses; never edit it by hand), prompts for the secrets in `.dev.vars.example` (`TACK_SECRET` only, described in `package.json`'s `cloudflare.bindings`), and runs `package.json`'s `deploy` script. It deploys the name in `cloudflare.config.ts`. The self-hosting pages (`deploy.mdx`, `configuration.md`, `upgrades.md`) describe it.
- **Version pins that move together**: the `vite@*` and `vitest@*` overrides in `pnpm-workspace.yaml` pin Vite to `@voidzero-dev/vite-plus-core` at the installed `vite-plus` version, and Vitest to what `vp toolchain vitest` reports. Update them whenever `vite-plus` changes. There are no direct `vite` or `vitest` dependencies. The site keeps its own Vite for Astro (`astro>vite`).
Expand Down
7 changes: 3 additions & 4 deletions cloudflare.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,9 @@ export default defineConfig(({ isPreview, mode }) => ({
Instance: exports.durableObject({ storage: "sqlite" }),
Directory: exports.durableObject({ storage: "sqlite" }),
},
triggers: [
// Redeliver failed GitHub webhooks (src/server/github/redeliver.ts).
triggers.scheduled({ schedule: "*/10 * * * *" }),
],
// Redeliver failed GitHub webhooks (src/server/github/redeliver.ts).
// Preview uploads refuse triggers, and webhooks only go to production.
...(isPreview ? {} : { triggers: [triggers.scheduled({ schedule: "*/10 * * * *" })] }),
observability: {
enabled: true,
},
Expand Down
45 changes: 44 additions & 1 deletion e2e/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ test.describe("exporting from the playground", () => {
const dialog = await openExport(page);
await dialog.getByRole("radio", { name: "JSON" }).click();
await expect(dialog).toContainText(/\d+ cards/);
await expect(dialog).toContainText("Comments, history, votes");
await expect(dialog).toContainText("History and staged changes stay here");
const [json] = await Promise.all([
page.waitForEvent("download"),
dialog.getByRole("button", { name: "Download JSON" }).click(),
Expand All @@ -118,6 +118,49 @@ test.describe("exporting from the playground", () => {
const next = await openExport(page);
await expect(next.getByRole("radio", { name: "JSON" })).toHaveAttribute("aria-checked", "true");
});

test("with what people did, when asked, for reading but not importing", async ({ page }) => {
await openScenario(page, "vote");
const dialog = await openExport(page);
await dialog.getByRole("radio", { name: "JSON" }).click();
const include = dialog.getByRole("switch", { name: "Include votes, stamps and comments" });
await expect(include).not.toBeChecked();
await expect(dialog).toContainText("for reading the board elsewhere");
await dialog.getByText("Include votes, stamps and comments").click();
await expect(include).toBeChecked();
await expect(dialog).toContainText("2 votes");
await expect(dialog).toContainText("except in hidden votes");
const [json] = await Promise.all([
page.waitForEvent("download"),
dialog.getByRole("button", { name: "Download JSON" }).click(),
]);
const text = (await saved(json)).toString();
const { activity } = JSON.parse(text) as {
activity: {
votes: { hidden: boolean; closedAt?: string; results?: { by?: unknown[] }[] }[];
items: { title: string }[];
};
};
const [hidden, open] = [
activity.votes.find((vote) => vote.hidden),
activity.votes.find((vote) => !vote.hidden),
];
// Closed, so its results are in, but never who voted for what.
expect(hidden?.closedAt).toBeDefined();
expect(hidden?.results?.length).toBeGreaterThan(0);
for (const row of hidden?.results ?? []) expect(row).not.toHaveProperty("by");
expect(open?.closedAt).toBeUndefined();
expect(open?.results?.[0]?.by?.length).toBeGreaterThan(0);
expect(activity.items.length).toBeGreaterThan(0);
// Importing it leaves what people did behind.
const read = readBoardExport(text);
expect(read.ok && read.data).not.toHaveProperty("activity");
await expect(dialog).toBeHidden();
const next = await openExport(page);
await expect(
next.getByRole("switch", { name: "Include votes, stamps and comments" }),
).toBeChecked();
});
});

test.describe.serial("importing and backing up a real board", () => {
Expand Down
22 changes: 15 additions & 7 deletions scripts/wrangler-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,25 @@ const HEADER = `// Generated from cloudflare.config.ts by \`node scripts/wrangle
// cloudflare.config.ts.
`;

/** wrangler.jsonc as it should be, for production. */
export async function wranglerConfig(): Promise<string> {
/** cloudflare.config.ts as cf reads it, for a production build or a Preview's. */
export async function workerConfig(options: {
isPreview: boolean;
mode: string;
}): Promise<{ worker: Record<string, unknown> }> {
const config = await configModule();
const { result } = await config.loadAndParseConfig(join(ROOT, "cloudflare.config.ts"), {
isPreview: false,
mode: "production",
});
const { result } = await config.loadAndParseConfig(join(ROOT, "cloudflare.config.ts"), options);
if (!result.success) {
throw new Error(`cloudflare.config.ts is invalid: ${JSON.stringify(result.error)}`);
}
const wrangler = config.convertToWranglerConfig(result.data);
return result.data as { worker: Record<string, unknown> };
}

/** wrangler.jsonc as it should be, for production. */
export async function wranglerConfig(): Promise<string> {
const config = await configModule();
const wrangler = config.convertToWranglerConfig(
await workerConfig({ isPreview: false, mode: "production" }),
);
// The converter gives an absolute entrypoint; the file is shared.
if (typeof wrangler.main === "string") wrangler.main = relative(ROOT, wrangler.main);
return `${HEADER}${JSON.stringify(wrangler, null, "\t")}\n`;
Expand Down
16 changes: 14 additions & 2 deletions site/src/content/docs/boards/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,26 @@ Tack remembers your format, scale and background in this browser.

A board's file is its content, not anyone's own part of it:

- comments, history and visits, which aren't part of the board's content
- votes on decisions, dot votes and stamps: who voted for what is theirs
- history and visits, which aren't part of the board's content
- comments, votes on decisions, dot votes, stamps and hands up, unless you [include them](#including-votes-stamps-and-comments)
- stickies still face down in silent mode: their text is their author's until it's revealed
- staged changes, and where cards came from (inboxes, rules, lists)
- cards from repositories you can't read, and the issues action items link to there; the dialog says how many

Cards are their links, so an imported board shows them as they are now on GitHub, to whoever can read them.

## Including votes, stamps and comments

To read a board somewhere else, after a planning session say, or to hand it to a tool or an AI agent, turn on **Include votes, stamps and comments** under JSON. The file then also says what people did on the board, beside its content:

- **Dot votes**: each vote's settings, whether it's closed, how many people voted, and each shape's dots, 👍 reactions counted as votes, total and rank. Like the board, a vote that hides its dots never says who put them where, and has no results in the file until it closes.
- **Decisions' votes**: who agreed and who disagreed.
- **Stamps** and **hands up**: who, on each shape.
- **Comments**: each thread still showing, with its messages, who wrote them, what it's pinned to and whether it's resolved.
- **What the cards show**: each item's title, state, labels, assignees, comment and reaction counts, and for discussions their category and upvotes, as you see them as you export.

It's only ever about what's in the file, so nothing on a card from a repository you can't read, or on a sticky still face down, goes in. The dialog counts what it adds, and Tack remembers the choice in this browser. Importing a file like this makes a board from its content alone: everything it says people did stays behind. Backups never include it.

## Importing

On the home page, choose **New board**, then **Import a board** under **From a file**. Drop a board's `.json` on it, choose the file, or paste its JSON. Tack checks it and shows what's in it; name the board, choose who can open it, and choose **Import board**.
Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/help/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Only where it helps and you can see it: naming [stacks](/boards/stacks/), sortin

## Can I export a board?

Yes: as a PNG or SVG (the whole board, or what's selected), or as JSON to import into a new board, here or on another Tack. A board's owner can also have Tack back it up to a branch on GitHub. See [Export, import and backups](/boards/export/). You can also copy shapes between boards, and pasting them into another app gives plain text: stickies' text and cards' links.
Yes: as a PNG or SVG (the whole board, or what's selected), or as JSON to import into a new board, here or on another Tack, or to read elsewhere with its votes, stamps and comments. A board's owner can also have Tack back it up to a branch on GitHub. See [Export, import and backups](/boards/export/). You can also copy shapes between boards, and pasting them into another app gives plain text: stickies' text and cards' links.

## Is there an API?

Expand Down
Loading
Loading