Files
plotzilla/AGENTS.md
T
2026-09-28 14:21:06 +02:00

28 KiB
Raw Blame History

AGENTS.md

Everything you need to start working on Plotzilla, a Linux screenwriting app (an Arc Studio Pro / Final Draft alternative) that produces scripts in the Hollywood standard format. Read this file first; README.md is the user-facing documentation.

1. At a glance

Backend Go ≥ 1.23, Wails v2 (GTK 3 + WebKitGTK 4.1)
Frontend TypeScript 7, ProseMirror, Vite 8, plain CSS, no UI framework
Target Any Linux desktop (GNOME, Plasma, Xfce…; Wayland and X11), GNOME look (Arch dev machine)
Native format Fountain (.fountain); also FDX import/export and PDF export
License GPL-3.0 (LICENSE); new dependencies must be GPL-3.0 compatible
Language Everything user-facing is English: UI, messages, dialogs, README, docs, comments, commit messages

2. Commands

make            # npm install + vite build + go build → build/bin/plotzilla
make test       # go test ./internal/...
make screenshots # regenerate the README images (needs Firefox and pdftoppm)
make dev        # wails dev (live reload; needs `go install github.com/wailsapp/wails/v2/cmd/wails@latest`)
./build/bin/plotzilla path/to/script.fountain

