Universal guided frontmatter editing — a toolbar button on every note card (FM), plus an optional inline fm edit block. Both are "fm-edit" (called "Changes" until 2026-10-06). No raw YAML editing required. Body content is never touched.
Three components work together:
| Component | File | Role |
|---|---|---|
.constraints.md |
per-folder or notebook root | field → widget type mapping (legacy source) |
constraints: in .{foldername}.md |
folder config | same mapping, higher priority (migration path) — see below |
GET /api/note/constraints |
app.py |
merges both sources, normalizes to widget-type strings, returns JSON |
GET /api/note/constraints-full |
app.py |
folder-config constraints: only, keeps required: instead of dropping it — see "Required fields" below |
FM button + panel (#nb-fm-edit-btn, labeled "FM") |
main.js |
toolbar button → fmUtils.form |
fm edit block |
nbweb-codeblocks.js |
inline alternative → fmUtils.form |
NbWeb.fmUtils |
nbweb-codeblocks.js |
shared form (form) and its parseFields/patch/widget helpers |
A dotfile (not indexed by nb) placed in a folder or notebook root. Standard YAML frontmatter — one line per field:
---
category: select exec,creative,tech
dept: select grip,camera,lighting,sound,makeup,wardrobe,sfx,transport,art
unit: select day,hour,week,flat
lock: bool
type: select resource,character,cast,location,shot,scene,storyline,report
---Constraint string format:
| Value | Widget rendered |
|---|---|
select a,b,c |
<select> dropdown |
bool |
checkbox |
date |
<input type=date> |
area |
<textarea> |
| (anything else or absent) | <input type=text> |
Scope and walk-up merge:
~/.nb/Takeout/resources/.constraints.md ← folder-scoped (wins)
~/.nb/Takeout/.constraints.md ← notebook-wide (fallback)
Flask walks from the note's directory up to the notebook root, merging files (folder entries override notebook entries for the same key). Notes in folders without a .constraints.md get only the notebook-wide constraints.
.constraints.md above is the legacy source. _load_constraints (app.py) merges it with a constraints: section inside the folder's own config dotfile (.{foldername}.md — the same file that already carries type: dotfile, and for a notebook root, whatever else that root config holds), and folder config always wins over .constraints.md for the same key — this is the migration path, not a competing convention. New schemas should be written here, not as a separate .constraints.md file:
# preciousfinds.ca:items/.items.md
---
type: dotfile
constraints:
status:
widget: select
values: [available, sold]
required: true
price:
widget: text
required: true
category:
widget: text
---values: must be a real YAML list, not a bare comma-string. _normalize_constraint does ','.join(str(v) for v in values) — if values is written as available,sold (a plain YAML scalar string) rather than [available, sold], that join iterates the string character by character, silently producing a garbled dropdown (select a,v,a,i,l,a,b,l,e,...). Caught live 2026-07-14 in exactly this shape — _load_constraints doesn't validate the type, it just iterates whatever it's given.
required: is the one sub-key /api/note/constraints cannot round-trip — see next section.
GET /api/note/constraints?selector=Takeout:shots/2
Returns a flat JSON object:
{
"type": "select resource,character,cast,location,shot",
"category": "select exec,creative,tech",
"unit": "select day,hour,week,flat",
"lock": "bool"
}Backend helper: _load_constraints(note_path: Path) → dict in app.py. Called by api_note_constraints(). Processes root-first so deeper entries win.
_normalize_constraint flattens every rich dict shape down to a bare widget-type string — required: (and anything else beyond widget/type/values) is simply dropped. That's correct for /api/note/constraints's one consumer (the FM panel only needs to know how to render a field it's already showing), but it means nothing can ask "which fields does this schema require?" through that endpoint.
api_note_constraints_full() answers that question — same _normalize_constraint widget-string values (can't drift from the FM panel's own rendering), paired with required: bool alongside each one:
GET /api/note/constraints-full?selector=preciousfinds.ca:items/gxx102.md
{
"status": {"widget": "select available,sold", "required": true},
"price": {"widget": "text", "required": true},
"category": {"widget": "text", "required": false}
}Deliberately reads only the note's own immediate folder's .{foldername}.md — not the full _folder_config cascade _load_constraints uses. constraints: is dict-valued, and dict-valued keys merge key-by-key across every config level (_merge_configs recurses into dicts rather than replacing wholesale — see the "Cascading config" pattern doc if one exists, or .rules/checks.md's note on why _add/_skip semantics aren't needed for dict keys). Tried against the full cascade, an unrelated notebook-or-global-level constraints: entry for a completely different schema (e.g. a service-pack client:/billing_type: block) leaked into an items/ folder's result — both incorrectly marked required: true for every item note, regardless of type. A folder-specific schema should show exactly what that folder declares, nothing inherited. If a future consumer genuinely needs the cascading/legacy-.constraints.md-merged behavior with required: preserved, that's new work, not something this endpoint currently does.
Also carries the same absolute-path selector bypass api_note() has (elif selector.startswith('/')) — the original /api/note/constraints doesn't have this, which matters for testing: hitting it with a notebook:path selector shells out through run_nb to nb's own index, which won't know about files a test fixture wrote directly to a synthetic tmp_nb. Tests for this endpoint live in nb-web-tests/test_constraints.py.
As of 2026-07-14 there are three, not two, and they cover genuinely different gaps:
- Direct Edit — raw markdown/frontmatter editing. Always available, no schema needed.
- FM button /
fm editblock (this doc, above) — schema-typed widgets for the note's own fields, then (since 2026-10-06) a row for each field/api/note/constraints-fulldeclares that the note doesn't have yet (italic label,data-fm-new), written only when given a value. Required fields get a*. Declared fields come from the note's own folder and its ownconstraints:/constraints_add:, never the full cascade (invariant 38); widget types still come from/api/note/constraints. - A type-specific "fill in everything" modal — shows every field a schema declares, required or optional, present or not, using
/api/note/constraints-fullto know what to show and which fields are required. First (and so far only) example: the item fields modal,_itemFieldsModalinnbweb-hledger.js— the "📝 Fields" button on the item specialty header (seedocs:dev/plugins/hledger/CLAUDE.md). Reuses the exact sameNbWeb.fmUtils.widget/patchhelpers as the FM panel, so widget rendering and the save mechanism can't drift between the two UIs.
(3) only works because of a fmUtils.patch() fix, not a new save path. patch()'s original behavior only ever matched and updated a field that already existed in the frontmatter — a missing key's regex simply never matched anything, so the update for that key silently no-op'd. That's why (2) can't fill in blanks: even if you built a form row for a genuinely-missing field, saving it would go nowhere. Fixed by falling through to append the key when the regex doesn't match (existing-key update path unchanged):
for (const [key, val] of Object.entries(updates)) {
const re = new RegExp(`^([ \\t]*${escaped(key)}):[ \\t]*.*$`, 'm');
if (re.test(head)) {
head = head.replace(re, `$1: ${val}`);
} else {
head += `\n${key}: ${val}`; // key didn't exist yet — append, don't drop
}
}This fixed the bug for both UIs — the FM panel could in principle now add a missing field too, if a future change ever built rows for schema fields it doesn't currently show.
_configFmToContent() in main.js re-serialises the entire frontmatter on
every save — not just the fields the form renders. The form manages a small set
of scalar fields (access, pinned, prepend_date, check, tag_color). All other
fields pass through the serialiser unchanged.
The trap: any catch-all that uses a template literal on an unknown value will
coerce nested objects to [object Object]:
lines.push(`${key}: ${v}`); // v = {journal: '...', commodity: 'CAD'}
// → "hledger: [object Object]" ← silent data lossThis destroyed the hledger:, cine:, constraints:, and types: blocks in
.Takeout.md when the user changed access: and saved (2026-06-21).
Fix in place: emitYaml(key, v, indent) — recursive, handles nested objects
as indented block YAML at any depth. Auto-quotes strings containing : or #.
Deeper fix (not yet done): fields the form never touches should be round-tripped as raw YAML text from the original file, not re-serialised from the parsed object. This preserves comments, quote style, and any YAML the parser may not perfectly reconstruct.
Flow-sequence items must be individually quoted, not just joined:
// wrong — element "festival, drama" becomes two items after round-trip
lines.push(`${key}: [${v.join(', ')}]`);
// right — quote elements that contain , : # or surrounding spaces
const items = v.map(item => needsQuote(item) ? `"${item}"` : item);
lines.push(`${key}: [${items.join(', ')}]`);Low-probability but silent: the YAML parses without error, just with wrong cardinality. Fixed 2026-06-21 (commit 681d136).
_saveNote() (raw markdown editor) called bustNoteCache() but not
bustNotebookConfigCache(). Editing a config dotfile (.{notebook}.md,
.nb.md) via the raw editor left stale access levels, plugin detection, and
inherited form values cached until page reload.
The config form save (_saveConfigForm) already busted both caches correctly.
The gap was the raw editor path only.
Fix: _saveNote() now checks if the saved filename starts with . and calls
bustNotebookConfigCache(NbNav.notebook) when true. Fixed 2026-06-21 (commit 681d136).
Added to #nb-preview-actions in index.html — sits left of Edit in the grey toolbar bar, always visible regardless of note scroll depth.
Visibility rules (wired in renderPreview in main.js):
- Hidden if
note.metais empty (no frontmatter) - Hidden if
note.locked(folder lock) or the note's ownlock:is set - Resets panel state on every note navigation
Flow:
- Click FM →
_toggleFmEditPanel(note, btn)→NbWeb.fmUtils.form(panel, selector, onDone) - Parallel fetch:
GET /api/note(raw) +/api/note/constraints(widgets) +/api/note/constraints-full(declared fields) parseFields(raw)extracts the ordered field list (skips block scalars); declared fields the note lacks followwidget(key, value, constraint)builds each input- Panel opens as
#nb-fm-edit-panel— a static HTML div flush under the toolbar - Save →
patch(raw, updates)→PUT /api/note→NbMain.bustNoteCache→ reload note - Cancel → panel hides, no write
The fm edit block calls the same form() with its own inline panel, so the two can't drift.
Exported from nbweb-codeblocks.js so main.js can reuse without duplication:
NbWeb.fmUtils = {
form(panel, selector, onDone), // the whole fm-edit form; onDone(saved) on save/cancel
parseFields(raw), // → [{key, value}] from frontmatter, block scalars excluded
patch(raw, updates),// → new raw string, body preserved exactly
widget(key, value, constraint), // → DOM input element with data-fm-key
}parseFields — uses indexOf('\n---', 3) (not regex) to locate the FM boundary, so the body is never ambiguous. Skips lines whose value is | or > (block scalar headers).
patch — slices raw into head (opening --- through FM content) and tail (\n--- onward including body). Only patches head; concatenates head + tail. The body survives unchanged even if it contains --- lines. Per-key: regex-replaces the line if the key already exists; appends a new key: value line to head if it doesn't (fixed 2026-07-14 — see "Three ways to fill in a note's fields" above; previously silently no-op'd for missing keys).
widget — returns a DOM element with data-fm-key set for Save to collect. Checkbox returns String(checked) ('true'/'false').
An alternative entry point — places the editor at a specific point in the note body, useful in long notes:
```fm
edit |Edit Resource
```The fm block dispatches to _loadFmEdit(el) when the body starts with edit (or the old changes). The label after | is optional (default fm-edit). Uses NbWeb.fmUtils.form. Renders a collapsed button that expands to the same form inline.
The toolbar FM button covers the universal case; the codeblock is optional.
| Class | Purpose |
|---|---|
#nb-fm-edit-panel |
Static toolbar panel — flush under grey bar, background: var(--bg2) |
.nb-fm-edit |
Block wrapper |
.nb-fm-edit-inline-panel |
Inline block panel |
.nb-fm-edit-form |
Two-column grid (label + widget) |
.nb-fm-edit-row |
display: contents — one field row |
.nb-fm-edit-label |
Monospace field key (.nb-fm-edit-new: a declared field the note lacks) |
.nb-fm-edit-actions |
Save/Cancel button row |
Preferred (folder config, current convention):
- Add a
constraints:section to the folder's own.{foldername}.md(or the notebook root's.{notebook}.mdfor notebook-wide fields) - Mark genuinely required fields with
required: trueif you also want/api/note/constraints-fullconsumers (a fill-in-blanks modal, a check script) to know about them — see "Three ways to fill in a note's fields" above values:must be a YAML list ([a, b, c]), not a bare comma-string — see the gotcha above- No restart needed — constraints are fetched live per note
Legacy (still supported, lower priority):
- Create
~/.nb/<notebook>/.constraints.mdwith relevant fields - Optionally add folder-scoped
.constraints.mdfor subfolder-specific dropdowns - Folder-config
constraints:for the same key will override this if both exist
Server rejects PUT /api/note if the session user lacks write access. The button is hidden for locked notes (note.locked). No other client-side gating — server is the authority.