# FilmOpen Software Specification

**The desktop application that reads, browses and (later) edits FilmOpen projects.**

| | |
|---|---|
| **Version** | 1.2 |
| **Date** | 10 September 2026 |
| **Status** | Describes the code at the end of milestone 3 |
| **Companion documents** | *FilmOpen Project Specification 1.5* (`docs/FilmOpen-Project-Specification v1.0.md`) defines the file format this software reads and writes. *FilmOpen — Milestone 1*, *2* and *3* (`docs/FilmOpen-Milestone *.md`) record what was built, why, and what comes next. |
| **Repository** | `filmopen-c`, branch `round-03-c` (milestone 3), on top of `round-02-c` (milestone 2) and `c` (milestone 1) |

---

## Contents

1. Purpose
2. Design principles
3. Technology
4. Architecture
5. The model layer
6. Services: where files come from
7. State: providers
8. The tree
9. The user interface
10. Localisation
11. The sample project
12. Testing
13. Conventions for contributors
14. Known limitations
15. Third-party code
Appendix A — File map
Appendix B — Glossary of code names

---

## 1. Purpose

A FilmOpen project is a folder of plain JSON files describing a film completely: characters, outfits, locations, props, styles, documents; the script as seasons, episodes, sequences and scenes with their blocks; shots and cues; models, platforms and plugins; render batches; commentary; and official pointers. Every entity file is versioned per author (`ch_main-hero_30yo_john123_v2.json`), forking copies a file into a new author's name, and a small `_official.json` pointer names the version the project has chosen. The intent is that a film is developed the way software is developed: many people, many versions, comparison, and a deliberate choice of what is official.

FilmOpen the application is the reader and editor for such folders. Milestone 1 delivered the **reader**: open a folder, see the whole project as a tree, inspect any object, see every version of it side by side, see the media rendered for it and what people said about it, and be warned about everything the format's rules say a reader should warn about. Milestone 2 made it a **desktop application that lives on a machine**: it knows where a project's media is (the manifest, §4.4 of the format), where the shared library of models, platforms and plugins is, where its own preferences and log are, which projects the user has opened, and it can create a project folder. Milestone 3 made it a **writer**: a new project comes with its creator's first project file, the project file is edited in a form that saves as you type, any version can be forked, a director sets official with a star, anyone picks their preferred version with a check, and a version can be labelled.

The application is not the format. The Project Specification is authoritative about what files mean; this document is authoritative about how the application is built. Where the application simplifies the format's rules, this document says so.

### 1.1 Audience of this document

A developer, or an AI development session, who has read the Project Specification and needs to continue the code without re-deriving its structure. It explains what exists, the reasons behind the shape of it, and the rules that must stay true when adding to it.

---

## 2. Design principles

These follow directly from the format's own principles (Project Specification §2) and from decisions made during milestone 1.

1. **Warn, don't enforce.** No file is rejected for being imperfect. A header that disagrees with its filename, a field of the wrong type, a segment that is too long, an unknown file: each is a warning the user can see, never a crash and never a silent fix. The loader tolerates any JSON that parses to an object.
2. **Never guess.** When the format says a reader must report rather than choose, the application reports. An ambiguous reference (several authors, nothing official, no viewer) is *unresolved*, not first-match. A folder with several project authors and no official pointer *asks* which to open. An implicit epoch falls back to `default` only, never to "whatever exists".
3. **The files are the only state.** Every write goes to a file through the project's `ProjectSource`, and the project is re-read afterwards; nothing is edited in memory and saved later. The app writes only under the viewer's own handle (§17, §18.3 of the format): a form is offered on the viewer's own version and Fork on everyone else's; the official pointer is a director's to write and to remove; the pick lives in the viewer's own commentary file. Reads and writes are confined to the project folder; the media root and the library are sandboxes of their own, and the library is never written. Creating a project refuses a folder that already holds files.
4. **The model knows the format, not the screen.** Everything under `lib/models/` and `lib/services/` is pure Dart, free of Flutter and free of user-facing text. Diagnostics are codes with arguments. This keeps the core testable without a widget tree and ready to become a separate package.
5. **Language-independent by construction.** Every string a person reads comes from a localisation file, including the display labels for the format's own field names, entity types and counts. The format's JSON keys are never translated; the screen is.
6. **One navigation path.** Whatever is clicked, in the tree or in the detail pane, goes through one controller that sets what is shown, highlights the matching tree row and reveals it.
7. **Small, honest surface.** The application says what it does not do (placeholders show a message, the README says "nothing writes yet"). Product text is not aspirational.
8. **Model after API Dash.** The window layout follows the API Dash desktop client: a narrow icon rail, a resizable sidebar, a main pane. One component is adapted from it under its licence; the rest is FilmOpen code.

---

## 3. Technology

| Concern | Choice | Notes |
|---|---|---|
| Language and toolkit | Dart 3.13, Flutter 3.47 (stable) | Windows desktop is the first target; the same code runs in Chrome for previews and in widget tests. |
| State | `flutter_riverpod` 3 | `Notifier` / `AsyncNotifier` / `Provider` only; no code generation. |
| Split view | `multi_split_view` 3.6 | Wrapped in `widgets/split_view.dart`, adapted from API Dash. |
| Folder picker | `file_selector` | Desktop only; hidden on the web. |
| Preferences | `shared_preferences` (`SharedPreferencesWithCache`) | Theme, language, viewer handle, sidebar width, verbose logs, recent projects, library folder. See §6.4 for where the file lives. |
| Application folders | `path_provider` | The app-support folder (preferences, shared library) and, on mobile, the log folder. Desktop log folders are computed from the environment (§6.4). |
| File browser and URL schemes | `url_launcher` | Only for the mobile "show in folder" branch; desktop uses the OS command directly. |
| Localisation | `flutter_localizations`, `intl` 0.20, Flutter `gen-l10n` | ARB files in `lib/l10n/`; generated code committed. |
| Lints | `flutter_lints` 6 | `flutter analyze` must report no issues. |
| Tests | `flutter_test` | Unit, fixture and widget tests; 40 at the end of milestone 1, 83 at milestone 2, 104 at milestone 3. |

Platforms enabled: `windows`, `web`. Android, iOS, macOS and Linux are not configured; the code has branches for them (paths, file reveal) that compile but have not run.

### 3.1 Commands

```
flutter pub get            # also regenerates localisations (generate: true)
flutter gen-l10n           # regenerate localisations by hand after editing ARB files
flutter analyze
flutter test
flutter run -d windows
flutter run -d chrome
flutter run -d web-server --web-port 8642 --web-hostname localhost   # headless preview
flutter build windows --release   # build\windows\x64\runner\Release\filmopen.exe
```

The Windows toolchain needs Visual Studio Build Tools with the C++ desktop workload; `flutter doctor` must show *Visual Studio – develop Windows apps* as OK.

---

## 4. Architecture

### 4.1 Layers