cd frontend && npm run build     # tsc --noEmit (type check) + vite build → frontend/dist
go vet -tags desktop,production,webkit2_41 ./...
gofmt -l .                       # must print nothing
  • The Go build requires the tag webkit2_41 (the machine only ships WebKitGTK 4.1). Plain go build . fails; always go through make or pass -tags desktop,production,webkit2_41.
  • main.go embeds frontend/dist (//go:embed all:frontend/dist). The frontend must be built before the Go binary. frontend/public/.gitkeep keeps dist/ non-empty on a fresh clone.
  • System deps (Arch): go nodejs npm webkit2gtk-4.1 gtk3. Optional font: ttf-courier-prime. Optional Hunspell dictionaries for spell checking: hunspell-en_us, hunspell-fr…

Definition of done for any change: make succeeds, make test passes, gofmt -l . is empty, npm run build has no type errors, npm audit reports no vulnerabilities, README updated if behaviour or shortcuts changed.

3. Repository map

main.go                 Wails bootstrap: frameless translucent window, LANGUAGE=en, CLI file argument
app.go                  App struct bound to JS: file I/O, dialogs, pagination, autosave recovery
desktop_linux.go        cgo: desktop light/dark preference for GTK dialogs, window button layout, compositing
internal/screenplay     the model: Document{TitlePage, Elements, Settings}, Element{Type, Runs, SceneNumber,
                        Dual, Level, Locked}, Run{Text, b/i/u, Note, Rev}, revision set names
internal/fountain       Fountain parser (parse.go) and writer (write.go), incl. Plotzilla's boneyard data
internal/fdx            Final Draft XML reader/writer
internal/layout         line wrapping + pagination (paper sizes, locked pages); the single source of truth
                        for page geometry
internal/pdf            hand-written PDF 1.4 writer, standard Courier fonts, zero dependencies
internal/spell          Hunspell .aff/.dic reader, checker (compounds too), suggestions, word scan
frontend/index.html     app shell: header bar, navigator, paper, HUD, <dialog>s
frontend/src/main.ts    app controller: state, save/autosave, pagination loop, navigator, menus, shortcuts, window frame
frontend/src/schema.ts  ProseMirror schema + conversion PM doc ⇄ ScriptDocument JSON
frontend/src/commands.ts screenplay keymap (Enter/Tab/Ctrl+1…9), retype, dual dialogue, auto-format
frontend/src/revisions.ts revision marking and locked-page anchors (plugins), lock/unlock/clear
frontend/src/autocomplete.ts suggestion popup (characters, locations, times, extensions, shots, transitions)
frontend/src/decorations.ts live page-break widgets + empty-element placeholder
frontend/src/search.ts  find & replace: query, match highlights, next/previous, replace (all)
frontend/src/spellcheck.ts misspelling underlines (from Go), word under the cursor left alone
frontend/src/menu.ts    popup menus with keyboard navigation (spelling corrections, languages)
frontend/src/backend.ts typed wrappers for window.go.main.App and the Wails runtime
frontend/src/icons.ts   inline Lucide-style SVG icons
frontend/src/style.css  the whole UI theme (dark, blue) and the on-screen page geometry
screenshots/            README images (2880×1800 UI captures + pdf-output.png)
tools/screenshots/      `make screenshots`: sample script, mock server (main.go), Firefox capture (capture.mjs)
VERSION                 current version (1.0)
CHANGELOG.md            Keep a Changelog; sections Added, Changed, Fixed only

4. Architecture

Data flow

ProseMirror doc ──toScript()──▶ ScriptDocument JSON ──Wails──▶ screenplay.Document (Go)
       ▲                                                             │
       └────────fromScript()◀──── OpenResult ◀── fountain/fdx ◀──────┘
                                                                     ├─▶ layout.Paginate ─▶ breaks → editor widgets
                                                                     └─▶ layout.Paginate ─▶ pdf.Render
  • The Go model is canonical. The frontend never parses or writes files itself; it sends the whole ScriptDocument to Go (Save, Paginate, ExportPDF, …). Documents are small enough for this to be cheap; pagination is debounced (300 ms).
  • Pagination lives only in Go (internal/layout). The editor shows page breaks computed by that same code, so the screen and the PDF always agree. Never reimplement wrapping or page rules in TypeScript.

The JSON contract (backend.ts ⇄ internal/screenplay)

ScriptDocument = { titlePage, elements, settings? } Element = { type, runs: [{text, b?, i?, u?, n?, rev?}] | null, sceneNumber?, dual?, level?, newPage?, locked? } Settings = { paper?: '' | 'a4', revisions?: [{name, color, date}], revising?, locked? }

  • runs may be null from Go; always read it as el.runs ?? []. Every settings field may be missing (Go omits zero values).
  • n: true marks a note run (Fountain [[…]]): part of Text() and of every offset, never printed (Element.PrintedText(), printedText() in the frontend). rev: n is the revision set the text belongs to (settings.revisions[n-1]).
  • newPage: true forces a page break before the element (Fountain ===, FDX StartsNewPage). On a dialogue part or a dual dialogue it breaks before the whole block; on an element that prints nothing it moves to the next printed one. Ctrl+Enter (pageBreak in commands.ts) sets it on the block's first element, never on a part.
  • level is a section's depth (from 1). locked: [{offset, page}] lists the locked pages that start in the element (rune offsets, like Break); in the editor they live in the element's locked attribute as UTF-16 offsets (runeOffset/utf16Offset in schema.ts convert).
  • A "\n" inside a run is a hard line break (ProseMirror hard_break node).
  • dual: true on a character element means "this dialogue block sits to the right of the previous one". In the editor this is a nested dual > column column > element+ structure.
  • Break.label / ScenePage.label is the printed page number (12, 12A, 12-13); page is the physical page index. Show the label, compare with page.
  • Element indexes used by Break and ScenePage count elements in flat document order, including empty ones and elements inside dual dialogues. Use flatElements(doc) in the frontend whenever you map an index to a position; never use doc.child(i).
  • Break.offset is a rune (code point) offset. JS strings are UTF-16: convert with utf16Offset() (decorations.ts). Misspelling (element, offset, length) follows the same rules.
  • Any change to these structs must be made in both internal/screenplay/screenplay.go and frontend/src/backend.ts, in the same change.

Element types

scene, action, character, parenthetical, dialogue, transition, shot, intertitle, centered, section, synopsis (Ctrl+1…9 for the first nine, in that order; the order of ELEMENT_TYPES in schema.ts defines the shortcuts). Sections and synopses are outline elements: not printed (ElementType.Printed() / isPrinted()), typed with # / = (auto-format).

Checklist to add or change an element type (the compiler catches the Record<ElementType,…> maps, but not the rest):

  1. internal/screenplay: constant, Uppercase() / InDialogue() / Printed() if relevant.
  2. internal/layout: specs (left inch, width chars, blank lines before), x() alignment, keep-with-next / splittable rules.
  3. internal/fountain: parse rule and writer rule (Fountain has few types; document any lossy mapping in README "Formats").
  4. internal/fdx: typeNames and the reader switch.
  5. Tests in fountain_test.go, fdx_test.go, layout_test.go.
  6. frontend/src/backend.ts union, schema.ts ELEMENT_TYPES + LABELS.
  7. commands.ts: NEXT_ON_ENTER, RETYPE_ON_EMPTY_ENTER, TAB, SHIFT_TAB, auto-detection.
  8. autocomplete.ts if it has suggestions; style.css .el-<type> + --t-<type> color.
  9. README shortcut table and format table; tooltip in index.html if the Ctrl+N range changes.

Screenplay format rules (do not break)

US Letter, Courier 12 pt = 10 chars/inch, 6 lines/inch, 54 body lines per page, 1" top/bottom, 1.5" left margin. Indents (inches from page edge / width in chars): scene & action 1.5/60, character 3.7/38, parenthetical 3.1/25 with 1-char hanging indent, dialogue 2.5/35, transition right-aligned at 7.5", dual columns at 1.5" and 4.5". Pagination: scene headings and inserts are kept with the next two lines; action and dialogue split only at sentence ends with ≥ 2 lines on each side; never end a page on a parenthetical; split dialogue gets (MORE) / NAME (CONT'D); dual dialogue is never split; title page and page 1 are unnumbered; sections, synopses, notes and lines holding only a note are not printed. A4 (layout.PaperGeometry) keeps every margin and width: the page is 8.27" wide and holds 58 lines; right scene numbers move from 7.75" to 7.6". Revision asterisks go at 8.1" (A4: 7.95"), a revised page's header (Pink Revision - 09/28/26) at 1.5" on the page-number line; a page is starred only for its latest revision. Locked pages (settings.locked + Element.Locked): each locked page starts where its anchor is; overflow makes A pages (12 → 12A → 12B), a page left empty is merged into the previous one (12-13), an anchor inside a dialogue block cuts it with (MORE) / (CONT'D), an anchor inside a dual dialogue starts the page above the whole block. The editor CSS reproduces the same geometry with in and ch units (1ch = 0.1in), the page width through --page-width (.paper[data-paper='a4']): if you change a spec in layout.go, change style.css too (and vice versa).

