diff --git a/SKILLS/nc-design-artcraft-integration/SKILL.md b/SKILLS/nc-design-artcraft-integration/SKILL.md index 252a793d..2338dbbc 100644 --- a/SKILLS/nc-design-artcraft-integration/SKILL.md +++ b/SKILLS/nc-design-artcraft-integration/SKILL.md @@ -128,3 +128,7 @@ For substantial work, route through `nc-arch` and `nc-hl-design`, then the relev - Are missing capabilities stated in the UI and in `docs/gaps.md`? - Are license, attribution, build, and test implications recorded? - Are directory listings kept distinct from implementation evidence? + +## Reference: WordCraft and NoteCraft document models + +See `references/wordcraft_x_notecraft_dom.md` for the comparison of the two document models and what it means for caret, runs and inline objects. diff --git a/SKILLS/nc-design-artcraft-integration/references/wordcraft_x_notecraft_dom.md b/SKILLS/nc-design-artcraft-integration/references/wordcraft_x_notecraft_dom.md new file mode 100644 index 00000000..7d20050c --- /dev/null +++ b/SKILLS/nc-design-artcraft-integration/references/wordcraft_x_notecraft_dom.md @@ -0,0 +1,59 @@ +# WordCraft and NoteCraft: document models compared (2026-11-12) + +Sources: +- NoteCraft: `crates/formats/src/markdown.rs` (`Span`, `Block`), `docs/dom_serialization/page-model.md`, + `SKILLS/nc-dom-ser/SKILL.md`. +- WordCraft: `crates/doc/src/block.rs`, `crates/doc/src/para.rs` (`Paragraph`), `crates/doc/src/para/run.rs` + (`Run`), `crates/doc/src/para/inline_object.rs` (`InlineObject`), `crates/doc/src/position.rs` (`Pos`), + `crates/doc/src/lib.rs` (`Document`). + +## Side by side + +| Concern | NoteCraft | WordCraft | +|---|---|---| +| Text container | `Paragraph(Vec)`: each block owns its styled spans | `Paragraph { text: String, runs, objects, props, mark }`: one text string per paragraph | +| Formatting unit | `Span { text, bold, italic, strike, underline, script, code, math, font_size, font_family, link }` | `Run { len, props: CharProps }`: a byte length over the paragraph text, with a full character property set | +| Formatting storage | flags on each span, written into the file per span | properties in a shared style sheet; runs refer to them | +| Inline objects | images are separate blocks (`Block::Image`) | objects sit in the text, marked by `OBJ` (U+FFFC); `InlineObject::Image`, fields, controls | +| Breaks | line and page breaks are parts of the text or separate blocks | `LINE_BREAK` (U+000A), `PAGE_BREAK` (U+000C) in the text | +| Block kinds | Heading, Paragraph, Item, Quote, Code, MathBlock, Table, Rule, Image: one enum | `Block::Para` and `Block::Table`; heading and list are paragraph properties | +| Position | a block index and a character offset in that block's text (`ui.caret`, `Caret { block, text, selection }`) | `Pos { story, path, off }`: a story, a path to the paragraph or cell, and a byte offset | +| Nesting | tables hold spans per cell; no nested boxes | tables and text boxes nest (`MAX_TEXT_BOX_DEPTH = 4`) | +| Sharing | blocks are owned values | blocks are `Arc`; undo copies only what changes | +| File format | HTML and CMGFM, one page per folder, both formats must match (`nc-dom-ser`) | DOCX (OOXML), with parts keyed by number | + +## What this means + +1. **Our caret is per block; theirs is per paragraph stream with runs and objects inside it.** A caret + in WordCraft steps across run and object boundaries without special cases, because they are offsets + into one string. In NoteCraft, each block is a unit, so a move across a block edge needs its own rule + for every block kind. This is the root of the Up, Down and Left landing bugs. +2. **Our formatting is coarse.** Flags per span can express what we ship today. They can't express + shared character styles, or a run-level change that doesn't create a new span. Changing a style on a + part of a word means splitting spans in our model, and the file grows. +3. **Inline objects are blocks for us.** An image in the middle of a sentence is a block break. In + WordCraft it is one character in the text. +4. **Positions are different kinds of address.** Ours is `(block, char)`, with the char counted in + characters. WordCraft's is `(story, path, byte)`, with the byte counted in UTF-8 bytes and snapped to + clusters by the layout. Any shared code must agree on one of these, and convert at the edge. + +## Options for NoteCraft + +- **A. Keep our model.** Fix the caret rules per block kind. Costs little now, and it's where the + current bugs come from. +- **B. A single text stream per paragraph, with runs and object markers,** kept in our own model and + written to both formats. This solves the caret problem at its root. It changes the page file, the + parity rules, and every reader and writer. It needs an owner decision and an ADR. +- **C. Adopt WordCraft's document crate.** Not recommended: it models DOCX, and its story, path and + style sheet are not the NoteCraft folder format. + +## Decision needed + +Owner: choose A now and B later, or B now. Until then, new caret and text code should keep positions as +`(block, char)` and not assume a block boundary is a text boundary, so that option B stays open. + +## Related + +- `nc-dom-ser`: the page model and the parity rules that option B would change. +- `nc-uiux-bidi`: the caret model section and `references/caret-options.md`. +- `nc-design-artcraft-integration`: the reuse and integration guidance this comparison supports. diff --git a/SKILLS/nc-dom-ser/SKILL.md b/SKILLS/nc-dom-ser/SKILL.md index d8641edc..df52343e 100644 --- a/SKILLS/nc-dom-ser/SKILL.md +++ b/SKILLS/nc-dom-ser/SKILL.md @@ -122,3 +122,15 @@ fn md_round_trip() { --- **Last updated:** 2026-10-24 | **Blocking requirement:** Yes + +## Reference: WordCraft and NoteCraft document models + +See `references/wordcraft_x_notecraft_dom.md` for the comparison of the two document models and what it means for caret, runs and inline objects. + +## Reference: object types + +See `references/object-types.md` for the object identifiers, Rust names, WordCraft and OneNote equivalents, and the room for future object kinds. + +See `references/naming-convention.md` for the rules that the identifiers, Rust names, HTML classes and data attributes follow. + +RFC 0001, the proposed NoteCraft document model with its integration appendix: `references/rfc-0001-notecraft-dom.md`. diff --git a/SKILLS/nc-dom-ser/references/naming-convention.md b/SKILLS/nc-dom-ser/references/naming-convention.md new file mode 100644 index 00000000..7f8f68b2 --- /dev/null +++ b/SKILLS/nc-dom-ser/references/naming-convention.md @@ -0,0 +1,76 @@ +# Naming convention for page objects (proposal, 2026-11-12) + +Status: proposal for owner review. It applies to new object types and to the table in +`object-types.md`. It does not rename anything already in a file. + +## Terms + +"DOM" in this repo means the semantic page model, not the browser DOM (see +`docs/dom_serialization/README.md`). WordCraft's `crates/doc` uses overlapping names (`Block`, `Frame`, +`ListItem`) with different meanings, so every name in this convention is NoteCraft's own. Research +context: `dom-naming-context.md` (untracked). + +## Why + +Neither WordCraft nor craft-libs has a written naming scheme for its document model. WordCraft's names +are the Rust type names in `crates/doc`, which are good, but they don't say how a name is stored in a +file. NoteCraft stores pages as HTML and CMGFM, so every name must work in both formats and never change +once written. This convention fixes that. + +## The three names of one object + +Each object type has three names. They are kept in one place (`object-types.md`). + +| Name | Rule | Example (`inline.image`) | +|---|---|---| +| Identifier | `family.kind`, lower case, dot separated, stored in the file | `inline.image` | +| Rust name | PascalCase, WordCraft's word where it has one | `Image` | +| Format form | the HTML tag and class, and the CMGFM syntax, from the table | `img`, `![alt](src)` | + +The identifier is the contract. The Rust name and the format form may change with the code; the +identifier may not. + +## Rules + +1. **Lower case, dot separated, no spaces.** `inline.image`, not `Inline-Image` or `image`. +2. **Families are fixed:** `block`, `inline`, `container`, `onenote`, `ext`. A new family needs an ADR. +3. **Kind words are singular nouns**, taken from WordCraft where it has the object (`image`, `field`, + `equation`, `control`, `bookmark`, `comment`), otherwise from OneNote (`ink`, `tag`, `pagelink`), and + otherwise a plain English noun. +4. **Start and end markers** share the kind and add `.start` or `.end`: `inline.bookmark.start`. +5. **No format words in identifiers.** `html`, `md`, `cmgfm`, `css` do not appear. The format forms + live in their own column. +6. **Class names follow the identifier.** The HTML class for an object is the identifier with dots + turned into hyphens and the prefix `nc-`: `inline.image` becomes `nc-inline-image`. The `nc-` + prefix keeps our classes apart from the reader's own CSS. +7. **Data attributes use the same words.** `data-nc-kind="inline.image"`, so a reader can check the kind + without parsing the class. +8. **Opaque and extension names are reserved.** `ext.opaque` is the fallback. `ext..` is + reserved for types from other apps; the vendor is a short lower-case name, for example `artcraft`. +9. **Never reuse an identifier.** A removed type keeps its identifier in the table, marked retired, so an + old file still reads. +10. **Renames are new identifiers.** The old one is kept as an alias in the reader until a migration + exists. The alias is written down in `object-types.md`. + +## Worked example + +Adding an inline callout (a highlighted note inside a paragraph): + +- Identifier: `inline.callout` +- Rust name: `Callout` (no WordCraft equivalent; OneNote has no such object, so the plain noun is used) +- HTML: `…` +- CMGFM: no native syntax, so the object is written as a `ext.opaque`-style HTML fragment in CMGFM and + listed in the parity exceptions with its reason. Parity exceptions need an owner decision. +- Table row, round-trip test, parity check, opaque fallback test: as in `object-types.md`, "Checks + before a new object type lands". + +## Checks + +1. The identifier follows rules 1 to 5 and is in `object-types.md`. +2. The class and data attribute follow rules 6 and 7, and a test reads them back. +3. A retired or renamed identifier has its alias row. + +## Decision needed + +Owner: accept this convention as NoteCraft's naming rule for page objects, and decide who owns the +`object-types.md` table (suggested: `nc-dom-ser`). diff --git a/SKILLS/nc-dom-ser/references/object-types.md b/SKILLS/nc-dom-ser/references/object-types.md new file mode 100644 index 00000000..357f7a02 --- /dev/null +++ b/SKILLS/nc-dom-ser/references/object-types.md @@ -0,0 +1,90 @@ +# Object types: identifiers, names and room to grow (2026-11-12) + +Status: proposal for owner review. Nothing here changes the file format until an ADR accepts it. + +## Naming rules + +1. **Identifier.** Every object type has a stable identifier in the form `family.kind`, lower case, dot + separated: `inline.image`, `block.table`, `onenote.ink`. The identifier is what the file stores. It + never changes once written. A renamed type gets a new identifier and the old one stays readable. +2. **Rust name.** PascalCase, the same word WordCraft uses where WordCraft has the object (`Image`, + `Field`, `Equation`, `Opaque`). Where WordCraft has no object, use the OneNote word, or a plain English + one. +3. **Family.** `block` for a whole block, `inline` for an object inside a paragraph's text, `container` + for a box, `onenote` for a type that exists because of OneNote import or OneNote's model, `ext` for a + type from outside NoteCraft (see Future room). +4. **Paired markers.** A start and an end marker share a family and end in `.start` and `.end`, as + WordCraft's `FieldStart`/`FieldEnd` do. +5. **No format-specific names.** The identifier does not say HTML or CMGFM. Each format has a mapping + column below. + +## The table + +| Identifier | Rust name | WordCraft | OneNote | NoteCraft today | HTML | CMGFM | Layer | +|---|---|---|---|---|---|---|---| +| `block.paragraph` | `Paragraph` | `Block::Para` | paragraph | `Paragraph` | `p` | text | model, formats | +| `block.heading` | `Heading` | paragraph style | heading 1-6 | `Heading` | `h1`-`h6` | `#` | model, formats | +| `block.item` | `Item` | list paragraph | bullet, number, to-do | `Item` | `ul`, `ol`, `li` | `-`, `1.`, `- [ ]` | model, formats | +| `block.quote` | `Quote` | paragraph style | quote | `Quote` | `blockquote` | `>` | model, formats | +| `block.code` | `Code` | paragraph style | code (OneNote has no code block) | `Code` | `pre` | fence | model, formats | +| `block.math` | `MathBlock` | `inline.equation` in a block | equation | `MathBlock` | `p.math` | `$$` | model, formats | +| `block.rule` | `Rule` | paragraph border | line | `Rule` | `hr` | `---` | model, formats | +| `block.table` | `Table` | `Block::Table` | table | `Table` | `table` | GFM table | model, formats | +| `block.container` | `Container` | text box | box and text box | `Container` (`div.nc-box`) | `div.nc-box` | `div.nc-box` | model, formats | +| `inline.image` | `Image` | `InlineObject::Image` | picture | `Image` block (not inline yet) | `img` | `![]()` | model, formats | +| `inline.graphic` | `Graphic` | `InlineObject::Graphic` | picture in text | not yet | `img` | not yet | model | +| `inline.shape` | `Shape` | `InlineObject::Shape` | drawing shape | not yet | `svg` | not yet | model | +| `inline.group` | `Group` | `InlineObject::Group` | group of drawings | not yet | `g` | not yet | model | +| `inline.field` | `Field` | `InlineObject::Field` | date, page number | date stamp (UI only) | `span.field` | not yet | model | +| `inline.field.start` | `FieldStart` | `FieldStart` | n/a | n/a | n/a | n/a | model | +| `inline.field.end` | `FieldEnd` | `FieldEnd` | n/a | n/a | n/a | n/a | model | +| `inline.control` | `Control` | `ControlStart`/`ControlEnd` | n/a (OneNote has none) | not yet | `span.control` | not yet | model | +| `inline.bookmark.start` | `BookmarkStart` | `BookmarkStart` | n/a | n/a | `a#name` | `` | model | +| `inline.bookmark.end` | `BookmarkEnd` | `BookmarkEnd` | n/a | n/a | n/a | n/a | model | +| `inline.comment.start` | `CommentStart` | `CommentStart` | n/a | n/a | not yet | not yet | model | +| `inline.comment.end` | `CommentEnd` | `CommentEnd` | n/a | n/a | not yet | not yet | model | +| `inline.equation` | `Equation` | `InlineObject::Equation` | equation | inline math `Span` | `span.math` | `$...$` | model, formats | +| `inline.ruby` | `Ruby` | `InlineObject::Ruby` | n/a | not yet | `ruby` | not yet | model | +| `inline.tab` | `PositionalTab` | `InlineObject::PositionalTab` | tab | not yet | `span.tab` | tab char | model | +| `inline.break` | `Break` | `LINE_BREAK`, `PAGE_BREAK` | line break, page break | line break in text | `br` | line break | model, formats | +| `onenote.ink` | `Ink` | n/a | ink drawing | `ink.svg` per page | `ink.svg` link | `ink.svg` link | model, formats | +| `onenote.tag` | `Tag` | n/a | tag (to-do, important, question, and so on) | `Tag` (model) | `span.tag` | `tag` marker | model, formats | +| `onenote.attachment` | `EmbeddedFile` | n/a | embedded file | attachment in `assets/` | `a` | `[]()` link | model, formats | +| `onenote.pagelink` | `PageLink` | n/a | link to a page | not yet | `a` | `[]()` | model, formats | +| `onenote.stamp` | `PageStamp` | n/a | date and time under the title | drawn stamp (UI only) | `p.stamp` | not yet | model, formats | +| `onenote.audio` | `EmbeddedAudio` | n/a | embedded audio | not yet | `audio` | not yet | model, formats | +| `onenote.video` | `EmbeddedVideo` | n/a | embedded video | not yet | `video` | not yet | model, formats | +| `onenote.printout` | `Printout` | n/a | printout | printout page (`pdf.printout-NNN.png`) | `img` | `![]()` | model, formats | +| `ext.opaque` | `Opaque` | `InlineObject::Opaque` | any object we cannot show | not yet | `span.opaque` with its original XML | same | model | + +Notes on the table: +- **Not yet** means no type in the model. Adding one is a change to this table first, then the model, + then both formats. +- **n/a** means the source has no such object, or the marker is not needed in that format. +- The OneNote rows are the types NoteCraft already imports or needs for OneNote parity. OneNote's + own names are used where they exist. + +## Future room + +- **Open kinds.** Readers keep an unknown object as `ext.opaque`, with its identifier and its original + content. An unknown kind is never dropped on save. This is WordCraft's `Opaque` rule. +- **Versioned object payloads.** Each object payload carries a version. A reader that meets a newer + version keeps the object as opaque. +- **Namespaces.** `ext..` is reserved for types that come from another app (for example + `ext.artcraft.chart`). They round-trip the same way as opaque objects until NoteCraft implements them. +- **Inline or block.** An object is either inline (a character in the text) or a block. Adding a type + means choosing one. The table says which. + +## Checks before a new object type lands + +1. The identifier is in this table, with its mapping for HTML and CMGFM. +2. A round-trip test: HTML to model to HTML, and CMGFM to model to CMGFM, with the object unchanged. +3. Cross-format parity (`cargo test -p notecraft-formats --test format_parity`). +4. An opaque fallback test: a file with the object read by a build that doesn't know it. +5. The page model doc (`docs/dom_serialization/page-model.md`) names it. + +## Decision needed + +Owner: accept the identifier scheme and the table as the basis for the next format change, and decide +which rows come first. Suggested first set: `inline.image`, `inline.field`, `inline.equation`, +`inline.break`, `onenote.ink`, `onenote.tag`, `onenote.attachment`, `onenote.pagelink`, `ext.opaque`. diff --git a/SKILLS/nc-dom-ser/references/rfc-0001-notecraft-dom.md b/SKILLS/nc-dom-ser/references/rfc-0001-notecraft-dom.md new file mode 100644 index 00000000..ea6ec695 --- /dev/null +++ b/SKILLS/nc-dom-ser/references/rfc-0001-notecraft-dom.md @@ -0,0 +1,571 @@ +# RFC 0001: The NoteCraft document object model (DOM) + +| Field | Value | +|---|---| +| RFC | 0001 | +| Title | The NoteCraft document object model | +| Status | Draft, for owner review. Not accepted. | +| Version of this RFC | `dom-rfc 0.1-draft` | +| Versioning | Independent of the app version. This RFC's version changes only when this document changes. | +| Owner | The NoteCraft owner, with `nc-dom-ser` as the working skill | +| Affects | `crates/formats`, `crates/model`, `crates/ui-egui` (page editor), page files in both formats, `docs/dom_serialization/` | +| Related | `SKILLS/nc-dom-ser/references/object-types.md`, `SKILLS/nc-dom-ser/references/naming-convention.md`, `SKILLS/nc-uiux-bidi/references/caret-options.md`, `SKILLS/nc-design-artcraft-integration/references/wordcraft_x_notecraft_dom.md` | +| Appendix | A: Integration with other Craft apps (WordCraft as primary candidate) | + +--- + +## 1. Summary + +NoteCraft pages are stored as HTML or CommonMark-plus-GFM files. In memory they are a tree of +objects. This RFC defines that tree: what an object is, how it is named, which fields it has, how +each field is versioned, which objects may contain other objects, and how objects that this build +does not understand are kept and written back unchanged. + +It also fixes the rules that let the document model evolve without breaking old files, old builds, +or other Craft apps that later read the same objects. + +## 2. Terms + +- **DOM** here means the semantic page model: the tree the editor works on and the serializers read + and write. It does not mean the browser DOM. This is the meaning already used in + `docs/dom_serialization/README.md`. +- **Object**: one node of the tree. Every object has a kind, a version, an identifier and a payload. +- **Kind**: the identifier of an object type, in the form `family.kind` (for example `inline.image`). + It is the contract that is stored in files. +- **Payload**: the fields of an object, as defined by its kind and version. +- **Schema**: the list of fields, types, requirements, children and extension points of one kind at + one version. +- **Opaque object**: an object whose kind or version this build does not know. It keeps its original + bytes and is written back unchanged. +- **Inline object**: an object that sits in a paragraph's text as one character position. +- **Block object**: an object that sits in the body as a block of its own. +- **Container**: an object whose payload is a list of child blocks. + +## 3. Goals and non-goals + +### 3.1 Goals + +1. **Stable identity.** Every object type has a name that never changes once written. +2. **Explicit schema.** Every field is declared. Unknown fields are not silently accepted. +3. **Independent evolution.** A kind can gain a field or a new version without breaking older readers, + and without forcing an app release. +4. **Lossless unknowns.** Objects this build does not understand survive a load and save. +5. **Format parity.** A page means the same thing in HTML and CommonMark-plus-GFM. Where one format + cannot express an object, the RFC says so and says how the object is still preserved. +6. **Room for other apps.** An object created by another Craft app can live in a NoteCraft page and + come back unchanged. +7. **Testable.** Every rule in this RFC has a test that the build runs. + +### 3.2 Non-goals + +- This RFC does not define the editor's caret or layout. That is `caret-options.md`. +- It does not change the folder format of notebooks, sections or pages. +- It does not define DOCX import or export. Appendix A only maps names. +- It does not define the details of style, attributes or formats. They are stored as part of the + DOM (section 5.1), so this RFC names where they live and how they are versioned. Their content and + rules are defined in their own specifications: the theme and UI skills for visual style, and + `nc-dom-ser` and `docs/dom_serialization/` for file formats. + +## 4. Architecture + +```mermaid +flowchart LR + F1[HTML page file] -->|read| R[Reader] + F2[CMGFM page file] -->|read| R + R --> M[Object tree] + M -->|editor| E[Page editor] + E --> M + M -->|write| W1[HTML writer] + M -->|write| W2[CMGFM writer] + W1 --> F1 + W2 --> F2 + M --- S[(Schema registry
kind, version, fields)] + R --- S + W1 --- S + W2 --- S +``` + +The **schema registry** is the single source of truth for what a kind and version contains. The +reader, both writers, the editor and the tests all read the registry. No code keeps its own list of +fields. + +Layers, following `cargo xtask layers`: + +| Layer | Crate | Responsibility | +|---|---|---| +| Low | `notecraft-model` | The object tree types, identifiers, schema registry, version rules. No I/O. | +| Middle | `notecraft-formats` | Readers and writers for both formats. Uses the model. | +| High | `notecraft-ui-egui` | The page editor. Uses the model. Never defines a field itself. | + +## 5. The object record + +Every object, in memory and in the file, has the same record shape. + +```rust +/// One object of the page tree. The payload is parsed only when the kind and version are known. +pub struct ObjectRecord { + /// The contract: `family.kind`. Never changes once written. + pub kind: Kind, + /// Version of this kind's schema that the payload was written with. + pub version: u16, + /// Stable instance id within the page. Set when the object is created or imported. + pub id: Option, + /// Where the object came from. Set once; kept on save. + pub origin: Origin, + /// Known fields, as the schema for `kind` and `version` defines them. + pub fields: Fields, + /// Child objects, only if the schema allows children for this kind. + pub children: Vec, + /// Named extensions, only at the points the schema declares. + pub extensions: Vec, +} + +/// The raw form of an object this build cannot interpret. +pub struct Opaque { + pub kind: String, + pub version: u16, + pub id: Option, + pub origin: Origin, + /// The payload exactly as read: HTML fragment, or CMGFM text, or both. + pub raw: RawPayload, +} +``` + +| Field | Stored in file | Required | Notes | +|---|---|---|---| +| `kind` | yes | yes | The identifier. Format: `family.kind`. | +| `version` | yes | yes | Integer, starting at 1. See section 7. | +| `id` | yes, when present | no for inline objects that nothing refers to; yes once anything refers to them | Unique within its page. See section 6.3. | +| `origin` | yes | yes | `notecraft`, `onenote-import`, `wordcraft-import`, `ext.`. Set once. | +| `fields` | yes | per schema | Only the fields the schema defines. Nothing else. | +| `children` | yes, when the kind allows children | per schema | Ordered. | +| `extensions` | yes, when present | no | Namespaced. See section 8. | + +There is no catch-all field. Anything not in the schema is either an error (in a known version) or +the whole object is opaque (in an unknown version). + +### 5.1 Style, attributes and formats + +Three kinds of data are stored as part of the DOM. This RFC places them and versions them; it does not +define their content. + +| Kind of data | Where it lives | Defined by | +|---|---|---| +| **Style** (named styles that objects refer to, for example a heading level or a character style) | a style table in the page header, referenced by `style` in an object's fields | a later RFC on styles | +| **Attributes** (typed properties of one object, for example width, alt text, alignment) | the object's `attrs`, declared in its schema | the kind's schema (section 10) | +| **Formats** (the page format and each object's format-specific form) | the page header (`data-nc-format`, `nc_format`) and the format mapping (section 9) | `nc-dom-ser` and `docs/dom_serialization/` | + +Rules that apply to all three, and that this RFC does define: + +1. Each is stored in the file, so a reader that loads a page and writes it back keeps them. +2. Each is versioned with the object or page that uses it: a style table has its own version, and an + attribute change follows the kind's version rules (section 7.2). +3. An unknown style name is kept as it is, like an unknown object. It is not replaced by a default. + +## 6. Identity, naming and placement + +### 6.1 Three names per kind + +| Name | Rule | Stored | +|---|---|---| +| Kind (identifier) | `family.kind`, lower case, dot separated | yes | +| Rust name | PascalCase; WordCraft's name where WordCraft has the object | no | +| Format form | HTML tag and class; CMGFM syntax | no (derived) | + +Families are fixed: `block`, `inline`, `container`, `onenote`, `ext`. A new family needs an ADR. + +Full rules and the table of kinds are in `naming-convention.md` and `object-types.md`. This RFC does +not repeat them; it depends on them. + +### 6.2 Placement + +Every kind is either inline or block. The schema says which. + +```mermaid +flowchart TD + P[Paragraph text] --> C1[character] + P --> O1[inline object
one character position] + P --> C2[character] + B[Body] --> BL1[paragraph] + B --> BL2[block object] + B --> BL3[container] + BL3 --> BL4[child blocks] +``` + +An inline object occupies one character position in its paragraph's text. A block object is a body +child. + +### 6.3 Object identity + +An `ObjectId` is needed when something refers to an object: a comment, a link, a search result, a +caret placement, an ink stroke that belongs to an image, or an import match. + +- Format: `o_` followed by 8 to 16 lower-case hex digits, unique within the page. +- Assigned when the object is created or imported, and kept on every save. +- An id is never reused in the same page, even after the object is deleted. +- An object without an id is not referenced by anything. Writers may omit it. + +```html +
+ A harbour at dusk +
+``` + +## 7. Versioning + +### 7.1 Two version lines + +| Line | What it versions | Where it is stored | +|---|---|---| +| Format version | the file layout and the page header | `data-nc-format` in HTML; front matter key `nc_format` in CMGFM | +| Kind version | one object kind's fields | `data-nc-version` in HTML; a `version` attribute in the CMGFM object marker | + +A page has one format version. Each object has its own kind version. A page can contain a kind at +version 1 and another at version 2. + +### 7.2 When a version changes + +| Change | Kind version | Compatibility | +|---|---|---| +| Add an optional field | no change | Old readers ignore it only if the field is optional and has a default; otherwise see below | +| Add a required field | new version | Old readers keep the object opaque | +| Remove a field | new version | Old readers keep the object opaque | +| Change a field's type or meaning | new version | Old readers keep the object opaque | +| Rename a field, same meaning | no change; add an alias for one version | Documented in the schema | +| Change allowed children | new version | Old readers keep the object opaque | +| Change allowed extensions | no change for adding a named point; new version for removing one | As above | + +Rule: **an optional field may be added without a new version only if an old reader that ignores it +loses nothing when it writes the object back.** If in doubt, bump the version. + +### 7.3 Reader behaviour + +```mermaid +sequenceDiagram + participant File + participant Reader + participant Registry + participant Model + File->>Reader: object kind=inline.image version=2 + Reader->>Registry: schema(inline.image, 2)? + alt version known + Registry-->>Reader: schema + Reader->>Model: parse fields strictly + Model-->>Reader: ObjectRecord + else version unknown + Registry-->>Reader: none + Reader->>Model: keep as Opaque (raw bytes) + else kind unknown + Registry-->>Reader: none + Reader->>Model: keep as Opaque (raw bytes) + end +``` + +Writing: + +- A known object is written from its fields, with its kind and version. +- An opaque object is written from its raw bytes, unchanged. +- The page's format version is the highest the writer needs for the objects it writes. It is never + lowered on save. + +### 7.4 Parse errors + +A known kind and version with a missing required field, or with an unknown field, is an error. The +reader reports it with the object's kind, version, id and location. It does not guess. The page still +opens: the object is kept as opaque and the user is told, so nothing is lost. + +## 8. Children and extensions + +### 8.1 Children + +Each kind declares what it may contain. Allowed values: + +| Allowed children | Meaning | Example kinds | +|---|---|---| +| `none` | no children | `inline.image`, `block.rule`, `block.math` | +| `spans` | text runs with marks | `block.paragraph`, `block.heading`, `block.quote` | +| `blocks` | a list of block objects | `container.box`, `container.callout` | +| `cells` | table cells, each holding spans or blocks | `block.table` | + +```mermaid +classDiagram + class ObjectRecord { + +kind + +version + +id + +origin + +fields + +children + +extensions + } + class Container { + children: blocks + } + class Paragraph { + children: spans + } + class Table { + children: cells + } + class Image { + children: none + } + ObjectRecord <|-- Container + ObjectRecord <|-- Paragraph + ObjectRecord <|-- Table + ObjectRecord <|-- Image + Container o-- ObjectRecord : blocks + Table o-- Cell : rows +``` + +A child may not be of a kind its parent forbids. The reader checks this and treats a violation like a +parse error. + +### 8.2 Extensions + +An extension is a named piece of data attached to an object, keyed `ext..`. Extensions +are how another Craft app, or a later NoteCraft feature, can add data to an object without changing +its kind. + +Rules: + +1. A kind declares its extension points in its schema: which names are allowed, and whether the point + is per object or per field. +2. An extension is never a core field. A reader that does not know an extension keeps it and writes it + back unchanged. A known field is never stored as an extension. +3. An extension has its own version. Its owner keeps it compatible. +4. An unknown extension never makes the object opaque. Only an unknown kind or version does. + +Example (HTML): + +```html +
+ A harbour at dusk + +
+``` + +The script element is inert in HTML and is never executed; it is used only as a data carrier. The +exact carrier for each format is part of the format sections below and needs its own test. + +## 9. Format mapping + +### 9.1 HTML + +- One `
`, `
`, `

