Skip to content

ZettelFlow constitution

The non-negotiables every story and change must honor. This is the top gate of augmented coding: a story or a pairing step that violates a principle is rejected when it is reviewed, not at merge. It restates — it does not replace — the conventions in CLAUDE.md and the scoring rules in Obsidian review & scoring.

Amend this file only through a story of its own. Keep it short; if a rule needs a paragraph of nuance, link the doc that owns it.

I. The score is a release gate, not a nice-to-have

Obsidian runs an automated review on every version and publishes a 1–100 quality score. No change may lower it. A story that could trip an eslint-plugin-obsidianmd rule says so in its acceptance criteria. npm run lint:obsidian is the ground truth; a change that adds new violations is not Done.

II. Test-first (TDD is the implementation contract)

Behavior changes and bug fixes land with a test that failed before the change and passes after (the pair and tdd skills): one test, red, then the least code that turns it green. No new unit of pure logic ships without a test. A committed test is never edited, skipped or deleted to get green — changing a test is its own step, decided by the navigator. The blocking guardrails (npm run verify = typecheck + oxlint + jest) are green before every push.

III. Go through the facades

Obsidian access goes through the ObsidianApi facade and the Vault API — never global app, never the Adapter API when a Vault call exists. DOM is built with createEl/createDiv/ createSpan and cleared with el.empty() — never innerHTML. Styling is CSS classes via c() + SCSS partials — never inline el.style.*. Logging is log — never bare console.*.

IV. One product surface, two locales, sentence case

User-facing strings live in the i18n layer (architecture/lang/) in sentence case, with keys present in both en.ts and es.ts. Commands don't repeat the plugin id/name or the word "command" and ship no default hotkeys. Settings headings use setHeading() and don't contain the word "settings".

V. Cross-platform by default

The plugin is isDesktopOnly: false. Node/Electron APIs (fs, path, child_process, electron) are gated behind Platform.isDesktop or the change sets isDesktopOnly. No regex lookbehind, no globalThis, use activeWindow/activeDocument and window.setTimeout.

VI. Defensive against Obsidian internals

Code that touches undocumented internals — above all the Canvas monkey-patcher (architecture/plugin/canvas) — guards every access with existence/shape checks, degrades gracefully with a user Notice when internals change, and uninstalls its patches on unload.

VII. Disclose capabilities honestly

The plugin's capabilities (file-system access, opt-in read-only fetches from the static community gallery, script execution) are disclosed in the README/docs. A change that adds a new capability discloses it in the same change. No hidden telemetry, no remote code execution, no auto-updating code.

VIII. Documentation ships with the change

When a change alters behavior or a public surface, the matching docs/ page (and mkdocs.yml nav) is updated in the same change. Stories and their status live in GitHub Issues.

IX. Small, conventional, one story per branch