Frontend state and saving

  • app in main.ts holds path, dirty, revision, saving, draftSaved, titlePage, settings, layout, preferences. The ProseMirror doc is the source of truth for the script body; the title page lives in app.titlePage and the production settings (paper, revision sets, marking, locking) in app.settings. Settings are not editor transactions: after changing one, call settingsChanged() (marks the script changed, repaginates).
  • Revisions (revisions.ts): revisionPlugin marks, in an appended transaction, the ranges each change inserted (a deletion marks its neighbour); it skips undo/redo (isHistoryTransaction) so undo removes text and mark together. lockPlugin maps every locked-page anchor through each change and rewrites the locked attributes; lock, unlock and clearing marks are built by lockPages, unlockPages, clearRevisionMarks. Their own transactions carry the INTERNAL meta so the plugins never react to each other. Locking and unlocking are not undoable (addToHistory: false), like the setting they go with.
  • The revision mark is not inclusive (typing next to revised text is not revised unless marking is on); the note mark is, like bold. Hard breaks carry note/revision marks only.
  • The frontend owns the dirty flag and pushes it to Go with SetDirty. Go's Save must not reset it: an autosave can finish after newer edits (revision guards this).
  • Every change goes through markChanged() (revision++, dirty, schedule autosave).
  • Autosave (1.2 s idle): writes the real file when there is a path, otherwise ~/.local/share/plotzilla/recovery.fountain (offered back at launch, removed on explicit discard or first save).
  • Spelling is checked in Go (SpellCheck, 500 ms after the last change, stale results dropped like pagination); the frontend only draws underlines and menus. Dictionaries are the system's Hunspell files, loaded lazily per language; the personal dictionary is ~/.local/share/plotzilla/dictionary.txt. Compound words are tried only when the word itself is unknown (Dictionary.accepts): parts need COMPOUNDFLAG or the BEGIN/MIDDLE/LAST flag of their position; the first part may take a prefix, the last a suffix, any other affix needs COMPOUNDPERMITFLAG in its continuation classes. Suggestions do not build compounds.
  • Find & replace is frontend-only (search.ts): matches are computed on blockText, where one character is one document position. Replacements go through replaceText() (commands.ts), which keeps the marks, and each one is its own undo step (closeHistory).
  • Closing: OnBeforeClose in Go returns "prevent" while dirty and emits confirm-close; the frontend asks with its own dialog and calls runtime.Quit() after SetDirty(false).