`, `` or other element per object. The element is chosen by the + kind's schema. +- Every object carries `data-nc-kind`, `data-nc-version`, and `data-nc-id` when it has an id. +- Known fields are written as attributes or as child elements, as the schema says. Text is text. +- Unknown objects are written as the raw element they were read from. + +### 9.2 CommonMark plus GFM + +CommonMark has no general object syntax. The rules: + +- An object with a native CommonMark form uses it (for example `![alt](src)` for `inline.image`). +- An object without one is written as an HTML block with the same `data-nc-*` attributes, in a fenced + marker that CommonMark renders as HTML. The marker is documented in the schema. +- The file's front matter carries `nc_format`. + +```markdown + +This is a callout inside a paragraph. + +``` + +### 9.3 Parity + +Both formats must mean the same page. The parity test compares the object tree read from each format. +Pixel parity is checked by hand, as `nc-dom-ser` already requires. + +Where one format cannot express an object, the object is recorded in the parity exceptions with its +reason and its fallback. An owner decision is needed for each exception. + +## 10. The schema registry + +```rust +pub struct KindSchema { + pub kind: &'static str, // "inline.image" + pub version: u16, // 1 + pub placement: Placement, // Inline or Block + pub fields: &'static [FieldDef], // the only fields allowed + pub children: ChildRule, // None, Spans, Blocks, Cells + pub extensions: &'static [ExtPoint], + pub html: HtmlForm, // element and attributes + pub cmgfm: CmgfmForm, // native syntax or marker + pub status: Status, // Current, Deprecated, Retired +} + +pub struct FieldDef { + pub name: &'static str, + pub ty: FieldType, + pub required: bool, + pub since: u16, // the version that added it +} +``` + +Rules for the registry: + +- Every kind that the code can produce is in the registry. A kind that is not in the registry is + opaque on read. +- A `Retired` kind keeps its entry forever, so old files still read. +- The registry is generated or checked by a test against `object-types.md`. The two must match. + +## 11. Testing + +Each rule in this RFC has a test. Required test groups: + +| Group | What it proves | +|---|---| +| Round trip, HTML | Read then write an HTML page; the bytes are the same except for normalised whitespace the RFC allows. | +| Round trip, CMGFM | The same for CommonMark-plus-GFM. | +| Parity | The object tree from an HTML page equals the tree from its CMGFM twin. | +| Unknown kind | A page with an unknown kind loads; the object is opaque; saving keeps its bytes. | +| Unknown version | A page with a known kind at a newer version loads; the object is opaque. | +| Unknown extension | An object with an unknown extension keeps it through a save. | +| Strict known version | A known kind with an unknown field is reported, not silently dropped. | +| Children rule | A forbidden child kind is reported. | +| Identity | Ids are unique in a page; a deleted id is not reused. | + +```rust +#[test] +fn an_unknown_kind_survives_a_round_trip_unchanged() { + let page = r#"

+

kept

"#; + let tree = read_html(page).expect("reads"); + let out = write_html(&tree); + assert_eq!(normalise(&out), normalise(page)); +} +``` + +Tests must not use mocks. Use real page files and the real readers and writers, per `AGENTS.md`. + +## 12. Migration and compatibility + +- The current model (`Block` and `Span` in `notecraft-formats`) is the starting point. Its types map to + kinds in the registry; the mapping is written as a table in `object-types.md`. +- Existing files keep reading. A file with no kind or version is read as the current version of its + implied kind. +- No file is rewritten on load. A file changes only when the user edits it, or when a migration is + run on purpose, and the migration is listed in this RFC before it ships. +- Every change to the registry is a change to this RFC's version as well: a new field or version + requires an entry in section 14. + +## 13. Open questions + +1. Whether a container's children can be a mix of blocks and tables. Proposed: yes, with the mix + listed in the schema. +2. The exact CMGFM marker for objects that have no native syntax. Proposed: the HTML-block form in 9.2. +3. Whether `origin` is needed on every object or only on imported ones. Proposed: every object, so a + later import can match. +4. How much of an opaque object's raw bytes is kept when a writer changes the page around it. + Proposed: the whole raw form, unchanged. +5. The carrier for extension data in HTML (section 8.2). Proposed: the inert script element, to be + confirmed by a test that the page's behaviour is unchanged. + +## 14. Changelog of this RFC + +| RFC version | Date | Change | +|---|---|---| +| 0.1-draft | 2026-11-12 | First draft. | +| 0.1-draft (r2) | 2026-11-12 | Style, attributes and formats are stored in the DOM; their details are deferred (sections 3.2, 5.1). | + +--- + +## Appendix A: Integration with other Craft apps (WordCraft as primary candidate) + +WordCraft is the primary candidate for integration: it already has a document model with fields, +objects, paragraphs and runs, and it is a Craft app that ships. This appendix maps NoteCraft's +proposed names to WordCraft's names, so that a shared object model, or an import and export between +the two, can be planned against one table. + +### A.1 Sources and their status + +| Source | Used for | Status | +|---|---|---| +| `storytold/wordcraft/crates/doc/src/block.rs` | `Block`, `Blocks` | read | +| `storytold/wordcraft/crates/doc/src/para.rs` | `Paragraph` | read | +| `storytold/wordcraft/crates/doc/src/para/run.rs` | `Run` | read | +| `storytold/wordcraft/crates/doc/src/para/inline_object.rs` | `InlineObject` variants | read | +| `storytold/wordcraft/crates/doc/src/position.rs` | `Pos` | read | +| `storytold/wordcraft/crates/doc/src/lib.rs` | `Document` | read | +| `storytold/wordcraft/crates/doc/src/control.rs` | content controls | read (header) | +| `storytold/wordcraft/crates/doc/src/cluster.rs` | cluster rules | read (header) | +| `storytold/wordcraft/docs/architecture.md` | list of model parts | read | +| `storytold/wordcraft/docs/typing-parity.md` | editing rules | read (sections) | +| WordCraft names not listed above (`Frame`, `ListItem`, `ContentControl`, `ChartSpec`, `Fill`) | named in the architecture notes and in an earlier survey | **to be verified** against the source before this appendix is accepted | + +### A.2 Mapping table + +Columns: NoteCraft kind (proposed), WordCraft name, relation, note. + +Relations: **same** (same meaning), **close** (similar, with a difference noted), **none** (no WordCraft +equivalent), **WC-only** (WordCraft has it, NoteCraft does not propose it yet). + +| NoteCraft kind | WordCraft name | Relation | Note | +|---|---|---|---| +| `block.paragraph` | `Block::Para(Paragraph)` | close | WordCraft's paragraph is one text stream with runs and objects; ours is spans per block. See the RFC's section 13 and the caret notes. | +| `block.heading` | paragraph with a heading style (`props`) | close | WordCraft has no heading kind; a heading is a style. | +| `block.item` | paragraph with list props (`ListItem` in the survey) | close | WordCraft's list membership is in paragraph properties. Verify the `ListItem` name. | +| `block.quote` | paragraph with a quote style | close | Style-based in WordCraft. | +| `block.code` | paragraph with a code style, or none | close | WordCraft has no code block kind in `Block`. | +| `block.math` | `InlineObject::Equation` in a paragraph, or an equation block | close | WordCraft's equations are inline objects. | +| `block.rule` | paragraph border or a section break | close | Different mechanism. | +| `block.table` | `Block::Table(Table)` | same | Cells, rows and nested content. | +| `block.container` | text box (`MAX_TEXT_BOX_DEPTH`) | close | WordCraft's text boxes nest to a depth limit. Verify whether the name is `Frame`. | +| `inline.image` | `InlineObject::Image` | same | | +| `inline.graphic` | `InlineObject::Graphic` | same | | +| `inline.shape` | `InlineObject::Shape` | same | | +| `inline.group` | `InlineObject::Group` | same | | +| `inline.field` | `InlineObject::Field` | same | | +| `inline.field.start` | `InlineObject::FieldStart` | same | | +| `inline.field.end` | `InlineObject::FieldEnd` | same | | +| `inline.control` | `InlineObject::ControlStart` and `ControlEnd` | same, paired | Pair markers in WordCraft; ours will also pair. | +| `inline.bookmark.start` | `InlineObject::BookmarkStart` | same | | +| `inline.bookmark.end` | `InlineObject::BookmarkEnd` | same | | +| `inline.comment.start` | `InlineObject::CommentStart` | same | | +| `inline.comment.end` | `InlineObject::CommentEnd` | same | | +| `inline.equation` | `InlineObject::Equation` | same | | +| `inline.ruby` | `InlineObject::Ruby` | same | | +| `inline.tab` | `InlineObject::PositionalTab` | close | WordCraft's is a positional tab stop, ours is a tab character. | +| `inline.break` | `LINE_BREAK` and `PAGE_BREAK` constants | same | WordCraft has them as characters in the text. | +| `inline.perm.start` | `InlineObject::PermStart` | same | Not yet proposed by NoteCraft. Listed for completeness. | +| `inline.perm.end` | `InlineObject::PermEnd` | same | As above. | +| `inline.note-ref` | `InlineObject::NoteRef` | same | Not yet proposed. | +| `ext.opaque` | `InlineObject::Opaque` | same | Both keep unknown content. | +| `onenote.ink` | none | none | OneNote ink has no WordCraft object. | +| `onenote.tag` | none | none | WordCraft has no OneNote tags. | +| `onenote.attachment` | `InlineObject::Image`, or a media part | close | WordCraft stores media in `Document::media`. | +| `onenote.pagelink` | a hyperlink run property | close | Not an object in WordCraft. | +| `onenote.stamp` | `InlineObject::Field` (date field) | close | Verify. | +| `onenote.audio` | none | none | | +| `onenote.video` | none | none | | +| `onenote.printout` | none | none | | +| `container.callout` | none | none | Proposed; no WordCraft equivalent. | +| WC-only: `Shape` group nesting, `ChartSpec`, `Fill` | `ChartSpec`, `Fill` (in the survey) | WC-only | Verify names. NoteCraft has no chart object yet. | + +### A.3 What the mapping means + +- Most inline kinds map one to one. The two models can exchange them without loss if the field sets + agree. The field sets are not yet compared; that is the next step. +- The biggest difference is the text model. WordCraft's paragraph is one stream; ours is per-block + spans. A shared model would need one of the two to change. This is the decision in + `caret-options.md`, option B. +- OneNote-specific kinds have no WordCraft counterpart. They need `ext.artcraft.*`-style extensions if + WordCraft is to carry them, or a shared `onenote` family in the shared library. The owner decides. + +### A.4 Next steps for the integration + +1. Verify every WordCraft name marked **to be verified** against the source. +2. Compare the field sets of each `same` row. +3. Decide whether the shared family is `ext.` (per app) or a shared crate. +4. Write the import and export tests for the `same` rows first. + +--- + +## Appendix B: How this RFC will be used + +- It lives in `SKILLS/nc-dom-ser/references/` until accepted. +- On acceptance, the owner moves it to `SKILLS/nc-design-artcraft-integration/references/` with + Appendix A as the integration record. +- Changes to the RFC bump its own version (section 14). App versions do not change it. diff --git a/SKILLS/nc-uiux-bidi/SKILL.md b/SKILLS/nc-uiux-bidi/SKILL.md index 97305cc3..818ece35 100644 --- a/SKILLS/nc-uiux-bidi/SKILL.md +++ b/SKILLS/nc-uiux-bidi/SKILL.md @@ -27,9 +27,33 @@ placement, selection, and mirrored layout. action semantics require it. - Keep controls usable when labels are longer in translated languages. +## Caret model (owner direction, 2026-11-12) + +- The caret is a logical position: one block and one character offset. Movement commands take that + position and return one. They do not read pixels, and they do not depend on the window. +- One owner for the caret. The text field draws it; it does not keep its own copy of the position. + Where a field keeps its own cursor, the code sets it every time the caret moves, and checks it + afterwards. +- Layout answers the questions: which line a position is on, where it is on that line, the goal + column for Up and Down, and whether a line end is right-to-left. Use parley (`crates/text`) for that. +- Horizontal moves step by user-perceived characters (extended grapheme clusters), with the cluster + rules for Myanmar, Khmer and Tai Tham. A caret never stops inside a stacked consonant. +- Up and Down keep a goal column across repeated presses and clear it on any other move. +- Word and line moves follow paragraph direction: in right-to-left text, the word to the left is the + next word. +- Edges are reported by layout (line start, line end, last line, right-to-left line) so the command + moves to the next block in the right direction. +- The rules are tests, copied from WordCraft's `docs/typing-parity.md` where they apply. + +Research and options: `references/caret-research.md` and `references/caret-options.md`. + ## Verification Read `work/rtl-and-caret-bar.md` before changing this area. Run focused bidi/caret tests and the cross-platform UI checks; record visual review at matching scale and theme. See `references/GOTCHAS_AND_GAPS.md` for cross-app RTL lessons and common pitfalls from NoteCraft and Wordcraft. + +## Reference: WordCraft and NoteCraft document models + +See `references/wordcraft_x_notecraft_dom.md` for the comparison of the two document models and what it means for caret, runs and inline objects. diff --git a/SKILLS/nc-uiux-bidi/references/caret-options.md b/SKILLS/nc-uiux-bidi/references/caret-options.md new file mode 100644 index 00000000..a9e62242 --- /dev/null +++ b/SKILLS/nc-uiux-bidi/references/caret-options.md @@ -0,0 +1,45 @@ +# Caret options for NoteCraft (for owner review, 2026-11-12) + +Background: `caret-research.md`. The problem is two owners for the caret: our code and egui's text +field. + +## Option A: keep egui's text field and patch the paths + +- **What:** keep each block as an egui `TextEdit`. Fix placements and edge moves case by case. +- **Pros:** no new code, no new dependencies, the current tests keep running. +- **Cons:** this is where we are now. Each fix has moved the bug to another path (see the history + of the edge tests). egui restores its own cursor a frame later, so placements get lost. +- **Effort:** low for each fix, high in total, and it doesn't reach a stable state. +- **Verdict:** stop here unless the owner wants only the current behaviour. + +## Option B: a logical caret model with a layout (WordCraft's rules, our code) + +- **What:** the page holds one `Pos` (block, character offset). Commands take and return a `Pos` and + live in `pageedit` or a new `caret` module without egui. Layout (parley, already in `crates/text`) + maps a `Pos` to a line and an x, and back. egui only paints the text and the caret. +- **Pros:** one owner. The rules from WordCraft's `typing-parity.md` can be copied as tests. Commands + are testable without a window. Right-to-left and mixed text come from parley's bidi output. +- **Cons:** the editor draws text itself, or pins egui's galley to our offsets. Painting is still an open + question in `crates/text/src/lib.rs`. This is a large change. +- **Dependencies:** parley is already in the repo. HarfBuzz and ICU are not needed directly: parley + uses HarfRust and the Unicode bidi algorithm. Adding either would need a CHECKPOINT. +- **Effort:** a few weeks of focused work. Needs an estimate and the owner's nod. +- **Verdict:** the recommended direction if the owner wants correct caret behaviour in every block. + +## Option C: adopt WordCraft's engine or layout crates + +- **What:** use `wordcraft-layout` and the engine's caret commands directly. +- **Pros:** the most complete behaviour, already tested. +- **Cons:** WordCraft's layout is for a page document with a different block model, and it would + pull in its story model. Licensing and repo boundaries need review. We would be adopting another + app's architecture. +- **Verdict:** not recommended now. Reuse its rules and test cases (Option B), not its code. + +## Recommendation + +Option B, after a short spike that answers two questions: +1. Can a `Pos` drive the egui text field without losing placements (for example, by owning the cursor + state for every block and never letting egui keep its own)? +2. Can parley's layout give the line and x for a `Pos` in a code block and in a paragraph? + +The spike is written as a failing test first, then code, under the usual gates. diff --git a/SKILLS/nc-uiux-bidi/references/caret-research.md b/SKILLS/nc-uiux-bidi/references/caret-research.md new file mode 100644 index 00000000..28da510d --- /dev/null +++ b/SKILLS/nc-uiux-bidi/references/caret-research.md @@ -0,0 +1,49 @@ +# Caret research: how the Craft apps move the caret (2026-11-12) + +Sources read, with the place each fact came from: + +- `storytold/wordcraft`: `crates/engine/src/cmd/caret.rs` (commands), `crates/layout/src/hit.rs` + (`vertical`, hit testing), `crates/doc/src/cluster.rs` (cluster rules), `docs/typing-parity.md` + (Editing keys table, Complex scripts, Text direction). +- `storytold/craft-libs`: `crates/text/README.md` ("shaping, glyph mirroring and caret placement stay + in each application"). No shared caret or editing crate was found. +- `storytold/craft-wiki`: user-facing app docs only. No caret design was found. + +## What WordCraft does + +1. **The caret is a logical position.** A `Pos` names a story, a path and a character offset. Every + command takes and returns a `Pos`. The UI only draws it. +2. **Commands live in the engine.** `caret.left`, `caret.right`, `caret.up`, `caret.down`, + `caret.wordLeft` and the rest are registered commands with key bindings and an `extend` flag. They + run without a window, so they can be tested and driven by the control channel and MCP. +3. **Horizontal moves step by clusters.** Left and Right step by user-perceived characters (extended + grapheme clusters, UAX #29), and `cluster.rs` adds the rules for Myanmar, Khmer and Tai Tham, so a + caret never stops inside a stacked consonant. +4. **Vertical moves use the layout and a goal column.** `Layout::vertical(pos, goal_x, dir, + page_hint)` finds the line above or below, keeps the goal x (`s.goal_x`) across consecutive Up and + Down presses, and clears it on any other move. Moving into a table picks the nearest cell row. +5. **Edges are reported, not guessed.** `VisualStep::Edge { start, stop, last_line, rtl }` tells the + command that a move hit a line end, and whether the line is right-to-left, so Left and Right at a + line end move to the next line in the right direction. +6. **Word and line moves depend on paragraph direction.** In a right-to-left paragraph the "word to the + left" is the next word (`rtl_para`). On macOS, ⌘← and ⌘→ go to the line ends. +7. **The rules are written down and tested.** `docs/typing-parity.md` lists each key, Word's result, + and the test that checks it. + +## What NoteCraft does today + +- The page editor draws each text block with egui's text field. egui owns the caret, the line layout + and the hit testing. Our code reads and sets the caret through `Caret { block, text, selection, + place }`, and the text field can ignore a placement or restore its own cursor a frame later. That is + the cause of the landing bugs: two owners of the caret. +- `crates/text` (parley) has shaping, bidi and a caret for Arabic, but the page editor does not use it + for caret movement. +- Code blocks are drawn as one text field per block, so Up and Down cross block edges in our code, + not in a layout. + +## Conclusion + +WordCraft's approach works because one model owns the caret and the layout answers the questions. +NoteCraft has two owners. Copying WordCraft's engine would be large. Copying its rules is cheap. + +See `caret-options.md` for the options. diff --git a/SKILLS/nc-uiux-keys/SKILL.md b/SKILLS/nc-uiux-keys/SKILL.md new file mode 100644 index 00000000..2df3cf8c --- /dev/null +++ b/SKILLS/nc-uiux-keys/SKILL.md @@ -0,0 +1,87 @@ +--- +name: nc-uiux-keys +description: Keyboard and pointer navigation into and out of page objects (code blocks, math, tables, images, diagrams) and between page blocks: arrows, Page Up and Down, Home, End, and a single click to edit. +license: MIT +metadata: + version: "0.0.20261111" + related_skills: [nc-uiux-controls, nc-uiux-bidi, nc-uiux-z-order, nc-uiux-testing] +--- + +# nc-uiux-keys + +## Job to be done + +Make moving through a page feel the same whether the caret is in text or in an object. The owner's +standard (2026-11-11): navigation into and out of a block is smooth with the cursor and the +navigation keys. + +## Contract + +Two layers. Keep them separate. The generic layer is the default. A block kind may override it +only with an owner rule written under its own heading below. + +### Generic caret movement (unrestricted text) + +Paragraphs, headings, quotes and list items. These are plain text: the caret moves character by +character and line by line across blocks in page order. + +- Left and Right move one character; at a block edge they move into the neighbouring block. +- Up and Down move one visual line at a time, keeping the column. Shorter lines clamp to their end. +- Home and End go to the start and end of the current visual line. +- Ctrl+Home and Ctrl+End go to the start and end of the page. +- Page Up and Page Down move by a screen and never skip a block. +- Enter splits the block at the caret. Backspace at the start joins it with the block above. +- Shift with any of these extends the selection across blocks. +- Escape leaves editing without changing words. + +### Code block + +Owner rules (2026-11-11). A code block is visual styling: a frame and background drawn around +page text. + +- Click on a code block's text places the caret in it. A click does not select the block as an + object. +- Inside the block, Up and Down move one line at a time, keeping the column, as in generic text. +- Up from the block's first line, and Down from its last line, leave the block for the neighbouring + block. The block then shows in view mode, with its line numbers. +- Down from the block above lands in the first line of the block. Up from the block below lands in + its last line, at the end of that line. +- Enter inside the block adds a line. Enter on an empty line below the block leaves it as a new + paragraph. +- Numbered code keeps its numbers and bar while the caret is inside; they are not part of the text + the caret moves through. + +Known open points (2026-11-11): Up and Down inside a block did not move the caret line by line in +the running app, and Down into a block landed at its end instead of its start. + +### Other special blocks + +Math blocks, tables, images, diagrams and boxes have their own rules. Do not assume the code block +rules apply to them. Each needs owner rules recorded here before its caret behaviour changes. + +## Design rules + +- The keys are the same in read and edit views. The view should not change size when the caret + arrives: draw the object with the same frame in both states (see `preview.rs` and `pageedit.rs`). +- Object clicks, link clicks and text clicks must not fight. A click on a link follows the link; a + click on an object's body edits it; a click beside an object puts the caret in the neighbour. +- Do not add a keyboard mode switch. One keyboard model for every block. + +## Known gap (2026-11-11) + +Code blocks are treated as objects (`crates/ui-egui/src/preview.rs`, `object_kind`, which returns +`code`). Object clicks record a selection, and editing a code block needs a double-click. The fix +belongs in the object click path, so a single click on a code object moves the caret into it. + +## Checks + +- A UI test for each rule above, using the real window and key events (`crates/ui-egui/tests/ui.rs`), + with a failing test written before the fix. +- Run the same test in the HTML and CMGFM pages. +- Visual check: caret arrival must not change the block's size. + +## Not in this skill + +- Text entry in right-to-left blocks: `nc-uiux-bidi`. +- Pointer ownership when surfaces overlap: `nc-uiux-z-order`. +- Buttons and menus: `nc-uiux-controls`.