43: Story Studio Practice - transcript
Jessica: The QUILL Cast, episode forty-three. I'm Jessica. Last time we met the binder, a whole book in a folder of plain text files plus one small companion file. Today we put our hands on it, get comfortable, and find the moves that turn Story Studio from a curious feature into a daily writing tool.
Liam: I'm Liam. Episode forty-two was the idea, today's the practice. We will walk through opening a project, building a binder from real files, editing an element, compiling the manuscript, and then look honestly at a couple of places where docs and code have drifted, because that's what "verify against code" earns you.
Jessica: First, a quick recap. A Story Studio project is a folder on disk. Inside it, ordinary text files, your chapters, your scenes, your character notes, your research. Optionally, one sidecar file named project.quillstory.json that holds a title, an ordered list of manuscript paths, and a list of element records, things like characters, places, plot threads, research, and brainstorm. That is the whole data model: title, manuscript, elements. Schema version one. No databases, no proprietary formats, no lock-in. If you delete the sidecar, your words are still there in their files.
Liam: And the next time you open that folder, Story Studio reads the folder, finds plain text files with the suffixes .md, .markdown, or .txt, sorts them by name, and synthesizes a fresh project with that file list as the manuscript spine. The folder is always the source of truth; the sidecar is a hint.
Jessica: So here's the move. Pause this episode. Open your file manager, create a new empty folder somewhere easy to find, name it after the project you've been meaning to start. Add three small Markdown files into it, each with two or three ATX headings and a paragraph or two of placeholder prose. We are going to come back to that folder in a moment. Pause for a few seconds, then come back.
Liam: Good, we are back. Let's open Story Studio on the folder you just made and see what actually happens, in the order it actually happens, against the actual code in the dialog and the binder builder.
Jessica: The command is registered as story.open_studio, and in the UI it lives in the Tools menu, as "Story Studio". The handler is in main_frame_story_studio.py, and the first thing it does is show a wx.DirDialog titled "Choose a story project folder", with the DD_DIR_MUST_EXIST style, so you cannot accidentally point it at a path that doesn't exist. There is a small label drift to call out: in some older write-ups the folder picker was called "Choose Story Project", but the actual dialog title in the source is "Choose a story project folder". When a doc and the code disagree, the code wins, that's the rule for this episode.
Liam: The modal then runs through apply_modal_ids, so the keyboard contract is honored. Tab moves through controls, Escape cancels, and so on. If you cancel, the status bar reads "Story Studio cancelled" and nothing changes. If you confirm, the path goes into a local Path object, and Story Studio calls load_project on that folder.
Jessica: load_project reads project.quillstory.json if it exists, and if it doesn't, it walks the folder and synthesizes a project. The synthesized project uses the folder's name as the title, and the sorted list of text files as the manuscript spine. So the simplest possible workflow is: make a folder, drop three or four .md files in it, and you have a project. One note on what counts as a manuscript file, because the code is explicit. The folder scan looks at the suffix, lowercased, and accepts only .md, .markdown, and .txt. Anything else, an .rst, a .adoc, a .docx, even a perfectly readable plain text file with no recognized extension, is ignored by the synthesizer. If you want it to be part of the spine, rename the file or add an explicit entry to the sidecar. The synthesizer is a friendly default, not a constraint; the sidecar is always the authority when both are present, and bad entries inside the sidecar are dropped rather than allowed to corrupt the rest. A corrupt sidecar, in fact, falls back to an empty project rather than raising an exception, so a hand-written mistake never blocks you from opening the folder.
Liam: Then the dialog appears. Its title bar is "Story Studio - " plus the project title. Inside, you see a labelled "Binder" header, a wx.TreeCtrl named "Story binder" so screen readers announce it, and at the bottom two buttons, "Edit details..." and "Compile manuscript...". The only other control is a Close button from the standard dialog button sizer, and the apply_modal_ids call wires it as the affirmative button labelled "&Close" with ID_CLOSE.
Jessica: Arrow up and down through the tree. The root is your project title. Its first child is the Manuscript group, which contains a node for each manuscript file, and under each file the headings of that file, derived on the fly from Markdown ATX headings, one through six hashes, a space, and the title. Underneath the Manuscript group come the element groups, in this exact order: Characters, Places, Plot threads, Research, Brainstorm. A kind with no members is hidden, so an empty Brainstorm group will not appear in your binder at all.
Liam: A code-verified note worth saying out loud: the binder is derived, not stored. build_binder walks the project, reads each manuscript file through an injected read_text callable, calls iter_headings on it, and nests the headings by level so a Part contains Chapters and each Chapter contains Scenes. If you edit a heading, the next time you open the binder, the change is reflected. There is no drag-and-drop ritual; the structure of your book is the heading outline of your files. Press Enter on any file, heading, or element node, and that file opens at the heading's offset, and the binder closes.
Jessica: One small thing about navigation, because it matters with screen readers. Activation only opens things that the binder considers openable. The openable types are "manuscript", "heading", and "element". The non-openable types, "root" and "group", expand and collapse when you activate them, they don't open a file. So pressing Enter on "Characters" is a tree toggle; pressing Enter on a specific character is an open.
Liam: And here's the second code-verified correction. In the original binder code, activating an openable node would sometimes nest the file-open prompt under the live binder modal, which caused focus churn. The fix in story_studio_dialog.py is to call EndModal with ID_CLOSE on the binder first, then defer the actual open with CallAfter. You, the user, see the binder close, then the file appears in a tab. EndModal ID_CLOSE is the same constant the apply_modal_ids call wires to "&Close", so the keyboard contract stays clean: pressing Escape on the binder is the same as clicking Close, and it is also the same as activating an openable node, all three paths land on ID_CLOSE. There is also a quiet bit of defensive reading built in. The injected read_text callable in the binder catches both OSError and UnicodeDecodeError and returns an empty string in their place, so a stray non-UTF-8 file in your project does not crash the binder; it just contributes no headings, and you carry on. The same defensive try-block lives in main_frame_story_studio's read_text closure. The only place that bails out instead of swallowing is the element details form: if it cannot read the file, the status bar tells you and the form does not open, because opening a form on an empty body would let Save overwrite the real file with nothing. That is a deliberate asymmetry, and the source comment in main_frame_story_studio calls it out as a review fix.
Jessica: Let's do the element flow. Pick a placeholder character. Add a new file to the project folder, name it heroine.md, write one line of text inside. Switch to QUILL, open Story Studio, arrow to the Characters group, and notice it's not there, because we haven't told Story Studio about the element yet.
Liam: Right, that's the gotcha. The element groups only show members that the project knows about. The sidecar has to list the element, with a kind, a title, a relative POSIX path, and an id. The good news: you don't have to hand-edit JSON. There is a form for it. We are about to do that, but the path sidecar reality is worth saying out loud once: the on-disk shape is title, manuscript list, elements list, and that's it. Anything Story Studio knows about your character as a structured thing lives there.
Jessica: Time for a "do this now" beat. Pause the audio. In your project folder, create one more file, name it heroine.md, and put a single line inside: "The detective who refuses to look away." That's her first sentence in the binder's mind. Pause for a moment, then come back. We will walk through adding her as a Character element the way the code actually does it.
Liam: Welcome back. The current version of Story Studio exposes the elements as a sidecar-only data model. The binder dialog itself does not have an "Add element" button, that is a known product gap. So for today's practice, you have two honest paths. Path one: open the project folder in QUILL, open project.quillstory.json as a text file, add an entry by hand, save. Path two: drive a small Python snippet from a Quillin or a one-shot. The dialog supports editing, not creation, today.
Jessica: Hand-editing is fine for one element. The path field must be relative POSIX, which means forward slashes, no leading slash, no drive letter, and no parent-traversal segments. So "heroine.md" is valid, "subfolder/heroine.md" is valid, "..\heroine.md" is not. If the path check fails, from_dict returns None and that element is silently dropped; the binder loses it. The kind field is one of "character", "location", "plot", "research", "brainstorm", with research as the fallback for anything unrecognized. Tags are an optional list of strings. The id can be any non-empty string; new_element generates a fresh uuid4 hex for you, and that's the pattern to copy.
Liam: Save the sidecar, reopen Story Studio on the same folder, and the Characters group is now there with one member: heroine. Stand on it, press Enter, and heroine.md opens as a new tab. Press Enter again on the group itself and it collapses; Enter again, it expands. That is the toggle.
Jessica: Now the story-bible side. Stand on heroine in the binder, press Alt-E, that's the mnemonic for "Edit details...". The details dialog appears. Its title bar is "Details - heroine", because the dialog title is computed from the file's stem, not the element's title. The form itself is a labelled field per element kind. For a Character, the fields are Role, Goal, Motivation, and Arc, in that order. A blank field is dropped on save, so the file never accumulates empty keys like "goal: ".
Liam: Plot threads get Status. Locations get Significance. Research items get Source. Brainstorm has no defaults at all, only the universal Tags field, which is the last row in every form. Tags is a comma-separated list, and what you type is what gets stored, with whitespace trimmed and empties dropped.
Jessica: There's a label inside the source worth mentioning. The "type" key is preserved when an element file already has one, and it is intentionally not shown in the form. That way the form's "save" doesn't accidentally strip a kind marker someone hand-wrote. And any unknown key already in the file is preserved as its own row, so a hand-edited front matter doesn't get deleted on round-trip; the form just adds new labelled fields to the ones you already have.
Liam: Each label has a unique Alt-letter mnemonic, and the TextCtrl next to it is given a name equal to the field label, so screen readers announce the field's purpose. Tab moves field to field, Alt-letter jumps to a field, and the standard OK and Cancel buttons at the bottom route through apply_modal_ids. OK is labelled "&Save" and the affirmative id is ID_OK. If you cancel, the status bar reads "Details unchanged". If you save, the file is rewritten with the merged fields at the top, fenced by two "---" lines, and the original body preserved underneath.
Jessica: What does that look like on disk. A character's file with a goal and tags would have, at the top, a line of three dashes, then a "role:" line, a "goal:" line, a "motivation:" line, an "arc:" line, a "tags:" line followed by a bullet for each tag indented under it, then a closing line of three dashes, then the prose. Lists are serialized as "key:" on its own line followed by "- value" entries; the codec is a small deliberate subset of YAML, no third-party dependency, round-trip safe. Episode forty-two promised this, and here it is in actual bytes. It is also worth pausing on what this means for the rest of QUILL. Because the file is plain text with a leading front-matter block, you can edit it in any editor, sync it through any cloud service, search it with any tool, and version it with any version control. Story Studio is not the keeper of your data; it is one reader and one writer of the same files you would have had without it. That is why the binder can be a derived view, and why the compile is a one-button concatenation, and why the sidecar is a hint. Everything in the system earns its complexity, and most of it is small. A few details worth knowing about the codec, because they show up in the wild. A front-matter block is only recognized when the file's very first line is exactly three dashes, optionally followed by a carriage return for Windows-authored files. The closing fence is the next line that is exactly three dashes. If there is no closing fence, the parser treats the file as having no front matter at all, so a stray "---" inside your prose never swallows your prose. Scalar values that need quoting are quoted; backslashes are escaped so round-tripping stays clean. If you write the file by hand and use a leading dash, a leading apostrophe, a leading quote, or a leading hash, the serializer will quote the value for you, so you don't have to think about it. A block that has no recognized key lines is treated as an empty block, body unchanged.
Liam: And the same front matter is what compile_manuscript strips when you ask for the whole book in one document. The compile logic lives in core/story/compile.py, and it's tiny on purpose: it walks the manuscript paths in the order the sidecar says, calls split_front_matter on each, takes the body, joins them with a blank line. No custom export engine, no transformation, just front matter removed and bodies concatenated. The result is plain text.
Jessica: Time to compile. Stand anywhere in the binder and press Alt-C, the mnemonic for "Compile manuscript...". The button handler closes the binder via EndModal ID_CLOSE, then CallAfter's the compile, so the binder is fully gone before the new tab is created. compile_manuscript walks the spine, joins the bodies, and the result is handed to a callback that opens it as a new document tab.
Liam: The new tab's title is the project title with "(compiled)" appended, sanitized so unusual characters don't break the display name. The status bar reads "Compiled manuscript opened in a new tab. Use File > Export to save it (Markdown, HTML, Word, DAISY, and more)." From there you are in the export pipeline you already know. Compile is a no-op for empty manuscripts: if the joined text is just whitespace, the status bar reads "Story Studio: the manuscript is empty, nothing to compile" and nothing opens. Two more things to say about compile, in the spirit of verify against code. First, the join separator is a blank line, two newlines in a row, which is what the default Markdown paragraph separator wants, so the compiled text is a valid Markdown document out of the gate. If you wanted a chapter break, a page break, anything fancier, you do not get it for free; you would put that break inside the file's body. The compile does not insert one. Second, the read_text function used for compile is the same one used for binder construction, so the same defensive swallowing of OSError and UnicodeDecodeError applies: a single misbehaving file yields an empty body for that file, the other files still compile, and you get a usable manuscript. That is the right behavior for a long-running project, where one bad encoding should never freeze the whole pipeline.
Jessica: A small honesty moment about file ordering. The manuscript spine is whatever the sidecar says, in the order it says it. When the sidecar is synthesized from a bare folder, the order is alphabetical, by file name. That means a manuscript called "10-chapter.md" sorts before "2-chapter.md", and you can shoot yourself in the foot. The fix is a sidecar, or numeric prefixes that sort the way you want, or, if you have one, a Quillin that rewrites the spine into a sensible order. It's a known sharp edge. There is a related nuance for the path field on element records. The model only accepts relative POSIX paths, and the validator explicitly rejects backslashes, absolute paths, drive letters, and parent-traversal segments. So a sidecar copied from a Windows machine to a Mac, or vice versa, where the path was originally written as "heroine.md" already in relative form, is fine; a sidecar where someone wrote an absolute path or a path with backslashes will silently drop that element, and you will wonder why the binder is missing a character. If you see a missing member, the first place to look is the path field.
Liam: One more code-verified correction before homework, because the team that built this was thorough. In the Edit Details button handler, if there is no element handler configured, the dialog bleeps instead of silently reporting success, that's a review-comment from issue 784 in the source itself. The compile button, separately, was reordered to close the binder first and compile after, because running the compile callback under the active modal was the cause of the focus churn. Both fixes are in the file you would read, story_studio_dialog.py, in the underscore-prefixed handlers.
Jessica: And to be transparent about what is not in this episode. We have not talked about reordering chapters from inside the binder, because there is no drag-and-drop reordering. The code only supports reordering by editing the sidecar manuscript list, or by renaming files so the alphabetical scan reshuffles. We have not talked about splitting or merging files, because the binder is a view, not an editor of the file system. We have not talked about adding elements through the binder dialog, because the binder dialog does not currently expose an add-element button. These are honest product gaps, and naming them is the same job as verifying everything else.
Liam: The mental model, then, is this. Your project is a folder. Your structure is your headings. Your bible is plain text with optional front matter. Your compile is a one-button concatenation with front matter stripped. The binder is a derived view that lets you move through a hundred thousand words with arrows and Enter. The remaining power is in the sidecar and the file system, which is exactly the point: every word is in a file you can open with any tool, including the ones we have not built yet.
Jessica: Homework. One: create that empty project folder, add three Markdown files with a few ATX headings in each, open Story Studio, and arrow through the binder. Two: pick one of those files, hand-edit project.quillstory.json to register it as a Character element with a title and a relative path, reopen, and see the Characters group appear. Three: open the element, fill in Goal and Tags, save, then open the file as plain text in QUILL and read the front matter. Four: press Compile, see the whole book open in one tab, export it to your favorite format from episode twenty-two. Take your time with each step; this is practice, not a race. The point is to build a feel for the binder, for the sidecar, for the form, for the compile button, and to notice how each of them leaves the underlying files alone. By the end of the homework, you should be able to look at a project folder and predict, without opening QUILL, what the binder is going to show you.
Liam: Next episode we wire Story Studio to the Accessible Vault and the AIs, so your worldbuilding, your characters, and your prose can all reference each other through links and backlinks, and the agents from episode thirty-nine can do a continuity pass against the whole project. Same time, same channel, episode forty-four.
Jessica: I'm Jessica.
Liam: I'm Liam.
Jessica: Open the binder, and write the next sentence. Tomorrow's draft is one heading away.