Window and desktop integration

  • The window is frameless and translucent; the top bar is a GNOME-style header bar on every desktop (--wails-draggable: drag, every interactive child no-drag, double-click toggles maximise). body draws the 12 px rounded corners; body.maximized removes them, and so does body.opaque (no compositor, hence no alpha channel: rounded corners would show black wedges); body.backdrop dims the bar when unfocused.
  • Nothing may depend on GNOME being the desktop. Read desktop settings through GTK or the XDG portal, never with gsettings/org.gnome.* alone (they hold GNOME's defaults, not the user's choice, on other desktops). WindowFrame() returns GTK's gtk-decoration-layout (each desktop feeds it: GNOME/Plasma settings, XSettings on Xfce/MATE/Cinnamon, settings.ini) and whether the window is really translucent; the frontend lays out the buttons from it and ignores entries it does not draw (menu, appmenu, icon, spacer). The light/dark preference for native dialogs comes from the portal's org.freedesktop.appearance color-scheme, with GNOME's color-scheme as the fallback when no portal answers; "no preference" leaves GTK's configured theme alone.
  • Never use Wails runtime.MessageDialog for questions: on Linux it ignores custom buttons and shows GTK stock Yes/No/OK translated into the system language. Use messageBox() in main.ts (supports destructive).
  • main.go sets LANGUAGE=en so GTK's own strings (file chooser) are English.
  • desktop_linux.go must only touch GTK through g_idle_add (main loop); Wails callbacks run on other goroutines. query_window_frame shows how to get an answer back synchronously (refcounted request + GCond with a timeout); never call it from the main thread.

5. Testing

  • Go: unit tests live next to each package (make test). Add a test for every parser, writer or pagination change; round-trip tests (Parse(Write(doc)) == doc) protect formats.
  • PDF visual check: PLOTZILLA_PDF=/tmp/x.pdf go test ./internal/pdf, then pdftoppm -png -r 60 /tmp/x.pdf /tmp/page and look at the image, or pdftotext -layout /tmp/x.pdf - to check columns.
  • UI without the Go side: serve frontend/dist over HTTP with a small script defining window.go.main.App (async stubs returning sample data) and window.runtime, injected before the module script; screenshot with firefox --headless -no-remote -profile <tmpdir> --window-size=1440,900 --screenshot out.png URL. Disable animations in that page (*{animation:none!important;transition:none!important}) or the capture happens mid-fade. Firefox is not WebKitGTK: confirm anything engine-specific (zoom, drag, dialogs, fonts) in the real app with make dev.
  • Screenshots (screenshots/): regenerate them with make screenshots whenever the UI changes visibly, and keep the README references in sync (a new image: add its steps to tools/screenshots/capture.mjs). The run is deterministic: unchanged UI gives identical pixels. How it works:
    • tools/screenshots/main.go serves frontend/dist with a window.go.main.App proxy posting each call to /api/<Method>, answered by the real fountain, layout and screenplay packages, so pagination, locked pages and revision sets are real. The sample script is tools/screenshots/the-last-reel.fountain; ?v=typos and ?v=outline serve variants (the misspellings projecter, thraeds, wacth for spelling.png, whose suggestions are fixed, so no dictionary is needed; sections, synopses and a note for outline.png and revisions.png). Save writes nothing; revision sets have fixed dates.
    • capture.mjs drives Firefox through puppeteer-core (WebDriver BiDi) at 1440×900, device scale 2, with animations, transitions and the caret disabled and <html spellcheck="false"> (WebKitGTK does not spell-check; Firefox does). It moves the pointer off the header before each capture (tooltips).
    • BiDi cannot omit the background, so main.go masks the 12 px window corners (radius 24 px at scale 2), and renders pdf-output.png: the PDF export of the sample with scene numbers, pdftoppm -r 110, pages side by side on the --bg color.
    • FIREFOX=/path/to/firefox picks another browser binary; SCREENSHOTS_FLAGS="-out dir" writes the images elsewhere (to compare before replacing).
  • Behaviour that only exists in the real window (drag, resize, GTK dialogs, close flow, autosave timing) must be checked by running ./build/bin/plotzilla; say so when you could not.