Conventional Commits (feat(scope):, fix:, docs:), no AI co-author trailer. A story is one branch and one pull request, a vertical slice that works when it merges. Every step the navigator accepts is a commit, with its tests green; the PR is squash-merged, so main reads one commit per story. Commits reference the issue ((#N)); they do not close it.

X. Issues close on merge, never from a feature branch

An issue is Done only when its fix is on main. Close issues through the pull request that merges the branch — put Closes #N (one per addressed issue) in the PR body, so merging to main closes them automatically. Never gh issue close from a feature branch and never rely on a commit message to close an issue (feature-branch commits don't trigger auto-close, and closing before merge marks work shipped that isn't). "Issue closed" means "listed in the PR's Closes set", realised when the PR merges.

XI. Consolidation over expansion — the transformation gate

The manifesto is the design gate: ZettelFlow does not manage notes; it manages the processes that transform knowledge. Every change answers "what knowledge transformation does it enable?" — if it does not transform, connect, evaluate, discover or advance knowledge, it is secondary.

Three invariants make this enforceable, not aspirational:

  • No net complexity. A change removes or merges surface (a command, view, setting, path) or hardens a boundary — it does not only add. Consolidation is the default; new top-level surface is the exception that must justify itself against this gate.
  • No visible breakage (consolidate & hide). Existing .zftemplate systems keep loading, saved settings keep working, and no command or view disappears without an alias.
  • Knowledge is offline and platform-free. The Knowledge layer (architecture/knowledge, and the future knowledge/ home) never imports obsidian and makes no network/AI call. Derived metrics are queries over the model, not invented dashboard features — metrics are consequences, not inventions.

XIII. Configuration is a product surface, not a fallback

The manifesto's power you can reach: a capability is authorable from the interface that owns it, with working defaults. A feature whose only authoring path is a hand-edited YAML/JSON block is not shippable, however well documented.

Three consequences a reviewer can check on a diff:

  • The authoring control ships with the field. A new step/flow capability adds its Setting in the step editor (or its settings row, or its modal field) in the same change that adds the data field.
  • The form is complete. It can express every field the engine reads. A half-authorable capability is rejected: the missing half is precisely what drives people back to the file.
  • The file stays valid. What the form writes is what a .zftemplate carries, so hand-authoring remains possible for those who want it — as an escape hatch, not the front door.

Recorded debt rather than hidden debt: onCreation (#170) is still YAML-only and predates this rule. It is the one exception on the books, and it is a defect, not a precedent.

XII. Never write a judgement the user did not make

The manifesto's meta-principle is cognitive agency: ZettelFlow removes mechanical work and protects cognitive work. Mechanical output — a gathered list, a derived metric, a structural projection — may be written freely. Interpretive output — an interpretation, conclusion, counterargument, synthesis or proposed connection, whether produced by AI or by a heuristic — reaches the vault only through an explicit human verdict (accept / modify / reject). Machine text is proposed, never committed.

Two consequences a reviewer can check on a diff:

  • No silent interpretive write. An action that produces interpretive output and writes it with no decision point is rejected — inside a wizard build and inside an automation. The AI gate (#301) bounds cost and automation; it does not grant agency.
  • The verdict is data, not just an effect. A human decision is recorded (the judgement record), so cognitive agency stays a consequence of the model like every other metric (§XI) — never an invented score.

Deliberate friction — asking for the user's reading before revealing the system's — is a design tool, not a tax: applied only where judgement is genuinely at stake, never as a generic confirmation dialog. It is the opposite of the operational friction removed by the friction audit; §XI still applies, so a friction step must not add net surface.

XIV. A change ships with a way to see it work

Every refined story ends with a How to verify section, and it is the last one for a reason: a change whose outcome cannot be seen has not been described. It carries two halves.

  • Automated — a command → the criteria it proves table. npm run verify is the floor, not the answer; name the test file that proves each AC, plus npm run lint:obsidian and npm run test:perf where they apply.
  • By hand — a script a stranger can walk in a real vault: the preconditions (and how to reach them), numbered steps with one observable expectation each, the empty state, and the negative (nothing written, no layout change — §XII and §XIII are checked here).

Three consequences a reviewer can check on a diff:

  • Every acceptance criterion has a prover. Automated, or a numbered step. An AC nobody can check is not an AC, and the story names its prover when it is refined.
  • Manual is a decision, not a default. A step that stays manual names the reason in one clause. This repo accepts four: a WebGL scene, a camera flight, an Obsidian view lifecycle, a real vault's shape. It does not accept "hard to test" — and a manual step that turns out to be automatable becomes a test, in the same change.
  • The script has been walked. Declaring an implementation done includes having followed it end to end and corrected it where it no longer matched reality. A script nobody walked is worse than none, because it will be trusted.

Written for the person verifying, in their vocabulary — the command as the palette shows it, the surface title, the chip label. Never a symbol name, never a file path.

XV. The user's theme wins

ZettelFlow has no look of its own, and that is a decision rather than an omission: a vault runs Minimal, or Things, or a snippet the user wrote last Tuesday, and we do not know which. Every colour we hardcode survives their choice; every pixel we invent is one their theme cannot reach. Obsidian says it plainly — hardcoded styling "makes it impossible to modify with themes and snippets", while its variables mean a plugin "will look great even if the user has a different theme".

The full guide, with the citations and the variable families, is styling: the user's theme wins. Five consequences a reviewer can check on a diff:

  • No hex, no rgb(), no named colour in a stylesheet. Colour comes from --interactive-*, --background-*, --text-*. The one exemption is the 3D graph's fixed dark background, documented at the palette that needs it.
  • No pixel the 4-grid can express. padding: 8px is var(--size-4-2). A real pixel — a hairline, a sprite, a WebGL dimension — says why in a comment.
  • Nothing styled from JavaScript: no el.style.*, no innerHTML, a class always, because a class is what a snippet can reach (already enforced by lint:obsidian).
  • Obsidian's own classes before a new one: mod-cta, mod-warning, clickable-icon, setting-item, is-active. A button that looks like Obsidian's button is Obsidian's button.
  • A shape that exists twice is a mixin, in src/styles/utils/mixins.scss. A partial that redefines a chip, a card, a row, a hint or an empty state is drift.

The cost is that the plugin cannot look like itself. That is the point: the look belongs to the user, and legibility inside their choice is the whole job.

XVI. One continuous shot

The interface is one plano secuencia (manifesto): moving between places is a camera move, never a cut. Born in the Library and the Reader (their camera, readerShot.ts, and the physical chapter change, readerTurn.ts, are the reference), it applies wherever one view becomes another. Six consequences a reviewer can check on a diff:

  • No cut between related views. When one thing becomes another view (a cover becomes the reader, a card becomes its note), the element travels and becomes it; a fade-out/fade-in swap, or a new tab where the same leaf could carry the shot, is a cut.
  • Every entrance has an origin, every exit a destination, and the reverse gesture plays the reverse shot: closing the Reader returns the camera to the book on its shelf.
  • One gesture at a time. A transition is one camera move with continuous velocity — no hold in the middle, no several entrances running in parallel.
  • Physical, not decorative. Motion follows the material (a page turns, a sheet slides, text keeps flowing in the reading direction). A blur or a cross-dissolve is not a transition.
  • Cheap. transform and opacity only, on the shared motion tokens (src/styles/utils/motion.scss); opacity never on a 3D parent (it flattens the scene).
  • Never in the way. Input during a shot jumps it to its end, and under reduced motion every shot is instant — the place is still continuous, only the movement is gone.