Inventory of the 29 core plugins (22 done, 2 partial, 5 missing), 80 community plugins with a verdict each, a ranked roadmap with 8 ready briefs, the Bases spec (D1-D13) and 20 tasks, ASCII wireframes of the Bases screens (PaperCraft was not running). crates/bases is registered at layer L2. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
28 KiB
Bases: database views over notes (.base files)
Part of specs/plugins-parity/. The owner asked on 2026-10-21 for Obsidian's Bases core plugin
(Table, Cards, List) and the Map view (Obsidian ships it as the community plugin Maps). SPEC 6.1
lists "Bases/database views" and "Dataview-style queries" as deferred; this spec lifts both: a base
is the one query surface, Dataview-style queries become a .base the user can click through.
Reference: the official help vault ~/Documents/vaults/obsidian-help-all/en/Bases/ (Bases syntax.md,
Functions.md, Formulas.md, Views.md, Layouts/*.md). Where this spec and those pages differ,
the pages win for the file format (we must open files Obsidian wrote and Obsidian must open ours).
Problem
A vault of 15 000 notes with properties (author, year, status, coordinates) has no way to see
them as a table, sort them, filter them, or edit a value across many notes. The user must open each
note, or write a query in a plugin we do not have. The properties view (views/properties_view)
edits ONE note. The query embed (obsidian-basics task 19) shows search results, not columns.
Goal
A .base file opens as a tab with a toolbar (view menu, Sort, Group, Filter, Properties, Search,
New). Views are Table, Cards, List and (with the Maps feature on) Map, over the notes the file's
filters select. Cells show note properties, file properties and formulas; a cell of a note property
is editable and writes the note's frontmatter. The same base can be embedded in a note. Everything
is an engine command (SPEC 10), the evaluator is a pure crate, and a 15 000-note vault scrolls the
table at frame rate.
Non-Goals
- Kanban view (Obsidian 1.14+).
type: kanbanand unknown view types are kept in the file and shown as a placeholder card "view type not supported"; never dropped on save. - Writing formulas that mutate anything. The language is pure.
- JS plugin function libraries (Obsidian: "in the future, plugins will add functions").
- Bases over non-Markdown file properties beyond the
file.*set (no PDF metadata). - Real-time refresh of
file.backlinkson every keystroke (Obsidian does not either). - Vector map tiles / TileJSON styles in v1 (raster
{z}/{x}/{y}only). - Importing Dataview
dataviewblocks. A converter is a later task of the roadmap.
Design decisions
D1 - Crate and layering
New crate crates/bases, package notescraft-bases, layer L2 (add to xtask/src/layers.rs and
the root [workspace.dependencies]; this registration is owner/lead work, agents may not edit
Cargo.toml). Depends on notescraft-markdown (L0) only, plus thiserror. No I/O, no clock (the
caller passes now), no egui. It never sees notescraft-index types: the rows come through a trait
pub trait NoteSource {
fn len(&self) -> usize; // number of candidate files
fn row(&self, i: usize) -> Option<RowRef<'_>>; // lazy: no copies
}
pub struct RowRef<'a> { /* path, ext, size, ctime, mtime, tags, links (as &[String]),
props: &'a [(String, Value)], backlinks: &'a dyn Fn() -> Vec<String> */ }
The engine implements NoteSource on a snapshot of notescraft-index (D2). L2 may depend on L1, so
a later task may let bases use index types directly; the trait keeps the crate testable with a
Vec<TestRow> and keeps the evaluator free of index internals.
D2 - What the index must learn: properties
NoteInfo (crates/index/src/model.rs) holds path, title, aliases, tags, headings, block ids and
links. It does not hold frontmatter properties, so a base cannot filter on year > 2000
without reading 15 000 files. Add to the index, filled by update_note (the content is already in
hand): props: Vec<(String, PropValue)> per note (typed through the existing
notescraft_markdown::{PropValue, infer_type} and .obsidian/types.json), plus size, ctime,
mtime (from the vault scan; insert_info path). Limits: 200 properties per note, 4 KiB per
value string, list length 500; excess is dropped and flagged, never a panic. The on-disk index
cache (crates/engine/src/vault/index_cache/) gains the new fields under a bumped version (an old
cache is rebuilt, not migrated).
Nested maps in frontmatter stay Raw today (yaml::Value::Raw). Bases needs a.b access
(note.coordinates, file.properties.icon). v1: one level, a Raw map value is parsed lazily by
the evaluator when a formula touches .field.
D3 - The .base file format (YAML)
Exactly the schema of Bases syntax.md. Top-level keys, all optional, unknown keys preserved:
filters: # string | { and|or|not: [string | filter-object] }
formulas: # name: expression-string
properties: # id: { displayName: str } id = note.x | file.x | formula.x | bare x (=note.x)
summaries: # name: expression-string (custom summaries; `values` is the column's list)
views:
- type: table|cards|list|map|<other>
name: str
limit: n
filters: ... # AND-ed with the global filters
groupBy: { property: id, direction: ASC|DESC }
groupOrder: [value|null, ...]
order: [id, ...] # visible columns, in order
sort: [{ property: id, direction: ASC|DESC }, ...]
summaries: { id: Average|Min|Max|Sum|Range|Median|Stddev|Earliest|Latest|Checked|Unchecked|Empty|Filled|Unique|<custom> }
columnSize: { id: px } # table
rowHeight: short|medium|tall|extra-tall # table
cardSize: n image: id imageFit: cover|contain imageAspectRatio: n # cards
markers: bullets|numbers|none indentProperties: bool separator: "," # list
coordinates: id markerIcon: id markerColor: id center: [lat,lng] zoom: n minZoom: n maxZoom: n height: n tiles: url # map
Rules:
- Parse is lossless for keys we do not model (kept in an ordered
extramap per object) and emits keys in the original order, so Obsidian's own additions survive a save. Comments in the YAML are not preserved (Obsidian drops them too); this is documented in the settings card. - A parse error opens the tab in a "raw YAML" fallback with the error line, never a crash, never an empty file written back.
- YAML subset: block maps and lists, flow lists
[a, b], plain / single- / double-quoted scalars,|and>block scalars,null, numbers, booleans, comments ignored. No anchors, no tags, no multi-document. The existingnotescraft_markdown::yamlhas NO nested maps (it keeps themRaw), so the crate carries its own small parser and emitter (yaml.rs, ~400 lines). Owner question Q1: approve a YAML crate instead. - Limits: file at most 1 MiB, depth 12, 100 views, 200 formulas, expression at most 4 KiB, expression AST depth 64 (iteration or a depth counter, never unbounded recursion).
D4 - Property ids and references
note.x / bare x -> the frontmatter property x. file.name|basename|path|folder|ext|size|ctime| mtime|tags|links|embeds|backlinks|properties|file. formula.x. this.* (D8). note["x y"] for
keys with spaces. Resolution order for a bare identifier: note, then error unknown_property
(null at filter time, not an error; a formula referencing a missing property is null).
formula.a may use formula.b; a cycle is detected before evaluation and reported once per
formula (formula_cycle), the cell shows the error chip, the rest of the table still renders.
D5 - The expression language
Tokens and grammar follow the docs: literals (strings in ' or ", numbers, true|false|null),
arrays [a, b], objects {k: v} (needed for [latitude, longitude] and list()), member access
a.b, a["b"], a[0], calls f(x) and methods x.f(y), lambdas implicit in filter|map|reduce|sort
with the names value, index, acc bound (as in the docs). Precedence (high to low): member/call,
unary ! -, * / %, + -, comparisons < <= > >=, == !=, &&, ||. if(c, a, b) is a
function with lazy branches.
Types: Null, Bool, Number(f64), String, Date{ms, has_time}, Duration{ms}, List, Object, Link{target, display}, File{path}, Regex, Html, Image, Icon. Rules:
- Date +/- duration string (
"1M","2 hours","1d"; units y M w d h m s and long forms) -> Date; Date - Date -> Number (ms);duration('5h') * 2-> Duration; scalar * Duration is a type error. Month arithmetic clamps to the last day (Jan 31 + 1M = Feb 28/29).bases(L2) cannot depend onnotescraft-templates(also L2), so it carries its own civil-date helpers (days_from_civil, ~60 lines). ==between Link and File compares the resolved path; between Links the same file, else the link text; Strings compare by code point; mixed types are unequal, never an error.- Ordering:
null < bool < number < string < date < list; within a type natural order; strings compare case-insensitively then by code point. Sort is stable. - Truthiness (
isTruthy): null, false, 0, "" and empty list are falsy. - Errors never panic and never abort the table: an error value renders as a red chip with the message; in a filter an error counts as false and is counted in the status line ("3 errors").
D6 - The function library (all of Functions.md)
| Group | Functions |
|---|---|
| Global | escapeHTML date duration file html if image icon link list max min now number today random |
| Any | isTruthy isType toString |
| Date | fields year month day hour minute second millisecond; date() format() time() relative() isEmpty() |
| String | field length; contains containsAll containsAny endsWith isEmpty lower replace repeat reverse slice split startsWith title trim |
| Number | abs ceil floor isEmpty round toFixed |
| List | field length; contains containsAll containsAny filter flat isEmpty join map reduce reverse slice sort unique |
| Link | asFile linksTo |
| File | fields name basename path folder ext size properties tags links ctime mtime; asLink hasLink hasProperty hasTag inFolder |
| Object | isEmpty keys values |
| Regexp | matches (literal /re/flags); the engine regex is notescraft-regex (workspace crate), no regex dependency |
Each function has a table-driven test: arity, a normal case, a null input (returns null or the
documented default), a type error. file.hasTag("a") matches #a and #a/b (nested), as search does.
file.inFolder("x") matches a folder and its descendants. random() takes its seed from the
caller (Env::seed), never from the OS, so results are reproducible in tests.
Default summaries (Average Min Max Sum Range Median Stddev Earliest Latest Checked Unchecked Empty Filled Unique) are implemented in Rust, not as formulas; custom summaries are formulas over values.
D7 - Evaluation and query
pub struct Query<'a> { base: &'a Base, view: usize, this: Option<RowRef<'a>>, now_ms: i64, seed: u64 }
pub struct QueryResult { columns: Vec<Column>, groups: Vec<Group>, total: usize, errors: usize }
pub struct Group { key: Value, rows: Vec<RowId>, summaries: Vec<Value> } // RowId = index into the source
Pipeline: (1) candidates = every file the index lists (Obsidian: no from, a base selects from the whole vault, attachments included);
(2) global filters AND view filters; (3) sort (multi-key, stable);
(4) group (groupBy + groupOrder, null key = "no value"); (5) limit; (6) summaries.
Column values are computed lazily: the result holds RowIds and QueryResult::cell(row, col)
evaluates on demand (memoised per frame by the UI). Sorting evaluates only the sort keys, filters
only what they reference (AST property-dependency analysis drives which props to touch).
file.backlinks and file.properties are computed only when referenced; a backlinks reference
marks the result slow and the UI shows a spinner row.
Caching: the engine keeps (base file hash, view, index generation) -> QueryResult and invalidates on
the index generation counter; a typing note does not re-run a base that does not select it
(dependency: the set of paths a result contains plus the set of properties it reads).
D8 - this
Open as a tab: this = the base file. Embedded in a note: this = the embedding note. In a sidebar
panel (roadmap item, not v1): the active note. The caller passes this: Option<RowRef>.
D9 - Editing cells writes the note
base.row.set {path, property, value} writes ONE frontmatter key of ONE note through the existing
vault/properties.rs::set (validates, typed by .obsidian/types.json, lands in the open buffer as
one undoable edit or on disk through Session::write_note). Rules: only note.* (bare) properties
are editable; file.* and formula.* cells are read-only except file.name (rename through
note.rename, link-updating); multi-cell paste is a batch of single sets applied with
batch.apply-style all-or-nothing preflight; a failed validation changes nothing and shows the error
in the cell. Undo in a table = the buffer undo of each touched note (one undo step per cell edit).
New in the toolbar runs note.create in the folder the filters imply (file.inFolder("x") ->
x/), with the equality filters (type == "book") pre-filled as frontmatter.
D10 - Engine commands
| Id | Params | Result |
|---|---|---|
base.list |
none | [{path, name, views}] every .base in the vault |
base.read |
path |
the parsed base as JSON (filters, formulas, properties, summaries, views) |
base.query |
path OR yaml (inline), view (name or index, default 0), this? path, offset?, limit? |
{columns, total, errors, groups:[{key, count, summaries}], rows:[{path, cells}]} for the slice; limit default 200, max 2000 |
base.properties |
path? |
every property key in the vault with its type and use count (feeds the Properties picker) |
base.write |
path, yaml |
validated write through craft-atomic; refused when parse fails |
base.view.add / base.view.duplicate / base.view.remove / base.view.set |
path, view, fields |
edits the file (lossless, D3) |
base.row.set |
path (the note), property, value, base? |
{changed} (D9) |
base.create |
path, from? (folder or tag to seed a filter) |
new .base with one Table view |
Read ids (base.list read query properties) go to the READ list of ai/assistant_gate.rs; write
ids to the WRITE list. The MCP server (crates/mcp) exposes base_query read-only.
D11 - UI
New tab kind TabKind::Base for the base extension (widgets/tabs/kind.rs), view in
crates/ui-egui/src/views/base_view/ (folder; max 12 loose files each):
toolbar.rs: view menu (switch, add, duplicate, rename, delete, drag to reorder), Results, Sort, Group, Filter, Properties, Search, New. Popovers use the existingwidgetspopup patterns.table.rs: virtualised (egui::ScrollArea::show_rows) with fixed row heights fromrowHeight; sticky header; sortable by clicking a header (adds/flips the first sort); column resize (columnSize) and drag-reorder (order); cell selection, copy/paste, the shortcuts ofTable view.md; group headers with count and group summary; summary row at the bottom.properties_popover.rs: the picker of the screenshots: search box, checkbox per property (note, file, formula), drag to reorder, "New formula" at the end.filter_builder.rs: groups All / Any / None, rowsproperty | operator | value, operators by property type (text: is, is not, contains, does not contain, starts with, ends with, is empty; number: = != < <= > >=; date: on, before, after, relative; checkbox: is true/false; list/tags: contains, does not contain, is empty), nested groups, "advanced" toggle showing the raw expression.cards.rs,list.rs: per the docs; cards load cover images through the existing image cache.cell.rs: renders by type: text, number right-aligned, checkbox, date, tags as chips, links clickable (open the note, Cmd-click new tab), images, errors.- Status line (bottom of the tab):
N results,E errors, and for the selected row the usual note stats (backlinks, words, characters), as in the screenshot. - Phone width: Table becomes a list of cards with the first property as title (compact shell).
- All strings through
text/views/base_view/tables and[ui.base-view]at the END ofen.toml. - Designs: PaperCraft was not running (2026-10-21), so wireframes are in
design/plugins/WIREFRAMES.mdand PNGs are open task 20; the implementer renders the tab through thesnapshotexample and compares with the owner's screenshots (Table, Properties picker, Map popup).
D12 - Embedding
- A fenced block with language
baseholds inline YAML; it renders as a card with the views as tabs in its header (the first view is selected) in the note, read-only for structure, cells still editable. ![[x.base]]and![[x.base#View name]]embed the file (existing embed pipeline,views/note_view/blocks/embed.rs; addbaseto its extension dispatch).- Embedded height:
heightfrom the view, default 320 pt, internal scroll.this= the embedding note. - Cycle guard: a base embedded inside a base-visible note cannot embed itself; embed depth 3 (as notes).
D13 - Map view and the Maps feature (network policy)
What the Map view needs: a coordinates property per note, a marker icon and colour, a tile background, pan/zoom, clusters, a popup card with the view's properties, "embedded height".
Where it lives. The wasm plugin ABI (crates/plugins) offers log vault_read vault_write vault_list search links backlinks active_note insert_text notice now and NO drawing, NO network,
NO views. A wasm "Maps plugin" is therefore impossible without a new UI-contribution ABI. Decision:
Maps is a first-party optional core feature, shown as a card in Settings > Core plugins ("Maps",
off by default, like Calendar/Templates/Diagrams/Whiteboard: core_plugins() in
views/settings_view/categories.rs). With it off, type: map views show a placeholder with an
"Enable Maps" button; the .base stays valid. Code: pure maths in crates/bases/src/map/ (data
model, projection, clustering, no I/O), the tile fetcher and cache in
crates/engine/src/bases/tiles.rs (needs ureq, already approved), the view in
views/base_view/map/.
Data model (pure, notescraft_bases::map):
pub struct LatLng { lat: f64, lng: f64 } // lat in [-85.0511, 85.0511], lng in [-180, 180]
pub struct Marker { row: RowId, at: LatLng, icon: Option<String>, color: Option<Color> }
pub fn parse_coordinates(v: &Value) -> Option<LatLng> // "lat, lng" | ["lat","lng"] | [lat, lng] | {lat,lng}
pub fn project(at: LatLng, zoom: u8) -> (f64, f64) // Web Mercator world pixels, 256 px tiles
pub fn cluster(markers: &[Marker], zoom: u8, radius_px: f64) -> Vec<Cluster> // grid clustering
pub fn fit(markers: &[Marker], viewport_px: (f64, f64)) -> (LatLng, u8)
Invalid or out-of-range coordinates are skipped and counted ("12 notes without coordinates"), never
clamped (AGENTS.md: invalid input returns Err/skip, no silent fixing). Clusters: grid of
radius_px (default 48) cells in world pixels at the current zoom; a cluster shows the count; a click
zooms to its bounds; at max zoom it opens a list popup. 360 markers cluster in < 1 ms.
Tile policy (network content). OpenStreetMap's tile servers are a volunteer service with a usage
policy (operations.osmfoundation.org/policies/tiles/): valid identifying User-Agent, attribution,
caching, no bulk download, no heavy use. NotesCraft therefore:
- Fetches NOTHING until the user enables Maps in Settings, and the first enable shows a one-time notice: "Maps downloads map tiles from tile.openstreetmap.org. Your viewed area is sent to that server." with Enable / Cancel. A second switch "Use tiles" can be turned off to keep the grid.
- Fetches only the tiles of the current viewport (plus a 1-tile margin), never prefetches or crawls, at most 2 concurrent requests and at most 1 request/s sustained beyond the first burst of 8 (token bucket), on a worker thread, results polled each frame.
User-Agent: NotesCraft/<version> (+<repo URL>),Referernot sent, HTTPS only,ureqwith rustls, 5 s timeout, response at most 256 KiB, must beimage/png, decoded with the existing image decoder; anything else is discarded (hostile input).- Caches under
.notescraft/cache/tiles/{provider}/{z}/{x}/{y}.png, honouringCache-Control/Expireswith a floor of 7 days (OSM asks for >= 7), size cap 200 MiB (default, setting) with least-recently-used eviction;.notescraft/cacheis private and never synced (add to the sync ignore list). Writes go throughcraft-atomic. - Attribution "© OpenStreetMap contributors" always visible in the map's corner as a link to
openstreetmap.org/copyright; never hideable. - The tile URL template is a setting (
tiles, defaulthttps://tile.openstreetmap.org/{z}/{x}/{y}.png); the notice names the host; onlyhttps://templates with{z},{x},{y}are accepted. Vector/TileJSON URLs from the docs (tiles.openfreemap.org/styles/...) are refused in v1 with a clear message (Non-Goal). - Offline or failed fetch or "Use tiles" off: a neutral grid fallback (graticule lines every N degrees at the current zoom with lat/lng labels, land-less) and the markers still work; a chip "Offline: tiles not loaded". A tile already cached is used offline.
- Honesty in the UI: the Maps card in Settings states the host, the cache size with a "Clear cache"
button, and that nothing is sent when the map is closed.
Engine commands:
maps.status(enabled, cache bytes, host),maps.clear_cache; the setting keys inprefs/*_config.rs(maps.enabled,maps.tiles,maps.tile_url,maps.cache_mb).
View settings: coordinates property, marker icon property (a Lucide name; we ship the icons in
assets/icons, unknown names fall back to a pin), marker colour property (#hex, rgb(), named
CSS colours; var(--color-x) maps to the theme tokens red orange yellow green cyan blue purple pink of the active theme), center, zoom, min/max zoom, height, tiles. Popup card: opens on marker
click, shows the view's order properties (title = first), "Open note" button.
Interaction: drag pan, scroll/pinch zoom around the pointer with the 150 ms ease, double-click zoom,
+/- buttons, right-click "Copy coordinates" (lat, lng, the format the docs say to copy).
User stories
- As a reader of books, I open
Books.baseand see title, author, year, genre, pages and tags as columns, sorted by year, with the 30 results counted. - I open the Properties picker, tick
pages, untickgenre, dragtagsbeforeyear; the fileorder:changes and Obsidian shows the same columns. - I add a filter
year >= 2000 and genre is "sci-fi"in the filter builder and see the YAML. - I edit a status cell in place; the note's frontmatter changes and Cmd-Z undoes it.
- I add a formula
ppu = (price / age).toFixed(2)and sort by it. - I switch the same base to Cards with the
coverproperty as the image. - I group by
statusand see a count and an Average ofpagesper group. - I turn on Maps, open
Places.basein the Map view, see 360 markers cluster, click one and read its properties in a popup, click "Open note". - I embed
base ...in a project note withfile.hasLink(this.file)and see its backlinks-like list. - Offline, the Map still shows markers over the grid.
Acceptance Criteria
Format:
- Every example in
Bases syntax.md,Formulas.mdand the fourLayouts/*.mdparses; saving without edits returns the file byte-equal modulo YAML comments (test over the help vault's embedded examples copied intocrates/bases/tests/fixtures/). - An unknown top-level key, unknown view key and unknown view type survive a view edit.
- A malformed file shows the raw-YAML fallback with line and column; no write happens.
Evaluator:
4. Every function of D6 has a test; now() + "1 day", today() + "7d", date("2024-12-01") + "1M" + "4h" + "3m" == 2025-01-01 04:03:00, (now() + "1d") - now() == 86 400 000 (docs).
5. price.toFixed(2), if(price, ...), list(type)[0].asFile().properties.icon,
file.hasLink(this.file), link(file.name) evaluate as the docs say.
6. A formula cycle, a type error, a division by zero and a 64-deep expression never panic; each
gives an error value; cargo test -p notescraft-bases includes a fuzz-ish test over 10 000 random
expression strings that asserts no panic and a bounded time.
Engine:
7. base.query over a TempVault of 50 notes returns the rows, order, groups, summaries and counts
of a golden JSON; a limit and offset slice.
8. base.row.set changes one frontmatter key, leaves the body byte-identical, honours the property
type, refuses file.size and formula.x, refuses an invalid date, and is undoable when the note is open.
9. base.* write ids are blocked in a read-only session and listed in the assistant gate.
Performance (15 000-note synthetic vault, release, M-class laptop; numbers recorded in the test):
10. Cold base.query of a 3-column table with one property filter and one sort: < 60 ms.
11. Warm re-run after one note changed: < 10 ms. Index property fill adds < 15% to the
full-index time and < 40 MB RSS at 15 000 notes x 8 properties.
12. The table frame (virtualised, 40 visible rows) paints in < 4 ms median; scrolling 15 000 rows
never allocates per-row cells outside the visible window (asserted by a counter in the test).
13. file.backlinks in a column of 15 000 rows computes off the UI thread and the table stays interactive.
UI:
14. A .base opens as a tab with the toolbar; Sort, Group, Filter, Properties popovers change the
file and the view; the kittest drives them with real events.
15. Cards and List render the same query; the Map view renders markers, clusters, the popup and the
attribution; with Maps off the placeholder offers Enable.
16. ![[x.base]] and ```base render inside a note.
17. Phone width renders the Table as stacked cards.
Maps:
18. No socket is opened while Maps is off (test with a counting fetcher trait).
19. Tile fetcher: the User-Agent, the 2-concurrency limit, the 1 req/s bucket, the 256 KiB cap, the
content-type check, the cache hit and the 7-day floor are unit-tested against a local stub HTTP
server (copy the publish_stub pattern); the grid fallback renders when the stub is down.
Constraints
AGENTS.md: never crash (no unwrap/expect/panic, no[i]on input-derived indices), files under ~400 lines,thiserror, rustdoc on every public item,clippy::pedanticclean, every disk write throughcraft-atomic, strings inen.toml, no new dependency without owner approval (this spec needs none: YAML is in-house,ureqis approved,notescraft-regexis in the workspace).- Layers:
basesL2 (markdown only below it); engine glue incrates/engine/src/bases/; UI L6. crates/engine/src/holds 12 loose files: the module is a new folderbases/declared with one line.crates/ui-egui/src/views/is a directory of folders;base_view/is a new folder.- Obsidian compatibility: a
.basewritten by us opens in Obsidian 1.9+; unsupported keys survive.
Open Questions (owner)
- YAML: approve a YAML crate (
saphyr/yaml-rust2) for.baseor keep the in-house subset (recommended: in-house, 400 lines, no new dependency; risk: exotic YAML from Obsidian). - Maps is not a wasm plugin. OK to ship it as an optional core feature behind a Settings card (recommended), or build a UI-contribution ABI for wasm plugins first (large, see roadmap P-ABI)?
- Tile provider default: OSM standard tiles (policy-compliant at personal scale, shown with notice) or default to the grid until the user pastes a tile URL (recommended for a shipped app with many users: OSM bans heavy default use)?
- Index growth: storing all properties in the index costs memory; OK, or store only the properties named by open bases (lazy, recommended later optimisation)?
- Kanban view: in scope for a later task? (Obsidian 1.14.)
- Replace the
queryembed and the Dataview-style queries with bases once Table lands?
Tasks
See tasks.md: five phases, 20 tasks.
Designs
design/plugins/WIREFRAMES.md (PaperCraft was not running on 2026-10-21; no PNGs yet).