6. Go best practices (this repo)

  • gofmt everything; go vet clean with the build tags. Standard library first: the PDF writer and all formats have no third-party dependency, keep it that way unless there is a strong reason.
  • Business logic lives in internal/… packages that know nothing about Wails; app.go is a thin adapter (dialogs, file system, JSON-friendly return types). Keep packages free of GUI imports so they stay testable with plain go test.
  • Bound methods: exported methods on App become JS APIs. Return (T, error) and let the frontend handle the error; return nil/"" (not an error) when the user cancels a dialog.
  • Wrap errors with context (fmt.Errorf("reading FDX: %w", err)), lower-case, no trailing punctuation. Never panic on user input; malformed files degrade to best effort.
  • Write files atomically (writeAtomic: temp file in the same directory + rename).
  • Text is Unicode: iterate with runes, use screenplay.UpperRunes when offsets must survive upper-casing (strings.ToUpper("ß") changes length). Offsets exchanged with the UI are runes.
  • Prefer small pure functions and value types; avoid package-level mutable state (the specs tables are read-only). Use min/max builtins, strings.Builder, bytes.Buffer.
  • Comments explain why (format rules, GTK quirks), not what; doc comments on exported identifiers start with the name.
  • cgo is confined to desktop_linux.go behind //go:build linux.

7. TypeScript best practices (this repo)

  • strict mode, noUnusedLocals; npm run build must type-check cleanly. No any: use unknown and narrow, or a precise type. Use import type for type-only imports.
  • No framework: DOM APIs, small modules, one responsibility per file. Query elements once and keep references; build DOM with createElement/textContent. Use innerHTML only for static trusted markup (icons); never for script text, file names or error messages.
  • ProseMirror rules:
    • All document changes go through transactions (view.dispatch(tr)); never mutate DOM inside the editor. Build one transaction per user action so undo is a single step; set addToHistory: false for non-content metadata (page-break decorations).
    • Commands follow the (state, dispatch) => boolean contract: return false when not applicable, do nothing if dispatch is undefined.
    • When positions change inside a transaction, map them with tr.mapping.map() instead of recomputing from the old document.
    • Positions are UTF-16 offsets; convert Go rune offsets explicitly.
    • Plugin order matters: autocomplete before the screenplay keymap, keymaps before baseKeymap.
  • Async calls to Go can resolve after the document changed: compare against a captured doc or revision before applying results (see paginate() and autosave()).
  • Handle every promise: await inside try/catch, or .catch(reportError | console.error).
  • localStorage only for per-user preferences (zoom, panel, autosave, export options), always through the load/store helpers (they swallow storage errors).
  • Keyboard shortcuts: global ones in the shortcuts map (capture phase, skipped while a dialog is open); editing ones in screenplayKeys(). Update the README table and tooltips together.

8. CSS best practices (this repo)

  • One stylesheet, organised in commented sections (topbar, menu, main, hud, paper, page break, autocomplete, dialogs). Plain CSS, no preprocessor.
  • Design tokens only: colors, radii, easing and fonts come from the custom properties in :root (--bg, --surface, --text, --accent, --t-<element>…). Do not hard-code a new color in a rule; add a token. The theme is intentionally always dark, blue-dominant.
  • Screen geometry of the page uses physical units (in, pt, ch) that mirror internal/layout; UI chrome uses px. Do not mix them.
  • Class names are short, kebab-case, component-scoped (.nav-head, .win-button); state is a class on body (.focus-mode, .no-navigator, .maximized, .backdrop) or a data-* attribute ([data-state='saved']), never inline styles except computed positions.
  • Animate only opacity and transform/translate with var(--ease); keep durations ≤ 350 ms; every hover state has a matching transition. Respect focus visibility (:focus-visible outlines on buttons).
  • Custom properties inherit, which the page-break widget relies on (--indent per element type) and so does window dragging (--wails-draggable). Remember both when nesting new elements in .topbar or .el.
  • Target WebKitGTK: -webkit- scrollbar pseudo-elements plus standard scrollbar-* properties; zoom is used for editor zoom; backdrop-filter is fine. Avoid features WebKit lacks.

