From 84c3a44aa36dc7dda0aa238517772d810dffca52 Mon Sep 17 00:00:00 2001 From: AlienTech Software Date: Thu, 12 Nov 2026 03:49:01 -0800 Subject: [PATCH] docs(skills): RFC 0001, object naming, caret model and keys skill Adds the NoteCraft DOM RFC (draft, its own version line) with its WordCraft mapping appendix, the object naming convention and object table, the caret research and options, the WordCraft and NoteCraft document model comparison, and the nc-uiux-keys skill. Docs only; no code. Co-Authored-By: Claude Haiku 5.5 --- .../nc-design-artcraft-integration/SKILL.md | 4 + .../references/wordcraft_x_notecraft_dom.md | 59 ++ SKILLS/nc-dom-ser/SKILL.md | 12 + .../references/naming-convention.md | 76 +++ SKILLS/nc-dom-ser/references/object-types.md | 90 +++ .../references/rfc-0001-notecraft-dom.md | 571 ++++++++++++++++++ SKILLS/nc-uiux-bidi/SKILL.md | 24 + .../nc-uiux-bidi/references/caret-options.md | 45 ++ .../nc-uiux-bidi/references/caret-research.md | 49 ++ SKILLS/nc-uiux-keys/SKILL.md | 87 +++ 10 files changed, 1017 insertions(+) create mode 100644 SKILLS/nc-design-artcraft-integration/references/wordcraft_x_notecraft_dom.md create mode 100644 SKILLS/nc-dom-ser/references/naming-convention.md create mode 100644 SKILLS/nc-dom-ser/references/object-types.md create mode 100644 SKILLS/nc-dom-ser/references/rfc-0001-notecraft-dom.md create mode 100644 SKILLS/nc-uiux-bidi/references/caret-options.md create mode 100644 SKILLS/nc-uiux-bidi/references/caret-research.md create mode 100644 SKILLS/nc-uiux-keys/SKILL.md 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`.