```
lib/
  models/      the format: filename grammar, entities, the project index, resolution, diagnostics   (pure Dart)
  services/    where files come from and go to (disk, bundled assets, memory), the loader, the manifest's
               media root, the shared library, the log, application folders, the file-browser reveal,
               what a new project contains (project_template), the writer (save, fork, official, pick)
               (pure Dart except asset_source and the *_io / *_stub pairs, which are conditional imports)
  providers/   Riverpod state: the open project, browser navigation, settings, localisation
  tree/        the tree model and the builder that lays a project out as a tree
  screens/     pages: dashboard, browser (tree pane, detail pane and its tabs), settings
  widgets/     reusable pieces: tree view, split view, attribute view, screenplay view, chips
  l10n/        ARB files, generated localisations, label helpers, field-label table
  utils/       natural (numeric-aware) string ordering
  theme.dart, app.dart, main.dart, consts.dart
```

**Dependency rule.** Arrows point downward only:

```
screens → widgets → providers → tree → l10n
                                  ↓
                             models ← services (project_source only)
```

- `models/` imports nothing from Flutter and nothing from the layers above. It imports `services/project_source.dart` (an abstract interface plus an in-memory implementation), which is also pure Dart. This is a known small inversion, accepted for now; a later package split would move `ProjectSource` into the model package.
- `services/asset_source.dart` is the one Flutter-dependent service (it reads the asset bundle) and is imported only by `providers/`. `services/app_log.dart` and `project_creator`'s model inputs are pure Dart. Every service that needs `dart:io` comes as a triple — `x.dart` (a conditional export), `x_io.dart`, `x_stub.dart` — so the same call compiles on the web and answers "not available" there: `directory_source`, `asset_disk`, `app_paths`, `log_file`, `reveal`, `project_creator`, `library_install`.
- `tree/` depends on Flutter only for `IconData`; icons are chosen in `widgets/entity_icons.dart`.
- `l10n/labels.dart` is the single bridge from model codes to human text.

### 4.2 Data flow

```
project folder ──┐
media root  ─────┤  three ProjectSources (folder / assets / memory), each its own sandbox
shared library ──┘  listPaths(), readText(), locate(), diskPath(), related()
      ▼
ProjectWriter ◄── (save / fork / official / pick: writeText, deleteFile on the project's source, then a re-read)
      ▲
ProjectLoader ──► FilmProject (manifest, media root, library; index: entities by stem (with origin),
      │            groups by type+tag, epochs, versions, official pointers, commentary, batches,
      │            takes (with media location), other files, warnings, project candidates,
      │            selected project)
      ▼
projectProvider (AsyncNotifier)  ◄── libraryProvider (FutureProvider: the library folder, seeded on first run)
      │
      ├─► treeModelProvider ──► TreeBuilder(project, l10n, viewer) ──► TreeModel(roots, index by selection key)
      │                                                                     │
      ▼                                                                     ▼
browserProvider (BrowserState: selection, highlighted node, toggled rows) ◄── TreePane clicks / DetailPane links
      │
      ▼
DetailPane: switch on Selection → ProjectOverview | ProjectChooser | CategoryView | EntityDetail | BatchDetail | OtherFilesView
```

Everything is derived: change the project, the viewer handle or the language, and the tree and pane rebuild from the index. Nothing is cached beyond Riverpod's own memoisation. A write is a file write followed by `refresh()`: the new index replaces the old, and because the browser resets its navigation only when the *source* changes (`projectSourceKeyProvider`), the page being edited stays where it is.

---

## 5. The model layer

### 5.1 Filename grammar — `models/stems.dart`

`FileNameParser.parse(path)` implements Project Specification §5.4 and returns one of a sealed set:

| Class | Filename shape | Holds |
|---|---|---|
| `VersionStem` | `<type>_<tag>[_<epoch>]_<author>_v<n>` | type, tag, epoch?, `Author`, v; `stem`, `versionsKey` (`ch_main-hero_30yo`), `groupKey` (`ch_main-hero`) |
| `OfficialStem` | `<type>_<tag>[_<epoch>]_official` | type, tag, epoch?; `versionsKey` |
| `CommentaryStem` | `cm_<type>_<tag>_<author>` | target type, tag, author |
| `BatchStem` | `rd_<id>_<author>` | id, author |
| `TakeName` | `<deliverable>_<source stem>_r<id>_<renderer>_<n>.<ext>` | deliverable, source `VersionStem`, batch id, renderer, n, extension; `batchStem` |
| `UnknownName` | anything else | the path |

Rules enforced by the parser: exact token counts per type (library types carry an epoch, spec types do not); `v<n>` with n ≥ 1; take number `n` is a positive integer without leading zeros; the reserved owner `official` is never accepted as an author; a take's batch token is `r` followed by at least one character. `parseStem(stem)` parses a stem without extension, as found in `forkedFrom` and batch `source` fields.

`namingWarnings(parsed)` returns warnings (as codes) for any segment outside `[a-z0-9][a-z0-9-]{0,19}` or longer than ten characters (§5.2). It is applied to every parsed name, including takes and commentary.

`Author` (`models/author.dart`) splits `owner[.workspace]`; equality is by full handle.

`EntityType` (`models/entity_type.dart`) is the enum of the nineteen prefixes with `category` (library / spec / commentary / renderBatch), `hasEpoch`, `isVersioned`, and the dotted-tag containment rules `parentType` / `containedType` (outfits nest under characters; areas under locations). `Deliverable` is the enum of take prefixes with `isImage`. Neither enum carries display text.

### 5.2 Documents — `models/entity.dart`

- `Entity`: one versioned file. Holds the `VersionStem`, the project-relative `path` and the complete `json` map (unknown fields preserved). Typed accessors (`name`, `label` — the version label of §7, `forkedFrom`, `notes`, `kind`, `stringList(key)`, `blocks`) never throw on a wrong JSON type. `headerMismatches` and `fieldTypeWarnings` return warning codes.
- `OfficialPointer`: from an `_official.json`; `fromJson` returns null when the `official` field is missing, which the loader reports. Keeps its whole `json` so the writer can rewrite it without losing fields.
- `Commentary` / `CommentaryEntry`: one `cm_` file with `prefer` — the author's *pick* — and entries (`on`, `at`, `rating` ±1, `text`, `block`, `field`); keeps its whole `json` for the same reason.
- `RenderBatch` / `RenderItem`: one `rd_` file with its items; totals cost.
- `Take`: a media file discovered by name; never listed inside an entity (§12.1).

Two helpers, `stringField(json, key)` and `numField(json, key)`, are the only way JSON scalars are read.

### 5.3 The index — `models/project.dart`

`FilmProject` is immutable after loading and holds:

| Field | Content |
|---|---|
| `entitiesByStem` | every versioned entity by full stem |
| `groupsByKey` | `EntityGroup` per type+tag; each group holds `EntityVersions` per epoch (spec types use the single epoch `null`); versions are sorted by author handle then number |
| `officialsByKey` | pointers by versions key |
| `commentaries`, `batches`, `takes` | as discovered |
| `companionFiles` | named by the grammar but not JSON (a plugin's `.js` beside its manifest) |
| `otherFiles` | not named by the grammar; reported, never touched |
| `warnings` | every `ProjectWarning` raised while loading |
| `projectCandidates` | every `pj` version in the folder, stable order |
| `selectedProject` | the `pj` version in use, or null when the user must choose (`needsProjectChoice`) |

Queries: `group(type, tag)`, `groupsOf(type)` in natural tag order, `childGroups(type, parentTag)` for dotted tags one level down, `officialPointer` / `officialEntity` / `isOfficial`, `takesFor(group)`, `commentaryFor(group)`, `batch(stem)`, `groupCounts`. `withSelectedProject(entity)` answers the chooser without reloading.

### 5.3a Project-level knowledge — `models/project_kind.dart`, `models/epochs.dart`

`ProjectKind` is the enum of §8.1 (`short`, `feature`, `miniseries`, `series`) with the 1.4 words read as their nearest value (`fromName`), `isEpisodic` (seasons and episodes rather than installments and acts, §10.1) and `defaultRootKey` (the story-root list a blank project of that kind starts with, §8.5). `recommendedGenres` is the vocabulary of Appendix A.9. `EpochDeclaration` / `declaredEpochs(json)` read the project's `epochs` object in the order §8.2 defines (unnumbered first in file order, then by `order`); `encodeEpochs(list, previous:)` writes it back renumbered 1..n, keeping fields the app does not know. `FilmProject` exposes `kind`, `declaredEpochList`, `defaultEpoch` (the first declared, else `default`), `epochLabel`, `sortedEpochs(...)` (declared order, then natural), `isDirector(author)` (§8.3), `commentaryBy`, `pickOf(group, viewer)` and `isPickOf`.

### 5.4 Resolution — `FilmProject.resolve`

Implements §6.1 in a simplified but faithful form. Given a type, a reference (tag or full stem), an optional epoch, whether that epoch was explicit, and an optional viewer:

1. A full stem names its file exactly.
2. No such tag → unresolved (`noEntity`).
3. Epoch: for library types the wanted epoch is the given one or the project's default epoch (the first it declares, §8.2; `default` when it declares none). If there is no file for it: an explicit epoch, or the default itself, is unresolved (`noEpoch`); an implicit epoch falls back to the default and marks `fellBack`; no file at the default either → unresolved (`noEpochOrDefault`).
4. Author: the viewer's own highest version, then the viewer's owner's highest; then the official version; then, if exactly one owner has versions, that owner's highest.
5. Otherwise unresolved (`ambiguous`, listing the owners).

Not implemented from §6.1: the rule that a file *named by an official pointer* resolves through official only (the "official sees the official world" half). The application resolves from the viewer's point of view for every file. This is recorded as a milestone 2 item.

### 5.5 Project selection — `ProjectLoader._selectProject`

Implements §4.1: a valid official `pj` pointer wins; else a sole author's highest version; else no selection and the UI asks. Several project *tags* in one folder also ask. An official pointer to a missing file is a warning and falls through as if absent.

### 5.6 Diagnostics — `models/diagnostics.dart`

`ProjectWarning(kind, args, path)` with `WarningKind` (twenty-five kinds: naming, duplicate stem, header missing or mismatched, field type, official pointer problems, unreadable JSON, project selection, since milestone 2 the manifest and library kinds: `noManifest`, `manifestTagMismatch`, `dataLocatorMalformed`, `dataLocatorUnsupported`, `dataRootUnavailable`, `dataRootAbsolute`, `libraryStemShadowed`, `libraryUnavailable`, and since milestone 3 `noEpochsDeclared`, §8.2). `ResolutionProblem(kind, type, reference, epoch, owners)` with `ProblemKind` (five kinds). Both have a `toString()` for logs only; the UI renders them through the localisation layer.

### 5.7 The manifest — `models/manifest.dart`

`filmopen-project.json` (Project Specification §4.4) is the one file in a project that is not somebody's versioned work. `ProjectManifest.fromJson` reads `tag`, `data` and `note` tolerantly (a missing or mistyped field is null and the loader warns). `DataLocator` is the typed "where": `LocatorType` (`local`, `dropbox`, `google-drive`, `onedrive`, `icloud`, `url`) or an unknown name kept as text; `isLocal`, `isAbsolutePath` (POSIX, drive-letter and UNC forms). `ProjectManifest.template` is what the creator writes. `projectManifestFileName` is the one constant that names the file.

### 5.8 Short names — `models/short_name.dart`

The format's *segment* (§5.2) as a person types it: `ShortName.pattern` (`[a-z0-9][a-z0-9-]{0,19}`), `validate` (a `ShortNameProblem`: empty, invalid characters, bad start, too long), `isLongerThanRecommended` (over ten: warn, never refuse), `normalise` (free text to the nearest segment, never inventing one). The filename grammar in `stems.dart` uses the same pattern, so there is exactly one place the rule lives; `widgets/short_name_field.dart` is the input box built on it.

### 5.9 Origin — `Entity.origin`, `Take.media`

`EntityOrigin { project, library }` says which root a versioned file came from; `EntityGroup.isFromLibrary` is true when every version of an entity came from the library. `Take.media` is the `MediaLocation` resolved at load time, because a take may sit in the media root rather than the project folder.

---

## 6. Services: where files come from

`ProjectSource` (`services/project_source.dart`) is the abstraction the loader reads through. One source is one root; a project's media root (§4.4) and the shared library (§14.5) are sources of their own:

```dart
String get label;                       // folder name, or a neutral label
String? get rootPath;                   // absolute path when it is a folder
Future<List<String>> listPaths();       // root-relative, '/'-separated, recursive
Future<String> readText(String path);
MediaLocation? locate(String path);     // AssetMedia(key) | FileMedia(path) | null
String? diskPath(String path);          // absolute path on this machine, for the file browser; null if not on disk
Future<ProjectSource?> related(String relativeFolder);  // the folder a manifest names: '../x-data', 'media'; null if absent
bool get canWrite;                      // false for bundled assets
Future<void> writeText(String relativePath, String text);   // create or replace, through the same sandbox check
Future<void> deleteFile(String relativePath);               // absent is not an error
```

`MediaLocation` keeps the model Flutter-free; `widgets/media_image.dart` turns it into an `ImageProvider`.

Implementations:

- **`DirectoryProjectSource`** (`directory_source_io.dart`, desktop): recursive scan that skips hidden entries and the folders `build`, `conform`, `node_modules`. Every read goes through `file(relativePath)`, which rejects schemes, absolute paths, backslashes, empty, `.` and `..` segments, then checks that the absolute path, and the symlink-resolved path when the file exists, lies inside the folder. Violations throw `ProjectPathException`. `related()` resolves `..` against the folder's URI (or takes an absolute path as given) and returns a **new** `DirectoryProjectSource` — a sandbox of its own, never a hole in this one — or null when the folder does not exist. A conditional export (`directory_source.dart`) swaps in a stub on the web, where `canOpenDirectories` is false.
- **`AssetProjectSource`** (`asset_source.dart`): a folder bundled as assets — the sample project, its media root `the-cartographer-data`, the seed copy of the library — enumerated from the asset manifest. `related()` resolves a sibling asset folder; `diskPath()` finds the file beside the executable (`asset_disk_io.dart`: `<exe dir>/data/flutter_assets/…` on Windows and Linux, the `App.framework` bundle on macOS) so that even the sample's files can be shown in the file browser. It is read-only (`canWrite` false); `snapshot(label:)` copies its text files into a writable `MemoryProjectSource` whose media root is still the bundled folder — how the web preview opens the sample, so editing can be tried where there is no disk.
- **`MemoryProjectSource`**: a `Map<String, String>` for tests and fixtures, and for session-only projects (the web), with a `relatedSources` map for the folders a manifest may name. Writable.

`ProjectLoader.load(source, {library})` (`services/project_loader.dart`) works from up to three roots. It reads the manifest first (a missing one is `noManifest`; a manifest without `filmopen` or `tag` is `headerMissing`; the folder without a manifest is its own media root) and opens the media root it names when the locator is `local` and the folder exists (else `dataLocatorUnsupported` / `dataRootUnavailable`; an absolute path is `dataRootAbsolute`). It indexes the library's files first with `EntityOrigin.library` (a library folder that cannot be listed is `libraryUnavailable` and the project goes on without it), then the project's with `EntityOrigin.project` — the project's copy of a stem replaces the library's with `libraryStemShadowed` and none of the replaced file's own warnings survive (per-entity warnings are emitted at the end); a project's official pointer replaces a library's silently (§14.5: accepting a library entry *is* pointing official at it); the library's unknown files are not the project's "other files". Then it walks the media root for takes only (anything else there is reference material reached by path, §6.3). Finally it builds the index as in milestone 1, checks the manifest's tag against the `pj` files (`manifestTagMismatch`) and selects the project. It logs one `project.loaded` debug entry with counts and timing.

### 6.1 Creating a project — `services/project_template.dart`, `project_creator*.dart`

`writeNewProject(source, {tag, author, name, kind, epochLabel})` (pure Dart) writes into any writable `ProjectSource`: `filmopen-project.json` from `ProjectManifest.template`, and `pj_<tag>_<author>_v1.json` with the header, the `kind`, an empty `genre`, the current `year`, one epoch (`default`, labelled in the user's language, with the current year, §8.2), the creator in `contributors` as `director` (§8.3), an empty story-root list for the kind (§8.5) and timestamps. `createProject(...)` (`_io`) makes `<parent>/<tag>/` first, refusing a folder that already holds files (`ProjectFolderNotEmptyException`) and a tag that is not a segment; where folders cannot be made (the web) the provider writes the same documents into a `MemoryProjectSource` that lives for the session. `encodeProjectJson` is the one JSON encoder for written files: two-space indentation and a final newline (§18.1); `rfc3339Now()` the one timestamp format (§7).

### 6.1a Writing — `services/project_writer.dart`

`ProjectWriter(project)` is pure Dart and does every write into an open project, through `project.source.writeText` / `deleteFile`, logging each (`entity.saved`, `entity.forked`, `official.set`, `official.cleared`, `pick.set`):

- `saveEntity(entity, json, viewer:)` — replaces the author's own file with `json` plus a fresh `updated`. Refuses a read-only source (`ProjectReadOnlyException`), a library file, no viewer (`NoViewerException`) and another author's file (`NotYourFileException`, §17 rule 2). Whether to warn that the version is official (§13.2) is the caller's decision; the editor shows a banner and still allows it.
- `fork(entity, viewer:)` — §13.1: the viewer's next number at that epoch (v1 when they have none), a complete copy with `author`, `v`, `forkedFrom` and fresh timestamps, minus the version `label`; written beside the original. Returns the new `VersionStem`.
- `setOfficial(entity, viewer:)` / `clearOfficial(entity, viewer:)` — writes `<versions key>_official.json` naming the entity with `setBy` and `setAt` (rewriting an existing pointer's other fields, dropping its `note`), or deletes the pointer file. The UI offers this to directors only (`FilmProject.isDirector`, §8.3).
- `setPick(group, entity?, viewer:)` — `prefer` in the viewer's own `cm_` file for the entity (§13.4), created with empty `entries` when they have none; null removes `prefer` and keeps the file.

Nothing renames or deletes another author's file (§18.3).

### 6.2 The log — `services/app_log.dart`, `log_file*.dart`

Structured logging, pure Dart. `LogLevel` is verbosity from `error` to `trace`; `error` and `warning` are always written, `info` is the default threshold, `debug` is what the *Verbose logs* setting adds, `trace` is per-file detail that only `FILMOPEN_LOG_LEVEL=trace` in the environment turns on (the environment *pins* the level; the setting then cannot lower it). `AppLog.instance` fans each `LogEntry` (UTC `ts`, `level`, dotted `event` such as `project.opened`, a `data` object, optional `error` and `stack`) to its sinks: `FileLogSink` (one JSON object per line, appended and flushed synchronously so a crash loses nothing, rotated to `<name>.1.jsonl` past 5 MB), `LineLogSink` (the console in debug builds), `MemoryLogSink` (tests). A sink that throws is skipped, never fatal. `AppLog.timed` wraps a future and logs its duration or its failure. `main.dart` opens the file sink before anything else, routes `FlutterError.onError` and `PlatformDispatcher.onError` into it, and writes `app.started`. Events are identifiers, not sentences, so the log stays language-independent and groupable.

### 6.3 Showing files — `services/reveal*.dart`

`revealInFileManager(path)` opens the platform's file browser with the file selected (or the folder opened): Windows `explorer.exe /select, <path>` (the switch and the path as two arguments — Dart quotes an argument containing spaces, and Explorer ignores a quoted `/select,C:\a b\f`), macOS `open -R`, Linux the freedesktop `org.freedesktop.FileManager1.ShowItems` D-Bus call with `xdg-open` of the folder as fallback. iOS (`shareddocuments://`) and Android (a `content://` Documents URI for shared storage) go through `url_launcher` and are best-effort: those platforms are not built here and the branches have not run. It returns false rather than throwing, and the caller says so in a snackbar. On the web `canRevealFiles` is false and the buttons are not shown.

### 6.4 Application files — `services/app_paths*.dart`

Where the application keeps its own files, in the place each operating system expects. Project data never lives here.

| | Preferences (`shared_preferences`) | Shared library | Log file |
|---|---|---|---|
| Windows | `%APPDATA%\ai.filmopen\filmopen\shared_preferences.json` | `%APPDATA%\ai.filmopen\filmopen\library\` | `%LOCALAPPDATA%\FilmOpen\logs\filmopen.jsonl` |
| macOS | `NSUserDefaults` (`~/Library/Preferences/<bundle id>.plist`) | `~/Library/Application Support/<bundle id>/library/` | `~/Library/Logs/FilmOpen/filmopen.jsonl` |
| Linux | `$XDG_DATA_HOME/filmopen/shared_preferences.json` (`~/.local/share/…`) | `$XDG_DATA_HOME/filmopen/library/` | `$XDG_STATE_HOME/filmopen/logs/` (`~/.local/state/…`) |
| Android | the system `SharedPreferences` store | the app's support folder, `library/` | the app's support folder, `logs/` |
| iOS | `NSUserDefaults` | the app's support folder, `library/` | the app's support folder, `logs/` |
| Web | browser storage | the bundled copy, read in place | none: console only |

The app-support folder is the one `path_provider` derives from the executable's company and product names (`windows/runner/Runner.rc`: `ai.filmopen`, `filmopen`). On Windows and Linux `shared_preferences` keeps a JSON file there, and Settings → About shows that file with a show-in-folder button; on macOS and mobile the plugin uses the system's own preference store, which is not a file to show, so the line is absent. The library sits in the same folder so everything the app owns on a machine is in one place; a library folder that cannot be read is a `libraryUnavailable` warning on the project, shown on the Settings library card, never a failure to open. **Preferences are plain JSON**: theme, language, handle, widths, verbose flag, recent project paths, library path — nothing secret. API keys and other credentials, when they arrive, go to the platform's credential store (Windows Credential Manager / DPAPI, the macOS and iOS Keychain, the Android Keystore, libsecret on Linux), not to this file; the code says so at `sharedPreferencesProvider`.

Note for development: two builds of the app on one machine (for instance `filmopen-a` and `filmopen-c`) share these folders and the log file, because they share the product identity.

### 6.5 Installing the library — `services/library_install*.dart`

`installLibraryIfEmpty(folder, seed)` copies the bundled library (`assets/sample/library`) into the app's library folder when that folder holds no files (empty subfolders do not count) and never touches it afterwards. Updating the library — a `git pull`, a download — is the user's or a future updater's job, independent of the app's own builds (§14.5). On the web the bundled copy is read in place.

---

## 7. State: providers

| Provider | Type | Responsibility |
|---|---|---|
| `projectProvider` | `AsyncNotifier<FilmProject>` | Loads the sample at start — read in place on desktop, as a writable session copy on the web — with the library from `libraryProvider` (which it watches: choosing another library folder re-reads the open project). `openDirectory(path)`, `openSample()` replace the page (loading spinner) and, on success, put the folder at the top of the recent list. `createProject(...)` writes the folder (or a session-only project where there is no disk) and opens it, returning the new `pj` stem. `refresh()` re-reads without entering a loading state, so the current project stays on screen. `selectProject(stem)` answers the chooser. `saveEntity`, `fork`, `setOfficial`, `setPick` call the `ProjectWriter` with the viewer from Settings and then `refresh()`. Every open, write, success and failure is logged. |
| `projectSourceKeyProvider` | `Provider<String?>` | Names *which* source is open (type and root path or label), unchanged by a re-read. The browser resets its navigation on this, not on every new index, so a page survives the re-read that follows a save. |
| `settingsProvider` | `Notifier<Settings>` | `themeMode`, `viewerHandle` (empty = no viewer), `sidebarWidth`, `locale` (null = platform), `verboseLogs` (applied to `AppLog.instance` on load and on change), `recentProjects` (most recent first, at most twelve, each a `{path, name}` JSON string; unreadable entries are dropped), `libraryPath` (null = the app's own folder). Persisted through `sharedPreferencesProvider`, which `main` overrides with a real store and tests leave null. |
| `libraryProvider` | `FutureProvider<ProjectSource?>` | The shared library: the folder chosen in Settings; else the app's library folder, seeded from the bundled copy on first run; else (web) the bundled copy read in place. |
| `preferencesLocationProvider` | `FutureProvider<String?>` | The preferences folder, for Settings → About; null on the web. |
| `localizationsProvider` | `Provider<AppLocalizations>` | The strings in effect for code without a `BuildContext`: the chosen locale, else the platform locale, resolved to a supported one by language code. Widgets use `context.l10n`; both resolve the same way. |
| `treeModelProvider` | `Provider<TreeModel>` | Rebuilds the tree from the project, the viewer and the language. |
| `browserProvider` | `Notifier<BrowserState>` | `selection` (what the detail pane shows), `nodeId` (highlighted row), `toggled` (rows flipped from their default expansion). `navigate(selection, {nodeId})` is the only way to change what is shown; without a node id it looks the row up via `TreeModel.nodeIdFor` and expands its ancestors. `toggle(node)`, `setAllExpanded(bool)`. Resets to the overview when another source is opened (`projectSourceKeyProvider`), not on a re-read. |
| `treeFilterProvider` | `Notifier<String>` | The sidebar filter text. |
| `navIndexProvider` | `Notifier<int>` | Which rail page is shown. |

Expansion state stores *toggles away from the default* rather than the expanded set, so a freshly loaded tree keeps sensible defaults (Script expanded, Characters expanded, scenes collapsed) without a reset step.

---

## 8. The tree

### 8.1 Model — `tree/tree_node.dart`, `tree/selection.dart`

`TreeNode`: `id` (stable, path-like), `label`, `detail` (muted secondary text), `tooltip`, `icon`, `selection` (what clicking shows; null for pure groupings), `children`, `initiallyExpanded`, `isOfficial` (star), `isPick` (the viewer's pick, a green check), `hasProblem` (warning icon, error-coloured).

`Selection` is sealed: `ProjectOverviewSelection`, `CategorySelection(type)`, `EntitySelection(type, tag, {epoch, stem})`, `BatchSelection(stem)`, `OtherFilesSelection`. Each exposes `matchKeys`, most specific first (`entity:<stem>`, `entity:<type>:<tag>@<epoch>`, `entity:<type>:<tag>`), which `TreeModel` indexes so a link from the detail pane can find and highlight the right row even when it names a version the tree does not show.

### 8.2 Builder — `tree/tree_builder.dart`

Top-level rows, in order:

1. **Project** — name, tag, its `pj` versions as children with the official star; flagged when a choice is pending.
2. **Script** — the selected project's story roots (`seasons` / `episodes` / `sequences` / `scenes`, whichever the file has), each unit's own child list beneath it, in the file's order. A scene gets **Shots** and **Cues** wrapper rows; any unit but a season gets **Cues**. A wrapper lists the unit's explicit `shots` / `cues` order or, absent that, every `<unit>.<n>` group in natural order (the tooltip says which). Empty wrappers are omitted; units and warning rows never are. Unresolved references are warning rows with the reason; a unit that lists one of its own ancestors is a *circular story reference* row and is not recursed into. A unit resolved by falling back to `default` says so in its tooltip.
3. **Characters, Locations, Props, Styles, Misc, Documents** — each entity by tag, then its epochs in the project's declared order (§8.2), then **one version row per author** showing that author's latest version (`author vN`, with the version label or `from <stem>` as detail; official star; the viewer's pick as a check). The tree does not list every iteration: beneath an author's row sit only the older versions of theirs that still matter — the official one and the viewer's pick. Dotted tags nest under their parent (outfits under a character as **Outfits**, areas under a location as **Areas**); one whose parent file is missing is listed at the top level with a warning. The same version rule applies to the project's own versions and to models, platforms and plugins.
4. **Models, Platforms, Plugins** — entity, then versions.
5. **Render batches** — `r<id>` with author, kind and item count.
6. **Other files** — present when the folder holds companion or unknown files.

Every visible string comes from the `AppLocalizations` the builder is given.

### 8.3 Widget — `widgets/tree_view.dart`

A flat, virtualised `ListView` over the visible rows. A row shows indent, chevron (toggles), icon, label plus muted detail as one rich text, then the star or warning icon. Tap selects when the row has a selection, otherwise toggles. There is deliberately no double-tap handler: it would delay every single tap by the double-tap timeout. With a filter, only rows that match or have a matching descendant are shown, fully expanded.

---

## 9. The user interface

### 9.1 Window

`Dashboard`: a 64-pixel rail (Project, Settings, About) beside an `IndexedStack` of pages. The browser page is a `SidebarSplitView` (sidebar 220–560 px, seeded from settings, saved on drag end) with `TreePane` left and `DetailPane` right. Material 3, `ColorScheme.fromSeed`, compact density, light and dark themes, and an `AppColors` theme extension for the two semantic colours the scheme lacks (the official star, a positive rating).

### 9.2 Tree pane

Header with project name and subtitle (folder path, or "bundled sample"), a chip showing the viewer handle when one is set, expand-all / collapse-all, and the project menu (⋮): *New project…* and *Open project…* (desktop only), *Re-read files*, then a **Projects** section listing the bundled sample and every folder opened before (name, path), the open one ticked. Choosing a remembered folder that no longer exists removes it from the list with a snackbar, and the project on screen stays. Below the header, the filter field and the tree.

**New project dialog** (`screens/browser/new_project_dialog.dart`): parent folder (desktop only; typed or browsed; pre-filled with the folder of the last opened project), the short name in a `ShortNameField`, an optional title, the **username** (a `ShortNameField` accepting `owner[.workspace]`, pre-filled from Settings, mandatory: "Lowercase letters, digits and dashes. All your files will contain this username"), and the **kind** (a pull-down of §8.1). It says which project file it will write and that the creator is its director, refuses a non-empty folder with a message, saves the username to Settings when it differs from the handle there, creates and opens the project, and the caller takes the user to the new project file's page, whose Edit tab is first. On the web the dialog says the project lives in the browser session only.

**`ShortNameField`** (`widgets/short_name_field.dart`) is the one input box for anything that must be a segment (§5.2): it filters as the user types (lower case, spaces and underscores to dashes, other characters dropped, twenty at most), explains the remaining problem under the box, and warns above ten characters without refusing. With `allowWorkspace` it accepts one dot and validates both sides, for an author handle (§5.6). Any place that asks for a tag, an epoch token or a handle reuses it.

### 9.3 Detail pane

Switches on the selection. When the project needs a choice, the **Project chooser** replaces everything: a card per `pj` candidate (name, stem, author, version, language, forked-from) that selects it.

- **Project overview** — name, logline, stem chip (click opens the `pj` entity), official chip, source chip, a `filmopen-project.json` chip (or a *No manifest* chip) and a *Media: …* chip naming the media root (or *Media in the project folder*); synopsis; cards for Details (kind and genres in words), Contributors, Epochs (one chip per epoch in order — token, label, year — the first marked as default), Tracks; *Contents of the folder* as count chips that open the category (entities the library contributed are not counted here); a *Shared library* card with where it was indexed from and its own counts; *Warnings* rendered in the current language.
- **Category view** — a list of every entity of one type with tag, epoch and version counts and an "official set" chip.
- **Entity detail** — header (name; chips for the type — named for the project's kind for story levels, §10.1 —, stem, *Shared library* when the file came from the library, forked-from as a link, updated, kind); an **Epoch** row when the entity has several, in the project's order; and the **Version** row: an **Author** pull-down (the authors with a version at this epoch; choosing one shows their official version if they have it, else their latest), a **Version** pull-down of that author's versions (`v2`, or `v2 · goth` when labelled; star or check as trailing icon), the **official star** (a toggle: grey, yellow when set; enabled for directors of a writable project; setting writes the pointer, clearing deletes it), the **pick check** (a toggle: grey, green when set; enabled for any viewer of a writable project), a **label** button (the author only; a small dialog writes `label`), and **Fork**. Tooltips say why a control is disabled (no username, read-only, directors only). Then tabs:
  - **Edit** (project files only, first when present) — the project form (`tabs/project_edit_tab.dart`): title, kind (pull-down; changing it moves an *empty* story-root list to the new kind's key), year, rating, genre (`widgets/genre_picker.dart`: a pull-down of check boxes over the recommended list plus whatever the file names, shown as chips), logline, synopsis, and the epochs (`tabs/epochs_editor.dart`: a reorderable list with a drag handle, token chip with a *default* badge on the first, label and year boxes, a remove button disabled for an epoch that files use or the last one, and *Add epoch* with a small dialog — token, label, year). Every change re-encodes the draft and saves it 700 ms after the last keystroke, with a status at the top right (*Saved*, *Unsaved changes*, *Saving…*, the error); a pending change is written when the page is left. An official version shows a banner offering Fork. On somebody else's file, or a read-only project, the tab says so and offers Fork.
  - **Overview** — every attribute of the chosen file except the header, as a nested key/value view; labels are translated with the raw key on hover; lists of scalars become chips, lists of objects become boxed sub-views. A separate *File* card shows path and header dates; a red card lists header/filename disagreements.
  - **Script** (scenes only) — the blocks as a screenplay page: block ids in the gutter, action, centred character cue with `(O.S.)` / `(V.O.)` / `^`, parenthetical direction, transitions right-aligned, titles, notes boxed.
  - **Compare** — the versions at this epoch as columns, ordered as §13.3: the version being viewed, the viewer's own, the viewer's pick, official, then the rest in author/version order. Rows are dotted leaf paths (`appearance.hair.color`) so each attribute diffs on its own; scene blocks align by id; differing rows are tinted. Copy-left (§13.3) is not implemented.
  - **Takes** — every take discovered for any version of the entity, as cards (image preview for image deliverables, placeholder icon otherwise), with deliverable, take number, source version, batch chip (opens the batch), renderer, path; "picked" marks takes the chosen version's `picks` name.
  - **Commentary** — all authors' commentary consolidated: a Preferences card, then entries newest first with rating icon, author, date, `on` chip, block/field chips, text.
  - **JSON** — the file pretty-printed with two-space indentation; top right, a folder button that shows the file in the platform's file browser (disabled with an explanation when the file is not on disk; absent on the web) and a copy button.
- **Batch detail** — header chips (stem, author, kind, tier, status, created, item count, total cost) and a table of items; the source column links to the entity.
- **Other files** — two lists: named by the grammar but not JSON; not named by the grammar.

### 9.4 Settings

Appearance (System / Light / Dark), Language (System / English / Español; language names in their own language), Identity (the author handle, with helper text saying whether a handle is in effect), **Shared library** (what it is; the folder in use with a show-in-file-browser button; *Choose folder…*; *Use default* when a folder was chosen), **Logs** (what is logged; the *Verbose logs* checkbox; the log file's path and a *View log* button that shows it in the file browser; on the web, a line saying entries go to the console), About (version, specification version, URL, the preferences folder with a show button, *Open-source notices* → Flutter's licence page, which includes the vendored API Dash licence).

### 9.5 Behavioural rules

- The viewer handle is empty by default, so a first-run user sees the format's rule that official wins. Setting a handle is visible in the tree header.
- Refresh never blanks the screen.
- Every empty state says what would fill it (no takes yet, only one version, no blocks).
- Every write goes through `ProjectWriter` and is followed by a re-read; the UI never edits the index. Writes happen only under the viewer's handle; the star is for directors; the library is never written.
- On the web nothing reaches a disk: the sample and new projects are session copies, and the tree header says so.
- Every transient message goes through `widgets/messages.dart` (`showMessage`), so they all look alike and can carry one action (for instance *Settings* when a username is missing).

---

## 10. Localisation

Three layers, all in `lib/l10n/`:

1. **Messages** — `app_en.arb` (template) and `app_es.arb`, compiled by `gen-l10n` (`l10n.yaml`: `nullable-getter: false`) into `app_localizations.dart` and one file per language. Widgets use `context.l10n`; providers use `localizationsProvider`. Counts use ICU plurals (`countScene(n)` → "1 scene" / "3 scenes").
2. **The format's vocabulary** — the JSON keys are normative and never translated. `fieldLabel(l10n, key)` in `field_labels.dart` maps 310 keys from Appendix A to messages (`fieldName`, `fieldForkedFrom`, …) and returns null for unknown keys, in which case the raw key is shown. Entity types (`typeScene`, `typeScenePlural`, `countScene`) and deliverables (`deliverableClip`) have their own messages, reached through the `Labels` extension in `labels.dart` (`typeLabel`, `typePlural`, `typeCount`, `deliverableLabel`).
3. **Diagnostics** — `warningText(ProjectWarning)` and `problemText(ResolutionProblem)` in `labels.dart` turn model codes into sentences.

The generated files are committed so editors and the analyzer see them; every build regenerates them. To add a language: copy `app_en.arb`, translate, add the language name to `settings_page.dart`. To add a field label: add `field<Key>` to every ARB and one case to `field_labels.dart`. British spelling is used in English messages (colour, licence).

---

## 11. The sample project

`assets/sample/` holds *The Cartographer* from Appendix B of the Project Specification, extended so the tree has depth, laid out the way a real project is meant to be (§4.4, §14.5):

- `the-cartographer/` — the project: `filmopen-project.json` (tag `cartographer`, `data` → `../the-cartographer-data`) and 40 JSON files, flat (folders carry no meaning in the format): two project versions (John's, and Suda's Thai remake forked from it) with an official pointer; the character `main-hero` at two epochs with three versions at `30yo` and an official pointer to Maria's fork; a second character; two outfits; two locations, one with an area; a prop, a style with an official pointer, a misc entry, a document; a season with two episodes, one using a sequence; two scenes, one of which has two versions and an official pointer; shots and cues including a J-cut and an episode-level music cue; two commentary files; two render batches; and one unknown text file so the *Other files* row appears.
- `the-cartographer-data/` — the media root: the nine placeholder takes (four small PNGs, empty `.mp4` and `.wav` files named as takes).
- `library/` — the seed of the shared library: a model, a platform and a plugin manifest.

Being under `assets/` and in Git is a development convenience (the build copies them to `build/flutter_assets/assets/sample/…`); a real project's media root is a synced folder, and the real library is the folder the app installs beside its preferences.

The sample is what the app opens at start and what most tests read. In-memory fixtures (`test/fixtures.dart`) cover what the sample cannot: ambiguity, missing epochs, multi-author project folders, bad field types, cycles.

---

## 12. Testing

| File | Covers |
|---|---|
| `test/stems_test.dart` | the filename grammar: every name kind, takes back to their source and batch, rejections, naming warnings, the reserved owner |
| `test/project_test.dart` | loading the sample: counts, warnings, resolution rules, the tree's shape |
| `test/fixture_test.dart` | spec behaviours on tiny in-memory projects: ambiguous resolve, `default`-only fallback, project selection (ask, invalid pointer, sole author), tolerance of wrong types, leaf scenes and unresolved rows in the tree, cycle detection |
| `test/directory_source_test.dart` | the path sandbox on a real temp folder, and skipped folders |
| `test/localization_test.dart` | Spanish messages, field labels, diagnostics and tree text |
| `test/widget_test.dart` | the app: overview and tree, every entity tab lays out without overflow, tree highlight when choosing a version, navigation from the detail pane reveals the row, the project chooser |
| `test/manifest_test.dart` | the manifest and media root (§4.4): locators, takes found in the media root and beside the JSON, no manifest, unsupported type, missing folder, absolute path, malformed `data`, tag mismatch, a manifest missing `filmopen` or `tag` |
| `test/library_test.dart` | the shared library (§14.5): entries marked by origin, separate counts, project stem shadows the library's with a warning and none of the replaced file's warnings survive, a fork beside library versions, a project official pointer overriding silently, an unreadable library folder as a warning rather than a failure |
| `test/short_name_test.dart` | the segment rule in one place: validation, the ten-character advice, normalisation, and that the filename grammar warns by the same rule |
| `test/app_log_test.dart` | JSON-line shape, thresholds, verbose and the environment pin, a failing sink, unencodable data, `timed`, the file sink and its rotation |
| `test/settings_test.dart` | recent projects (tolerant parsing, order, no duplicates, cap), library path and verbose defaults |
| `test/project_creator_test.dart` | creating a project on a real temp folder: manifest and first version (director, one epoch, root list for the kind), the kind's root list, refusing a non-empty folder and a bad tag |
| `test/project_writer_test.dart` | the writer on an in-memory project (§13, §17): save keeps unknown fields and stamps `updated`, refuses other authors, no viewer and read-only sources; fork numbering for own and others' versions, labels not copied; official set (rewriting the pointer, dropping its note), new pointer, clear; pick set, file created, cleared; epoch order, re-encoding, default-epoch fallback, the no-epochs warning; kind vocabulary |
| `test/editing_test.dart` | editing through the screen on an in-memory project: the project form saves after the delay and the page survives the re-read, somebody else's file offers Fork, Fork makes and shows the next version, the tree shows one row per author with official and pick beneath and the label as detail, the star and the check write their files and the star is disabled for a non-director |
| `test/project_menu_test.dart` | the ⋮ menu's items, the New project dialog's filtering, the mandatory username and the file it announces, the JSON tab's folder button, the library chip |

Widget tests load projects in `setUpAll` because file IO does not complete inside a widget test's fake-async zone. Tree rows draw label and detail as one rich text; match them with `find.text('label  detail', findRichText: true)`.

`flutter analyze` and `flutter test` must both be clean before a commit. Tests that need `dart:io` import the `_io` file directly (`log_file_io.dart`), because the analyzer resolves a conditional export to its stub.

---

## 13. Conventions for contributors

- **No user-facing text outside `lib/l10n/`.** Models and services emit codes. Widgets take strings from `context.l10n`; providers from `localizationsProvider`.
- **Every write goes through `ProjectWriter`** (or `writeNewProject` for a new project), through the source's `writeText` / `deleteFile`, followed by `refresh()`. No widget writes a file, and nothing edits the index in memory.
- **Write only as the viewer.** A write without a handle throws `NoViewerException`, another author's file `NotYourFileException`; the UI turns these into messages and offers Fork or Settings.
- **No Flutter imports in `lib/models/`, `lib/services/project_source.dart` or `lib/services/app_log.dart`.**
- **Platform code comes in threes:** `x.dart` (conditional export), `x_io.dart`, `x_stub.dart`; the stub answers "not available" (`canX = false`, null, or `UnsupportedError`) and the UI hides the affordance. Never import `dart:io` from a file the web build reaches.
- **Log events, not sentences:** `AppLog.instance.info('project.opened', data: {...})`; dotted identifiers, structured data, no user-facing text. Log every open, write, failure and setting change; log timings at debug.
- **One rule for short names:** anything that must be a segment goes through `ShortName` and, in the UI, `ShortNameField`.
- **Never throw on a bad file.** Add a `WarningKind` and report.
- **Never choose for the user** where the Project Specification says report. Return an unresolved `Resolution` or a null selection.
- **One way to navigate:** `browserProvider.navigate(...)`.
- **Comments explain why**, and cite the Project Specification section (`§6.1`) when a rule comes from it.
- **Filenames and identifiers follow the format's vocabulary:** `stem`, `tag`, `epoch`, `author`, `official`, `take`, `batch`, `cue`, `block`.
- **Tests for every spec rule** on an in-memory fixture, not only on the sample.
- **Keep `kAppVersion` in `lib/consts.dart` equal to `version:` in `pubspec.yaml`.**
- **Commit `pubspec.lock`** (it is an application) and the generated localisation files.

---

## 14. Known limitations

- **Only the project file has a form.** Other entity types are read here; they are forked, starred, picked and labelled, but their content is edited by hand or by a later milestone (a JSON editor, then per-type forms).
- **Character ages from epochs** (§9.1 `birthYear`) are specified but not yet proposed by the app: the character form does not exist yet.
- **No copy-left in Compare**, and no per-attribute compare of two epochs of one entity (§13.3) yet.
- **A save re-reads the whole folder.** Fine for hundreds of files; a project of thousands would want an incremental index.
- **Concurrent edits are not detected.** The last write wins; an outside change is picked up by the next re-read (or *Re-read files*) when the form is clean.
- **Locators other than `local`** (Dropbox, Google Drive, OneDrive, iCloud, URL) are recognised and reported, not resolved.
- **Mobile "show in folder"** (iOS `shareddocuments://`, Android `content://`) has not run: Android and iOS are not configured.
- **The shared library is not updated by the app**: it is seeded once and then left alone.
- **Warnings are not grouped**; the manifest warnings join the same list.
- **Resolution through official for official files** (§6.1 second paragraph) is not implemented; every file resolves from the viewer's point of view.
- **Fountain, SRT, OTIO, timeline export** and every §15–16 concern are out of scope so far.
- **Media**: images are shown; audio and video takes show an icon only.
- **Narrow layouts**: the window is designed for a desktop width; below about 690 px the split view cannot honour its minimums. The web build is a preview, not a target.
- **Spanish** is a first translation, not yet reviewed by a native speaker.
- **Windows path length limits** are not checked before writing a new project folder.
- **Warnings are per load** and are not persisted or grouped.

---

## 15. Third-party code

`lib/widgets/split_view.dart` is adapted from API Dash (`lib/widgets/splitview_dashboard.dart`, Apache License 2.0, revision `8044b218…`). The licence is vendored at `third_party/apidash/LICENSE`, registered with Flutter's `LicenseRegistry` in `main.dart`, and listed in `THIRD_PARTY_NOTICES.md`. The rail-beside-sidebar arrangement follows API Dash's dashboard; the theme, model, tree, views and state are FilmOpen code. FilmOpen's own licence is not yet decided.

---

## Appendix A — File map

```
filmopen-c/
  README.md                          how to run, layout, languages
  THIRD_PARTY_NOTICES.md
  l10n.yaml                          gen-l10n configuration
  pubspec.yaml, pubspec.lock
  analysis_options.yaml              flutter_lints
  assets/sample/the-cartographer/    the sample project (manifest + 40 JSON files)
  assets/sample/the-cartographer-data/  its media root (9 placeholder takes)
  assets/sample/library/             the seed of the shared library (model, platform, plugin)
  third_party/apidash/LICENSE
  docs/
    FilmOpen-Project-Specification v1.0.md   the format (v1.3 draft inside)
    FilmOpen-Software-Specification v1.0.md  this document
    FilmOpen-Milestone 1.md                  milestone 1: the reader
    FilmOpen-Milestone 2.md                  milestone 2: manifest, media root, library, log, projects
    FilmOpen-Development-Plan-*.md           earlier planning notes
    Pre-commit-improvements.md               the review that compared filmopen-c with filmopen-a
  lib/
    main.dart            log start-up, licence registration, preferences, ProviderScope
    app.dart             MaterialApp: themes, locale, delegates
    theme.dart           buildTheme, AppColors, monospace stack
    consts.dart          app name/version, sample asset path, spec URL
    models/              author, entity_type, stems, entity, project, diagnostics, manifest, short_name, project_kind, epochs
    services/            project_source (+memory), asset_source, asset_disk(_io/_stub), directory_source(_io/_stub),
                         project_loader, project_template, project_writer, app_log, log_file(_io/_stub),
                         app_paths(_io/_stub), reveal(_io/_stub), project_creator(_io/_stub), library_install(_io/_stub)
    providers/           project (+library), settings, localization, browser, app_paths
    tree/                tree_node (TreeNode, TreeModel), selection, tree_builder
    screens/             dashboard, settings_page, browser/{browser_page, tree_pane, new_project_dialog, detail_pane,
                         detail/{project_overview, project_chooser, category_view, entity_detail,
                         batch_detail, other_files_view, tabs/{project_edit, epochs_editor, overview, script,
                         compare, takes, commentary, json}}}
    widgets/             tree_view, split_view, attribute_view, screenplay_view, info_chip, genre_picker,
                         section_card, empty_state, entity_icons, media_image, short_name_field, messages
    l10n/                app_en.arb, app_es.arb, generated app_localizations*.dart, labels.dart, field_labels.dart
    utils/               natural_compare
  test/                  stems, project, fixtures (+fixture_test), directory_source, localization, widget,
                         manifest, library, short_name, app_log, settings, project_creator, project_menu,
                         project_writer, editing
  windows/, web/         platform runners (window title "FilmOpen")
```

Size at the end of milestone 1: about 5,500 lines of Dart outside generated code, 600 lines of tests, 549 localisation messages per language. At the end of milestone 2: about 7,300 lines, 1,100 lines of tests, 608 messages per language. At the end of milestone 3: about 8,900 lines, 1,600 lines of tests, 693 messages per language.

## Appendix B — Glossary of code names

| Name | Meaning |
|---|---|
| stem | a filename without extension, e.g. `sc_5_john123_v1` |
| versions key | the stem minus author and version, e.g. `ch_main-hero_30yo`; what an official pointer names |
| group key | type and tag only, e.g. `ch_main-hero`; every epoch and version of one entity |
| `EntityGroup` | one entity: all epochs |
| `EntityVersions` | one entity at one epoch: all authors' versions |
| viewer | the author handle the user browses as; empty means none |
| resolution | the outcome of looking up a reference: an entity, or a `ResolutionProblem` |
| selection | what the detail pane shows; the tree indexes rows by selection keys |
| wrapper row | the Shots / Cues grouping under a story unit |
| companion file | a file the grammar names that is not JSON (plugin code) |
| other file | a file the grammar does not name |
| manifest | `filmopen-project.json`: the fixed-name file that identifies a project folder and names its media root |
| media root | the folder takes and referenced media are read from: the manifest's `data` folder, else the project folder |
| locator | the manifest's typed "where" (`{type, path}`) for the media root |
| library, shared library | the models, platforms and plugins installed beside projects, indexed with every project |
| origin | whether an entity file came from the project folder or the library |
| short name | a segment (§5.2) as a person types it: a folder name, a tag, an epoch |
| pick | the version the viewer prefers: `prefer` in their own commentary file; a green check in the UI |
| version label | the header's `label`: a short word shown beside the version number in lists |
| session copy | a project held in memory for one run of the web preview; nothing reaches a disk |
| default epoch | the first epoch the project declares; where implicit references fall back to |
| reveal | showing a file in the platform's file browser with the file selected |