9. Gotchas already paid for

  • go:embed fails if frontend/dist is missing → run make, not go build.
  • GTK stock buttons follow the system locale (fr_FR here) → in-app dialogs + LANGUAGE=en.
  • GTK 3 ignores the desktop's dark preference → desktop_linux.go (portal, then GNOME fallback).
  • GTK reports GNOME's appmenu button as menu; both are ignored by the header bar.
  • gsettings exists on every desktop that has GTK 3, but outside GNOME it returns GNOME's defaults: it is not a way to read the user's desktop settings.
  • Fountain cannot express insert/shot or intertitle types: shots round-trip only when they start with a known prefix (INSERT, CLOSE ON, POV…); all-caps centred text is read as an intertitle. Documented in README; keep the parse and write rules symmetric.
  • Fountain has no revisions, locks or paper size: they are boneyard comments, which other apps ignore. Inline markers travel with the text (/*rev 2*/…/*/rev*/, /*page 12*/), settings are one /* Plotzilla {json} */ block at the end (*/ inside JSON is written *\/). The parser first replaces notes and markers by tokens (U+E000 payload U+E001, no line break) so a note spanning lines keeps its paragraph; line rules test bare(line) (tokens removed) and inline turns tokens back into runs and locks. Writers must put Fountain's force prefixes (., @, !, >) before any marker. A script without these features must write byte-identical output to before (TestPlainScriptUnchanged).
  • The Fountain writer skips empty elements: their locked pages and forced page break are carried to the next element written (layout does the same for elements that print nothing).
  • Final Draft has no sections or synopses: the FDX writer stores them as ScriptNotes whose text starts with # / =, attached (Range 0,0) to the next paragraph, or to an empty trailing Action paragraph at the end; the reader turns such notes back into elements. An ordinary note starting with #, = or \ is written with a leading \, removed on reading. Revised text is <Text RevisionID="n">, the sets a <Revisions> block named Blue Revision - date. The exact Final Draft schema for these (ScriptNote placement, Revisions attributes) was written from memory, not checked against Final Draft itself.
  • COMPOUNDRULE parts are bare dictionary words (no affixes), as in Hunspell; the first COMPOUNDRULE line is the rule count.
  • A literal BOM or other invisible character in Go source breaks go vet; write "".
  • npm blocks install scripts (esbuild postinstall); the build still works with the bundled binary.
  • Vite empties dist/ on each build; placeholders belong in frontend/public/.
  • Floating layers over the script (menus, suggestions, HUD) use the opaque --popover token: translucent backgrounds let the script text bleed through.
  • A position: fixed popup anchored to the text must follow scrolling (see reposition in autocomplete.ts).
  • Hidden-but-hoverable UI (focus mode) must stay in place with opacity: 0; moving it off-screen with a transform also moves its hover area.
  • The dev shell is zsh: unquoted variables are not word-split and an unmatched glob is an error.
  • pkill -f <pattern> also matches the shell running it when the pattern is in its command line (it kills your own command, exit code 144); use pkill -x <name>.
  • Firefox gives a synthetic ClipboardEvent an empty clipboardData: test paste with navigator.clipboard.writeText + a real Ctrl+V.

10. Git and commits

  • Commits are atomic: one logical change per commit, with the code, its tests and its documentation together. Every commit must build (make) and pass make test, so any commit can be checked out or reverted on its own. Do not mix a fix with a feature or a reformat.

  • Messages are in English and follow this convention:

    Prefix Use for
    feat: a new feature or a user-visible improvement
    fix: a bug fix
    chore: changelog and maintenance (dependency updates, build and project configuration)
    ci: anything that touches CI
    test: changes to tests only
    docs: README, AGENTS.md and other documentation
  • Summary line: <prefix> <what the commit does>, imperative mood, lower case, no final period, 72 characters at most (feat: add dual dialogue, fix: keep autocomplete popup under the caret when scrolling). Leave a blank line, then explain why in the body when it is not obvious.

  • A feature commit includes its tests; use test: only for commits that change tests alone. Screenshot updates go with docs:.

  • Never commit build output (build/bin, frontend/dist except .gitkeep, frontend/node_modules, frontend/wailsjs); .gitignore covers them.

11. Roadmap (not implemented)

Character/location reports, recent files, locked pages and paper size in FDX. Keep new features consistent with the rules above: Go owns formats and layout, the frontend owns interaction.