## The Design Intelligence Protocol url: https://rafters.studio/ Rafters

Your AI does not have taste.

It never will. But it can read a designer's judgment if someone bothers to write it down as structured data. That's what Rafters does.

Every design system on the planet stores token values. Colors, spacing, type scales. Rafters stores the reasoning: why that blue, when to use it, what happens when you pair it with that other blue, and how much cognitive load the resulting component puts on the person staring at it.

{`pnpx rafters@latest init pnpx rafters@latest add container grid typography button input editor`} Open source. Free. Built because nothing else does this.

AI builds UIs that look like garbage. Not because the models are bad. Because they have no design intelligence to work with. You hand an LLM a component library and it guesses. It picks the wrong button variant, pairs colors that fight each other, and stacks six high-cognitive-load components on a screen that should have three. It pattern matches what it knows. The top 5000 websites when the learning data was created, good or not, that is what it copies.

Designers know this stuff intuitively. They spent years learning it. But that knowledge lives in their heads, their Figma files, and maybe a Notion doc nobody reads.

Rafters makes it queryable.

Not marketing. Structured data that an AI agent or MCP server can query at build time.

Cognitive Load Scoring.

Every component gets a score from 0 to 10 across five dimensions: decision demand, information density, interaction complexity, context disruption, learning curve. A Button scores 3. An AlertDialog scores 7. A screen has a budget. No existing design system measures this.

Color Intelligence.

Pick a color. Get perceptual analysis, harmonic relationships, emotional associations, accessibility scoring, and conflict detection. Every color in the system carries ten or more data structures. Not a picker. A knowledge system. All in OKLCH because color math in sRGB is wrong and everyone knows it.

Do and Never Rules.

Not documentation someone might read. Structured rules an AI agent reads before generating code. Maximum one primary button per section. Never pair a Dialog inside a Sheet. Minimum touch target 44 pixels. The guardrails are the API.

Composition-Level Review.

Components are fine individually. Put six of them together and the page is a mess. Rafters reviews compositions against attention budgets and flags when the total cognitive load exceeds what a human can process.

{`pnpx rafters@latest init`} {`pnpx rafters@latest add button`}

You now have a design system with embedded intelligence. Components work immediately with grayscale defaults. Define a Zod schema, get a form that respects your cognitive budget. The MCP server gives your AI agent access to the full intelligence layer: cognitive load, color science, do-and-never rules, composition review.

Run Studio when you are ready to make it yours. Configure spacing progressions, type scales, color relationships, motion philosophy. The mathematical rules that generate your tokens, not a token editor.

shadcn/ui is a component library. Tailwind is a utility framework. Neither is a design system.

A design system encodes decisions. Why this spacing scale, when to use compact density, what level of cognitive load is acceptable for an onboarding flow versus a power-user dashboard. Rafters encodes all of that as structured data an AI can read.

What you get shadcn/ui Tokens Studio Knapsack Rafters Accessible components Yes No Yes Yes Own your code Yes No No Yes Cognitive load scores No No No Per component and per composition Color intelligence CSS variables Token values Token values OKLCH analysis, harmony, conflicts AI-readable rules No No No MCP server and JSON registry Composition review No No No Attention budgets, conflict detection Design decisions tracked Manual Token history Token history Git-tracked structured data Design reasoning encoded No No No Yes

The protocol is free. The CLI, the design tokens, the components, the color utilities, the math utilities, the MCP server, the Studio editor. Everything a solo practitioner or a small team needs to ship interfaces with real design intelligence.

[GitHub](https://github.com/rafters-studio/rafters)

--- ## Cognitive Load Scoring url: https://rafters.studio/docs/cognitive-load-scoring/ # Cognitive Load Scoring Model Rafters assigns every UI component a cognitive load score from 0 to 10. This document explains the theory behind the scores, the five dimensions that produce them, and how screen-level budgets work when components compose together. No existing design system or academic framework provides a quantitative per-component scoring model. NN/g's CASTLE framework mentions cognitive load as one of six UX dimensions but recommends post-hoc surveys (NASA-TLX), not automated scoring. Rafters is the first system to assign static, intrinsic scores to components and enforce budgets at composition time. --- ## The Five Dimensions Every component score is the sum of five dimensions, each contributing 0-2 points. The maximum possible score is 10. ### 1. Decision Demand (0-2) How many decisions does the user need to make? | Score | Meaning | Examples | |-------|---------|---------| | 0 | No decision required | Separator, Container, Skeleton | | 1 | Single binary or obvious choice | Button (click or don't), Toggle (on/off), Checkbox | | 2 | Multiple options or consequential choice | AlertDialog (proceed or cancel with stakes), Combobox (search and select from many) | A Button scores 1 here: the user decides whether to click. An AlertDialog scores 2: the user must weigh consequences and choose between action and cancellation. ### 2. Information Density (0-2) How much information must the user process simultaneously? | Score | Meaning | Examples | |-------|---------|---------| | 0 | No information to process | Separator, AspectRatio | | 1 | Single piece of information | Badge (one label), Tooltip (one message), Spinner | | 2 | Multiple pieces of information competing for attention | DataTable (rows and columns), Calendar (grid of dates), Command palette (filtered list and keyboard hints) | A Button scores 0 here: the label is the entire information payload, and you already know what it says before you look at it. A Combobox scores 2: the user reads the input value, scans filtered options, and compares matches simultaneously. ### 3. Interaction Complexity (0-2) How many distinct interaction modes does the component support? | Score | Meaning | Examples | |-------|---------|---------| | 0 | No interaction (display only) | Separator, Badge, Skeleton, Kbd | | 1 | Single interaction mode (click, toggle, type) | Button (click), Switch (toggle), Input (type) | | 2 | Multiple interaction modes | Combobox (type, scan, select), DatePicker (click button, navigate calendar, select date), ColorPicker (drag area, click bar, type values) | A Button scores 1: you click it. A Combobox scores 2: you type to filter, arrow-key through results, and press Enter or click to select. These are three distinct interaction modes the user must coordinate. ### 4. Context Disruption (0-2) How much does the component disrupt the user's current task context? | Score | Meaning | Examples | |-------|---------|---------| | 0 | No disruption, operates within the existing flow | Inline elements (Badge, Label, Typography), layout (Container, Grid) | | 1 | Partial disruption, adds a layer but preserves context | Popover, Sheet, Drawer (main content visible but dimmed) | | 2 | Full disruption, blocks everything else | Dialog, AlertDialog (modal overlay, focus trap, no escape except resolution) | A Button scores 0: clicking it doesn't inherently change context. An AlertDialog scores 2: it blocks the entire application and forces the user into a decision before they can return to anything else. ### 5. Learning Curve (0-2) How much prior knowledge does the component require to use correctly? | Score | Meaning | Examples | |-------|---------|---------| | 0 | Universally understood, no learning | Button, Checkbox, Text input | | 1 | Familiar pattern with some convention to learn | Accordion (click to expand isn't universal), Tabs (spatial model), Pagination (page numbers) | | 2 | Requires learning specific interactions or mental models | Command palette (keyboard shortcuts, search syntax), Menubar (nested submenus, accelerators), Combobox (type-ahead behavior, fuzzy matching) | A Button scores 0: every person who has used a computer knows what a button does. A Command palette scores 2: the user must learn that it exists, how to invoke it, the search syntax, and keyboard navigation. --- ## Scoring Walkthrough: Why a Button is a 3 | Dimension | Score | Reasoning | |-----------|-------|-----------| | Decision Demand | 1 | Click or don't click. One binary decision. | | Information Density | 0 | The label is the entire payload. "Save", "Delete", "Submit". | | Interaction Complexity | 1 | Click. That's it. | | Context Disruption | 0 | The button itself doesn't disrupt. It stays inline, doesn't overlay, doesn't trap focus. | | Learning Curve | 0 | Universal. Toddlers can use buttons. | | **Total** | **3** | | The `@attention-economics` metadata adds nuance: variant hierarchy, size hierarchy, and the rule "maximum 1 primary per section." But the cognitive load score measures intrinsic processing cost, not attention weight. --- ## Scoring Walkthrough: Why an AlertDialog is a 7 | Dimension | Score | Reasoning | |-----------|-------|-----------| | Decision Demand | 2 | Consequential choice: proceed with destructive action or cancel. | | Information Density | 1 | Title, description, action labels. More than a button, focused on one question. | | Interaction Complexity | 1 | Click one of two buttons. Simple interaction even though the decision is hard. | | Context Disruption | 2 | Full modal overlay. Focus trap. All other interactions blocked. | | Learning Curve | 1 | Most users understand dialogs, but confirmation patterns require some cognitive effort. | | **Total** | **7** | | The high score comes from disruption and decision stakes, not interaction complexity. This is why AlertDialog has `@attention-economics Full attention capture` and `@trust-building Focus defaults to Cancel`. --- ## Screen-Level Budgets Individual component scores don't exist in isolation. When components compose into screens, their loads add up. But not linearly. The user doesn't process every component simultaneously. They focus on one region at a time, with peripheral elements fading into background awareness. ### The Budget Tiers | Tier | Budget | Context | Examples | |------|--------|---------|----------| | **Focused** | 15 | Single-purpose interaction with clear entry and exit | Confirmation dialog, login form, payment modal | | **Page** | 30 | Standard application view with primary and secondary content | Settings page, data table view, list and detail | | **App** | 45 | Multi-panel workspace with concurrent activity zones | IDE layout, email client, multi-panel editor | Raw additive scoring overcounts because users focus attentionally, familiarity reduces load on return visits, and progressive disclosure gates content. The tiers account for this. ### Applying the Budget When evaluating a screen composition: 1. Sum the component scores for everything visible simultaneously. 2. Identify the tier based on the interaction context. 3. Compare sum against tier budget. 4. If over budget, look for components scoring 4+ that could be simplified, hidden behind progressive disclosure, or moved to a separate view. The budget is a lint, not a hard wall. Going 10% over is a design conversation. Going 50% over is a design problem. --- ## What the Score Does Not Measure The cognitive load score is an intrinsic property of the component. It measures the processing cost of the component itself, independent of: - **Content**: a Table is always 3/10 whether it has 5 rows or 5,000. - **Context**: a Button in a toolbar costs the same as a Button in a dialog. The screen budget accounts for context. - **Frequency**: how often a user encounters the component doesn't change its intrinsic cost. - **Aesthetic quality**: a beautifully styled Combobox costs the same as an ugly one. The companion `@attention-economics` tag captures the contextual, relative aspects. --- ## Scoring Reference Table All 52+ UI components with their scores and primary cost drivers: | Score | Components | Primary Cost Driver | |-------|-----------|-------------------| | 0 | Separator, Container, AspectRatio | Structural only, no cognitive demand | | 1 | Skeleton, Kbd | Display only, minimal information | | 2 | Badge, Breadcrumb, Label, Checkbox, Switch, Toggle, ButtonGroup, Card, Collapsible, Avatar, ScrollArea, Tooltip, Typography, Spinner, Empty, ToggleGroup | Simple state or single information piece | | 3 | Button, Alert, Sidebar, Accordion, Slider, RadioGroup, Table, Resizable, Field, HoverCard, Image, Embed, Item | One decision and one interaction mode | | 4 | Input, Textarea, InputGroup, InputOTP, Carousel, Tabs, ContextMenu, DropdownMenu, Popover, Progress, Drawer, Grid, BlockWrapper, InlineToolbar, EditorToolbar | Data entry or menu scanning | | 5 | Sheet, DatePicker, Calendar, Select, NavigationMenu, Menubar, ColorPicker, BlockCanvas | Multi-step interaction or spatial reasoning | | 6 | Dialog, Command, Combobox | Compound interaction modes or learning curve | | 7 | AlertDialog | Full context disruption and consequential decision | No Rafters component scores above 7. Components that would score 8-10 are too complex for a single component: they should be decomposed into multiple lower-scoring components composed together. --- ## Color url: https://rafters.studio/docs/color/ # Color Your AI doesn't know what blue means to your brand. It picks hex values that look plausible and moves on. Rafters doesn't guess. Every color is a decision with a reason attached. ## OKLCH, Not HSL We build in OKLCH because it matches human vision. Two colors at the same lightness actually look the same brightness. In HSL they don't. A blue at 50% lightness looks darker than a yellow at 50% lightness. This isn't a preference. It's physics. This matters because we generate entire color families from a single input. One OKLCH value produces an 11-position scale, five harmony sets, accessibility metadata, and perceptual analysis. If the color space lies, everything downstream is wrong. ## One Color In, Complete Family Out Give the system an OKLCH value. Get back: **An 11-step scale** from near-white (50) through base (500) to near-black (950). Each step has uniform perceptual distance. The scale doesn't bunch up in the darks or flatten in the lights. **Five harmony sets.** Complementary, triadic, analogous, tetradic, monochromatic. Computed from the hue angle, not picked from a palette. **Pre-computed accessibility.** WCAG AAA and APCA contrast ratios against white and black, in both directions. The system knows which positions pair safely before any component uses them. **Perceptual analysis.** Temperature, atmospheric weight, how visually heavy the color feels. The kind of information a senior designer carries in their head, encoded as data. ## The 11 Families Every project starts with 11 semantic families. These are roles, not colors. **primary** is the brand. It's on the nav, the CTAs, the elements that say "this is us." **secondary** supports the primary. It's the quieter actions, the less prominent UI. **destructive** is irreversible. Deletions, errors, the things that can't be undone. **success**, **warning**, **info** are status. They tell the user what happened without requiring them to read. Status hues stay conventional — recognizability is the point — but their chroma derives from your brand seed. A muted brand gets muted status colors. They belong to your palette instead of looking pasted on. **neutral** is the structure. Backgrounds, borders, text. The chrome that holds everything else. It's a role, not a family: zinc sits behind it by default, and like every role it can be repointed at import. **accent**, **highlight**, **muted**, **tertiary** fill the gaps between the primary roles. These families are the foundation, not the ceiling. A game adds faction colors. A bank adds tier colors. A retailer adds seasonal colors. Custom families get the same treatment: full scales, accessibility, dark mode, dependency graph. ## Dark Mode Is Math Dark mode is computed, not redesigned. But computed doesn't mean a naive flip. The system finds each color's light-mode pair first — the background plus its WCAG-verified foreground — then inverts the pair as a unit. A mid-tone stays a mid-tone, shifted for a dark surface: a 500 lands near 400, not at 950. The relationship between background and foreground survives the crossing, and the dark foreground re-derives against the dark background, so the pair holds through every cascade. Every result is verified against the family's contrast matrices. When the inverted pair can't satisfy contrast, the system nudges to the nearest passing pair and reports which strategy fired. Degradation is visible. Never silent. Change your primary color and both modes update. And when computed dark isn't the dark you want — dark mode is taste, not just inversion — override the dark token directly, with a reason. The override anchors in the graph and survives regeneration. ## The Dependency Graph Change primary and 20+ tokens cascade. Primary-foreground, primary-hover, primary-active, primary-ring, sidebar-primary, chart-primary. They all reference the primary family at computed positions. The semantic tokens don't store colors. They store references: "primary at position 500", not "oklch(0.5 0.15 240)." Swap your entire primary palette and every component updates without touching a single component file. ## The Why-Gate Override a color and the system asks why. Not to be bureaucratic, to build institutional memory. Six months from now, someone asks "why is destructive different from the default?" The answer is in the token, not in someone's head who quit in April. --- ## Depth url: https://rafters.studio/docs/depth/ # Depth Interfaces have layers. A dropdown sits above the page. A modal sits above the dropdown. A toast sits above everything. If these layers collide, the user loses trust in the interface. ## The Stack Every layer has a fixed position. No z-index wars. No `z-[9999]`. | Layer | Token | What lives here | |-------|-------|----------------| | Base | 0 | Page content, cards, form controls | | Navigation | `z-depth-navigation` | Sidebars, fixed headers | | Dropdown | `z-depth-dropdown` | Select menus, dropdown menus, comboboxes | | Popover | `z-depth-popover` | Popovers, hover cards, tooltips | | Overlay | `z-depth-overlay` | Background dim behind modals | | Modal | `z-depth-modal` | Dialogs, sheets, command palettes | | Toast | `z-depth-toast` | Notifications that sit above everything | These are not suggestions. A dropdown at `z-depth-modal` is a bug. A modal at `z-depth-dropdown` is invisible behind its own overlay. ## Depth Comes With Shadow Higher layers cast larger shadows. This is how the real world works. A sheet of paper held close to a desk casts a tight shadow. Held high, the shadow spreads. | Depth | Shadow | |-------|--------| | Base (cards, inputs) | `shadow-sm` tight, subtle | | Dropdown, popover | `shadow-md` visible but grounded | | Modal, sheet | `shadow-lg` clearly elevated | The shadow tokens are in `@theme`. Tailwind's `shadow-sm`, `shadow-md`, `shadow-lg` read from the system. Change the shadow definition, every component at that depth updates. ## Focus Trap When a modal opens, keyboard focus is trapped inside it. Tab cycles through the modal's focusable elements. Escape closes it. Focus returns to the element that opened it. This is not optional. Without focus trap, a keyboard user tabs behind the modal into invisible content. The overlay dims the page visually, but screen readers and keyboards don't see visual dimming. --- ## Design Philosophy url: https://rafters.studio/docs/design-philosophy/ # Rafters Design Philosophy This document captures the design principles that inform Rafters. It balances three perspectives: the obsessive craft of Jobs/Ive, the generative experimentation of Joshua Davis, and the empirical usability rigor of Jakob Nielsen. --- ## The Three Pillars ### 1. Craft: Jobs, Ive, and Dieter Rams The foundation. Design is not decoration, it is the fundamental soul of a creation. **Deep Simplicity** > "Simplicity isn't just a visual style. It's not just minimalism or the absence of clutter. It involves digging through the depth of the complexity. To be truly simple, you have to go really deep." > Jony Ive This means understanding every token, every spacing decision, every color relationship. Not simple because we removed things, but simple because we understood what was essential. **Dieter Rams' 10 Principles** 1. Good design is **innovative**. Technology enables new possibilities. 2. Good design makes a product **useful**. Functional, psychological, aesthetic. 3. Good design is **aesthetic**. Products we use daily affect our wellbeing. 4. Good design makes a product **understandable**. Self-explanatory at best. 5. Good design is **unobtrusive**. Tools, not decorations. 6. Good design is **honest**. No manipulation, no false promises. 7. Good design is **long-lasting**. Avoids fashion, never appears antiquated. 8. Good design is **thorough down to the last detail**. Nothing arbitrary. 9. Good design is **environmentally friendly**. Conserves resources. 10. Good design is **as little design as possible**. Less, but better. **Apple's Marketing Philosophy (Mike Markkula)** - **Empathy**: intimate connection with how users feel. - **Focus**: eliminate unimportant opportunities to do important ones well. - **Impute**: people judge by signals. Every detail communicates. --- ### 2. Experimentation: Joshua Davis The spark. Structured chaos that creates delight. Joshua Davis pioneered "Dynamic Abstraction", using code to generate art. His work at [Praystation](https://joshuadavis.com/) and Once Upon a Forest showed that creativity isn't about following rules, it is about exploring possibilities. **Key Principles** - **Structured Chaos**: rules create the boundaries, randomness fills them with life. - **Open Source Spirit**: share the source, let others build on it. - **Multi-disciplinary**: designer, programmer, and critic in one process. - **Energy Flow**: "when the chi is right, it is done." **What This Means for Rafters** Design systems can feel sterile. Davis's approach reminds us that within constraints, there should be room for delight, surprise, and personality. The system provides the grammar; each implementation speaks with its own voice. Components should feel alive, not stamped from a factory. --- ### 3. Usability: Jakob Nielsen The check. Empirical validation that design actually works. Nielsen's 10 Usability Heuristics (1994, refined 2020) remain the standard because they are based on how humans actually process information. **The 10 Heuristics** 1. **Visibility of System Status**. Keep users informed. Feedback within reasonable time. 2. **Match Between System and Real World**. Speak user language, not system jargon. Natural mapping. 3. **User Control and Freedom**. Clear emergency exits. Undo and redo. Users make mistakes. 4. **Consistency and Standards**. Same words, same actions, same meanings. Follow conventions. 5. **Error Prevention**. Prevent problems before they occur. Constraints and confirmations. 6. **Recognition Rather than Recall**. Minimize memory load. Make options visible in context. 7. **Flexibility and Efficiency of Use**. Shortcuts for experts. Customization. Accommodate all skill levels. 8. **Aesthetic and Minimalist Design**. Every extra element competes with relevant ones. Focus on essentials. 9. **Help Users Recognize, Diagnose, and Recover from Errors**. Plain language. Precise problem indication. Constructive solutions. 10. **Help and Documentation**. Task-focused, searchable, contextual. Concrete steps. --- ## The Balance These three perspectives create tension, and that tension produces good design: | Perspective | Focus | Risk if Unchecked | |-------------|-------|-------------------| | **Craft (Jobs/Ive)** | Perfection in detail | Over-polish, losing the forest for trees | | **Experimentation (Davis)** | Delight and surprise | Chaos without purpose, novelty over function | | **Usability (Nielsen)** | User success | Sterile, checkbox-driven, no soul | Rafters must be: - **Crafted**: every token relationship considered, every detail intentional. - **Alive**: room for personality within the system, delight in the details. - **Usable**: Section 508 compliant, WCAG 2.2 AAA, genuinely helpful. Not "accessible but looks like ass." Not "beautiful but confusing." Not "functional but forgettable." All three, together. --- ## What This Means in Practice ### For Components - Every component has cognitive load ratings (Nielsen: recognition over recall). - Every variant has semantic meaning (Rams: honest, understandable). - Interactions provide immediate feedback (Nielsen: system status). - Animations serve purpose, not decoration (Rams: unobtrusive). - There's room for brand expression within the system (Davis: structured chaos). ### For Documentation - Docs demonstrate the design system itself (Impute: every detail communicates). - Content is task-focused and scannable (Nielsen: help and documentation). - Examples show personality within constraints (Davis: energy flow). - Nothing arbitrary (Rams: thorough to the last detail). ### For the AI Layer - MCP tools capture the designer's intent, not just the API surface. - Do and Never guidance prevents misuse (Nielsen: error prevention). - Cognitive load scores help AI make appropriate choices. - The AI learns the philosophy, not just the components. --- ## Sources ### Jobs, Ive, and Craft - [How Steve Jobs' Love of Simplicity Fueled A Design Revolution](https://www.smithsonianmag.com/arts-culture/how-steve-jobs-love-of-simplicity-fueled-a-design-revolution-23868877/) - [Jonathan Ive: Principles and Philosophy of Powerful Design](https://www.playforthoughts.com/blog/jonathan-ive-power-of-great-design) - [Dieter Rams: Good Design (Vitsoe)](https://www.vitsoe.com/us/about/good-design) ### Joshua Davis - [Joshua Davis Studios](https://joshuadavis.com/) - [Creative Coding: Programming Visuals with Joshua Davis](https://dribbble.com/overtime/2018/10/09/creative-coding-programming-visuals-with-joshua-davis) - [Profile: Joshua Davis (Creative Bloq)](https://www.creativebloq.com/computer-arts/profile-joshua-davis-5079534) ### Jakob Nielsen - [10 Usability Heuristics for User Interface Design (NN/g)](https://www.nngroup.com/articles/ten-usability-heuristics/) - [How I Developed the 10 Usability Heuristics](https://www.uxtigers.com/post/usability-heuristics-history) --- ## Editor Architecture url: https://rafters.studio/docs/editor-architecture/ # Editor Architecture The editor is one React component on top of a constellation of vanilla-TypeScript primitives. The primitives are framework-agnostic. They run in any host. The component layer adapts them to React. This split is the load-bearing decision. Six products consume the editor. None of them should fork it to add a feature. The primitives layer is where capabilities live. The component layer is where consumption happens. ## Two Kinds Of Primitives **Leaf primitives.** Zero workspace dependencies. Callback injection for state. They do one thing and emit events. Examples: `keyboard-handler`, `focus-trap`, `outside-click`, `clipboard`, `history`, `block-canvas`, `inline-formatter`, `drag-drop`. The vast majority of primitives are leaves. **Composition primitives.** Orchestrate multiple leaves with shared reactive state via nanostores atoms. Two exist today: `block-handler` (`packages/ui/src/primitives/block-handler.ts`) and `document-editor` (`packages/ui/src/primitives/document-editor.ts`). These are the only primitives allowed to import nanostores. The rule is asymmetric on purpose. A leaf cannot reach for nanostores. A composition primitive can. Leaves stay portable. Composition stays cohesive. If a leaf needs reactive state, it accepts callbacks and lets the host wire them. ## Self-Contained Component Rule Every component in `@rafters/ui` is self-contained. It imports React, primitives from `../../primitives/`, and other components from the same package. Nothing else. No imports from `@rafters/shared`. No imports from `@rafters/composites`. No imports from `@rafters/design-tokens`. The editor is the lynchpin. If it depends on the rest of the monorepo, every consumer drags the rest of the monorepo with them. The rule has been violated exactly once and was rolled back. When you see a tempting import path that crosses packages, the answer is to either inline the helper or invert the dependency. ## Package Boundaries ``` @rafters/ui ├── components/ui/editor.tsx React surface ├── primitives/ Vanilla TS, framework-agnostic │ ├── block-canvas, block-handler, block-wrapper, ... │ ├── inline-formatter, inline-toolbar, command-palette │ ├── serializer.ts EditorSerializer interface │ └── serializer-mdx.ts Full bidirectional MDX adapter └── deps: react, nanostores @rafters/composites ├── manifest.ts Zod schemas ├── bridge.ts toBridgeItem, instantiateBlocks, serializeToComposite ├── registry.ts register, get, search ├── rules.ts matchRules, findCompatibleConsumers/Producers ├── serializer.ts Legacy toMdx — do not use for new work └── deps: zod ``` The intent is clear. `@rafters/composites` ships only Zod schemas, JSON validation, and pure functions. It depends on zod and nothing else. ## Architecture Violations The intent is not the current state. Two known violations live in `@rafters/composites`: `packages/composites/src/registry.ts:8` imports `fuzzyScore` from `@rafters/ui/primitives/typeahead`. `fuzzyScore` is a small pure helper. Inlining it removes the dependency. `packages/composites/src/bridge.ts:10` imports `BlockPaletteItem` as a type from `@rafters/ui/primitives/block-palette`. Type-only, but still couples the package boundary. The shape is small. Defining it locally in `bridge.ts` removes the coupling. Both are tracked in [Known Gaps](/docs/editor-known-gaps/). Documentation flags them. Fixes are out of scope for this audit. ## Six Product Reuse Model The editor is the lynchpin for six products. Each provides its own block types, composites, and rules. The editor is the shared authoring core. **Gitpress.** MDX page editor for content sites. Uses `mdxSerializer` from `@rafters/ui`. Folder-scoped file picking and frontmatter form sit on top. **Kelex.** Zod-to-form codegen. Outputs editor blocks. Form generation is a serialization adapter. **Ezmode guilds.** In-game page builder. Uses block-palette and composites with a gaming-specific palette. **Courses.** Lesson authoring. Block types extended for course-specific content (quizzes, video timestamps). **Ctrl.** Operations dashboard authoring. Block types for charts, queries, alerts. **Veneer.** Documentation rendering. Reads composite blocks, generates static HTML and PDF. The editor doesn't know about any of them. Each consumer extends with the surface points the editor exposes: serializer adapters, block types via `meta`, composites via the registry, palette items, slash commands, rules. ## Why The Editor Defines Its Own Contracts The editor is the source of truth for block shape. `BaseBlock` lives in `@rafters/ui` because the editor renders it. `CompositeBlock` lives in `@rafters/composites` because that package owns serialization. Both share the same fields by hand, validated independently. The temptation is to extract a shared types package. It has been considered. The conclusion: a shared package becomes a synchronization burden the moment one side needs to evolve faster than the other. Hand-keeping two structurally identical types is cheaper than the version coupling. When the editor grows a field, composites grows the same field. When composites grows a Zod refinement, the editor doesn't have to care. The duplication is the contract. ## When To Add A New Primitive A new primitive earns its keep when it is reusable across at least two products and has a contract that doesn't lean on any one consumer. If the capability is one consumer's specifics, it belongs in that consumer. Leaf vs composition is decided by state. If the new primitive needs to emit events but holds no state of its own, it's a leaf. If it needs to coordinate other primitives with shared reactive state, it's composition. Composition is rare. Two exist today and that's appropriate. --- ## Editor Behavior Matrix url: https://rafters.studio/docs/editor-behavior-matrix/ # Editor Behavior Matrix The matrix exists to catch drift. Every primitive in the editor has a small set of testable claims. When the code changes and a claim becomes false, this page is the place that should also change. Failing to update it is the signal that the claim is now silent and undefended. Each row carries four columns: - **Guarantees.** What the primitive promises. A consumer can rely on this. - **Does not guarantee.** What the primitive deliberately does not promise. A consumer who needs this builds it elsewhere. - **Known edge cases.** Where the contract bends. Documented so consumers aren't surprised. - **Test coverage.** Where the guarantee is asserted, or `untested` if no test exists. When a row is `untested`, the guarantee lives only in code comments and reader memory. That is the most vulnerable form a contract can take. ## Canvas Layer ### block-canvas | Aspect | Detail | |---|---| | Guarantees | Tracks selection, focus, and keyboard routing for `[data-block-id]` elements. Click and arrow keys change focus. Editable elements (`contenteditable`, `input`, `textarea`) pass events through to the host. | | Does not guarantee | CRUD on blocks. Multi-block selection. Drag-drop integration (use `drag-drop` and `canvas-drop-zone`). | | Known edge cases | Slash command position is hardcoded to a 144px offset from container center. Custom rendering surfaces with non-standard dimensions will see the slash menu in the wrong place. | | Test coverage | untested | ### block-handler | Aspect | Detail | |---|---| | Guarantees | Subscribes to `block-canvas`, `clipboard`, and `history`. Exposes a nanostores atom of unified state. Undo and redo move backward and forward through history entries. | | Does not guarantee | Block CRUD (create, delete, reorder). Use `block-operations` for pure mutations and apply them through the consumer. | | Known edge cases | Cut-and-paste round-trips through the clipboard primitive's MIME type. Copying from outside the editor uses text-only fallback. | | Test coverage | untested (composition primitive; covered indirectly through `document-editor`) | ### block-wrapper | Aspect | Detail | |---|---| | Guarantees | Per-block hover chrome state (drag handle, action menu) computed as `isHovered \|\| isFocused \|\| menuOpen`. Cleanup on destroy. | | Does not guarantee | Render of the chrome elements. Only the state. The consumer renders. | | Known edge cases | Menu state is consumer-managed. `block-wrapper` listens; it does not store. | | Test coverage | untested | ### document-editor | Aspect | Detail | |---|---| | Guarantees | DOM reconciliation on every input event. Markdown shortcuts: `# `, `## `, `> `, `- `, `1. `, ``` for h1–h6, blockquote, list, code. Cmd+Alt+0–4 converts the focused block's type. Selection-delete is browser-handled; everything else routes through `block-operations`. | | Does not guarantee | Inline mark persistence on user edit (see [Known Gaps](/docs/editor-known-gaps/)). Custom block types beyond the built-ins. | | Known edge cases | Shortcut detection runs on `beforeinput`, not `keydown`. IMEs with intermediate composition states may produce surprising shortcut activations. | | Test coverage | untested | ### block-operations | Aspect | Detail | |---|---| | Guarantees | Pure functions. `splitBlock`, `mergeWithPrevious`, `mergeWithNext`, `deleteBlock`, `convertBlockType`, `insertBlocksAt` return new arrays without mutating inputs. New blocks get fresh UUIDs. `blockContentToText` flattens `InlineContent[]` to a string. | | Does not guarantee | Side effects. Persistence. Selection state changes. | | Known edge cases | Merge across heterogeneous block types coerces to the destination type. A list-item merged into a text block becomes part of the text block's content. | | Test coverage | tested at `packages/ui/src/primitives/block-operations.test.ts` | ## Input Layer ### keyboard-handler | Aspect | Detail | |---|---| | Guarantees | Type-safe keyboard routing with modifier support. `Space` is normalized to `' '` in the key map. Capture-phase support via options. Cleanup on unsubscribe. | | Does not guarantee | Cross-IME consistency. Browser-specific keycode quirks. | | Known edge cases | Modifier state is read from the event, not from a separate state machine. Compound modifier sequences (Cmd then Shift then K) need consumer-side state. | | Test coverage | untested | ### escape-keydown | Aspect | Detail | |---|---| | Guarantees | Document-level Escape listener. Cleanup on unsubscribe. | | Does not guarantee | Stack-aware dismissal. Use `dismissable-layer` for nested overlays. | | Known edge cases | Escape during IME composition is intercepted by the browser before reaching the listener. | | Test coverage | untested | ### focus-trap | Aspect | Detail | |---|---| | Guarantees | Tab and Shift+Tab cycle within the trap element. Restores previously focused element on cleanup. iOS Safari elastic-scroll prevention available via `preventBodyScroll`. | | Does not guarantee | Cross-window focus. Iframe focus. | | Known edge cases | Disabled tabbables are skipped via the selector. Custom interactive elements without `tabindex` are not detected. | | Test coverage | untested | ### roving-focus | Aspect | Detail | |---|---| | Guarantees | Exactly one item has `tabindex=0` at any time. RTL inversion via `Direction`. Disabled items skipped. | | Does not guarantee | Visual focus indicators. Consumer styles the focus ring. | | Known edge cases | Adding or removing items mid-session requires `refreshRovingFocus`. The primitive does not observe DOM mutations. | | Test coverage | untested | ### cursor-tracker | Aspect | Detail | |---|---| | Guarantees | `findBlockElement(node)` walks up the DOM until it finds the nearest `[data-block-id]` ancestor. Returns null if no ancestor matches. | | Does not guarantee | Selection state across multiple editor instances. Cross-iframe selection. | | Known edge cases | A block element rendered as a React fragment has no DOM node and breaks the walk. The render-path contract requires single-node block elements. | | Test coverage | untested | ## Formatting Layer ### inline-formatter | Aspect | Detail | |---|---| | Guarantees | Bidirectional bridge: `serializeSelection() -> InlineContent[]` walks the DOM, `deserializeToDOM(content) -> DocumentFragment` walks the array. Format presets ship for bold, italic, code, strikethrough, link. SSR-safe (no-op if `typeof window === 'undefined'`). | | Does not guarantee | Persistence of user-applied formatting back into block content. Editor.tsx never calls `serializeSelection` (see [Known Gaps](/docs/editor-known-gaps/)). | | Known edge cases | Partial format removal across mixed-mark ranges splits and rebuilds the affected nodes. Performance is O(n) on the selected range length. | | Test coverage | untested | ### inline-toolbar | Aspect | Detail | |---|---| | Guarantees | Viewport-aware position. Prefers above the selection. Flips below on collision. 8px viewport padding. `getModifierKey()` returns Cmd on Mac, Ctrl elsewhere. | | Does not guarantee | Render of toolbar buttons. Provides config and positioning. | | Known edge cases | Selection that crosses block boundaries: position is computed for the first range, not the union. | | Test coverage | untested | ### command-palette | Aspect | Detail | |---|---| | Guarantees | Slash trigger fires at start-of-line or after whitespace. Fuzzy scoring: consecutive +2, start-of-word +3, base +1 per char. | | Does not guarantee | Async command actions. Commands run synchronously; consumers wrap async work themselves. | | Known edge cases | Trigger inside an inline-code mark is suppressed via the formatter's mark-aware check. | | Test coverage | untested | ## Palette Layer ### block-palette | Aspect | Detail | |---|---| | Guarantees | Categorized grid of draggable templates. Live region for screen reader announcements. Drag MIME `application/x-rafters-block` and `application/x-rafters-drag-data`. Event delegation through `[data-palette-item][data-palette-id]`. | | Does not guarantee | Item rendering. Consumer provides `renderPaletteItem`. | | Known edge cases | Interactive elements (Button, Input) inside a preview wrapper eat clicks. Wrapper requires `pointer-events-none`. | | Test coverage | untested | ### rule-palette | Aspect | Detail | |---|---| | Guarantees | Same shape as `block-palette` for rules. Drag MIME `application/x-rafters-rule`. | | Does not guarantee | Rule application logic. Drop handler invokes consumer callback. | | Known edge cases | The rule palette does not currently expose a remove-rule UI. Add-only. | | Test coverage | untested | ### typeahead | Aspect | Detail | |---|---| | Guarantees | Type-to-search with prefix and fuzzy modes. `fuzzyScore(query, target)` returns a numeric score; higher is better. Global type-to-search timeout 1000ms. | | Does not guarantee | Diacritic-insensitive matching. Locale-aware sort. | | Known edge cases | An empty query returns 0 for every target. Consumers should handle the empty-query case explicitly. | | Test coverage | untested | ## Drag, Drop, State Layer ### drag-drop | Aspect | Detail | |---|---| | Guarantees | Mouse, keyboard, and touch drag. Touch long-press 300ms. Screen reader announcements built in. MIME `application/x-rafters-drag-data`. | | Does not guarantee | Cross-window drag. Desktop file-drop integration. | | Known edge cases | Module-level shared keyboard drag state across all instances. `resetDragDropState()` is the escape hatch when a stuck state needs clearing. | | Test coverage | untested | ### canvas-drop-zone | Aspect | Detail | |---|---| | Guarantees | Y-axis midpoint determines insertion index. rAF-throttled `dragover` for performance. | | Does not guarantee | Horizontal layouts. The midpoint algorithm assumes vertical block flow. | | Known edge cases | Empty canvas accepts drops at index 0. Single-block canvas accepts before or after based on midpoint. | | Test coverage | untested | ### clipboard | Aspect | Detail | |---|---| | Guarantees | Copy and paste with custom MIME and text fallback. Permission denial handled gracefully. | | Does not guarantee | Cross-origin clipboard reads when permission is denied. | | Known edge cases | Browser permission prompts can interrupt the flow. The primitive returns null on denial; the consumer decides what to do. | | Test coverage | untested | ### history | Aspect | Detail | |---|---| | Guarantees | `push`, `undo`, `redo`, `batch`, `clear`, `canUndo`, `canRedo`. Batch mode accumulates without recording until close. Limit enforces FIFO drop of oldest entries. | | Does not guarantee | Cross-tab undo. Persistence to storage. | | Known edge cases | Deep-equal duplicate detection on `push`. Pushing identical state twice is a no-op. | | Test coverage | untested | ## Overlay Layer ### block-context-menu | Aspect | Detail | |---|---| | Guarantees | Shift+F10 opens the menu for the focused block. Menu position respects 4px gap from viewport edges. Escape and outside-click dismiss. | | Does not guarantee | Right-click suppression on touch. Touch-equivalent interaction is a long-press handled by `drag-drop`. | | Known edge cases | Menu items that themselves open submenus are out of scope. The primitive is single-level. | | Test coverage | untested | ### dismissable-layer | Aspect | Detail | |---|---| | Guarantees | Outside-click, Escape, focus-outside detection unified. Stacking support for nested layers via `createDismissableLayerStack`. CSS injection disables body pointer-events when modal. | | Does not guarantee | Animation. Dismissal is immediate. | | Known edge cases | Nested layers must be created via the stack helper, not `createDismissableLayer` directly. | | Test coverage | untested | ### collision-detector | Aspect | Detail | |---|---| | Guarantees | `computePosition` returns coordinates and chosen side after collision detection. `applyPosition` writes them to the floating element's style. `autoPosition` reapplies on resize via ResizeObserver. | | Does not guarantee | Scroll-aware repositioning by default. Consumers wire scroll listeners themselves. | | Known edge cases | Anchor inside a transformed ancestor: position is computed in viewport coordinates and may need adjustment. | | Test coverage | untested | ### outside-click | Aspect | Detail | |---|---| | Guarantees | Detects clicks and pointer-down outside the target. Touch deduplication via 50ms threshold. Capture phase. | | Does not guarantee | Cross-iframe detection. | | Known edge cases | A click inside an open shadow root may be reported as outside if the host element is outside. | | Test coverage | untested | ### hover-delay | Aspect | Detail | |---|---| | Guarantees | Configurable show and hide delays. Global open timestamp coordinates skip-delay across instances. 300ms threshold for "recently open." | | Does not guarantee | Hover intent detection by velocity. Use `createHoverIntent` for direction-aware logic. | | Known edge cases | Long delays interact with scroll: a tooltip scheduled to open may fire after the trigger has scrolled out of view. | | Test coverage | untested | ### dialog-aria | Aspect | Detail | |---|---| | Guarantees | Pure functions returning ARIA prop bags. Includes `data-state` for visual state. | | Does not guarantee | Focus management. `focus-trap` is a separate concern. | | Known edge cases | The functions return new objects on every call. Memoize in the consumer if reference equality matters. | | Test coverage | untested | ### aria-manager | Aspect | Detail | |---|---| | Guarantees | Type-safe ARIA attribute mutation with spec validation. Auto-generates IDs for relationships when missing. | | Does not guarantee | Cross-element relationship cleanup. Removing one side of a relationship leaves the other side referencing a now-missing id. | | Known edge cases | Setting an invalid attribute value throws. Consumers must catch or pre-validate. | | Test coverage | untested | ## Serialization Layer ### EditorSerializer interface | Aspect | Detail | |---|---| | Guarantees | `id: string`, `extensions: readonly string[]`, `deserialize(input): DeserializeResult`, `serialize(blocks, frontmatter?): string`. | | Does not guarantee | Round-trip equivalence. Adapter implementations claim that themselves. | | Known edge cases | Extensions are readonly arrays; consumers must not mutate. | | Test coverage | tested via concrete adapter tests | ### mdxSerializer | Aspect | Detail | |---|---| | Guarantees | Round-trip preserves bold, italic, code, strikethrough, link marks. Frontmatter parses YAML keys (booleans, numbers, arrays) and serializes back. MDX JSX components stored as `type: 'component'` with `meta.props`. | | Does not guarantee | MDX expressions with side effects. Custom remark plugins. | | Known edge cases | JSX attributes parsed by `jsxAttributesToProps` flatten complex expressions to strings. Consumer reconstruction is consumer's problem. | | Test coverage | tested at `packages/ui/src/primitives/serializer-mdx.test.ts` and `packages/ui/src/primitives/serializer-mdx-fixtures.test.ts` | ### Composites toMdx (legacy) | Aspect | Detail | |---|---| | Guarantees | Renders heading, text, blockquote, list, divider, grid, composite:* to markdown. | | Does not guarantee | Inline mark preservation. Frontmatter. Round-trip parsing. quote, input, button, image (drops to comment). | | Known edge cases | MAX_DEPTH=50 differs from `instantiateBlocks` maxDepth=10. | | Test coverage | tested at `packages/composites/src/serializer.test.ts` | ## Composites Layer ### Composite manifest | Aspect | Detail | |---|---| | Guarantees | Zod-validated. `id` matches `/^[a-z0-9-]+$/`. `cognitiveLoad` is integer 1–10. `category` is free-form string. | | Does not guarantee | Backward-compat for old composites missing `solves`, `appliesWhen`, `usagePatterns`. They parse fine; consumers must handle the optional fields. | | Known edge cases | Display name with non-kebab characters fails `serializeToComposite` validation. Names should yield valid kebab ids. | | Test coverage | tested at `packages/composites/src/manifest.test.ts` | ### bridge | Aspect | Detail | |---|---| | Guarantees | `instantiateBlocks` regenerates every block id with `crypto.randomUUID()`. `parentId` and `children` references are remapped. Composite expansion supports `maxDepth=10`. `serializeToComposite` derives `input` from root rules, `output` from leaf rules, keywords from block types and rule names. | | Does not guarantee | Determinism across runs. Each call produces fresh UUIDs. | | Known edge cases | Nested composite without explicit `parentId` inherits the parent's new id. Missing composite resolves to a fallback text block "Unknown composite: \{id\}". | | Test coverage | tested at `packages/composites/src/bridge.test.ts` | ### registry | Aspect | Detail | |---|---| | Guarantees | O(1) `get`, in-memory Map keyed by `manifest.id`. `search` returns fuzzy-scored matches sorted descending. | | Does not guarantee | Persistence. Cross-process visibility. | | Known edge cases | Imports `fuzzyScore` from `@rafters/ui` (architectural violation, see [Known Gaps](/docs/editor-known-gaps/)). | | Test coverage | tested at `packages/composites/src/registry.test.ts` | ### rules | Aspect | Detail | |---|---| | Guarantees | `matchRules` returns `{ matched, missing, extra, compatible }`. Compatibility is nominal name matching. Filter helpers `findCompatibleConsumers` and `findCompatibleProducers`. | | Does not guarantee | Runtime data validation. Zod schemas in `built-in-rules/` exist but are not invoked from blocks (see [Known Gaps](/docs/editor-known-gaps/)). | | Known edge cases | Rule name collisions across unrelated codebases register as compatible. | | Test coverage | tested at `packages/composites/src/rules.test.ts` and `packages/composites/src/built-in-rules.test.ts` | ## Reading The Matrix A `tested` entry is a contract that gets exercised by the test suite. A claim that breaks should produce a failing test. An `untested` entry is a contract that lives in this page and in the source. A claim that breaks will be silent until a consumer notices. The honest path forward is to convert `untested` rows into tests. Each guarantee should map to at least one assertion. The matrix is the spec; the tests are the enforcement. --- ## Editor Component url: https://rafters.studio/docs/editor-component/ # Editor Component `` is a React component at `packages/ui/src/components/ui/editor.tsx`. It is a thin React adapter over the `document-editor` composition primitive. It accepts blocks. It emits changes. It exposes an imperative ref for CRUD. The internal state is a nanostores atom synced to `document-editor`. ## Import ```ts ``` The component is the default export of `editor.tsx`. Types are named exports. ## EditorProps The full prop surface. Defined at `packages/ui/src/components/ui/editor.tsx:41`. ```ts interface EditorProps extends Omit, 'defaultValue' | 'onChange'> { defaultValue?: EditorBlock[]; value?: EditorBlock[]; onValueChange?: (blocks: EditorBlock[]) => void; onValueCommit?: (blocks: EditorBlock[]) => void; toolbar?: boolean; emptyState?: React.ReactNode; disabled?: boolean; dir?: Direction; className?: string; } ``` `defaultValue` is the uncontrolled initial blocks. Ignored when `value` is set. `value` is the controlled blocks. When present, the component mirrors it on every render. The internal atom syncs to it (line 457). `onValueChange` fires on every block mutation. Live. Use it as the live observer or as the controlled-mode setter. `onValueCommit` fires only on structural changes: `addBlock`, `addBlocks`, `removeBlocks`, `moveBlock`. Not fired on `updateBlock`. Useful for debounced saves. `toolbar` toggles a small in-component toolbar with a block-type selector and undo/redo. Off by default. `emptyState` is the fallback rendered when `blocks.length === 0`. Defaults to a built-in `DefaultEmptyState`. `disabled` sets `aria-disabled="true"`, applies `opacity-50 pointer-events-none` to the canvas, and forces `tabIndex=-1`. `dir` is `'ltr' | 'rtl' | 'auto'`. Passed to the root Container. The remaining `HTMLDivAttributes` (`id`, `data-*`, `aria-*`) pass through to the root. ## EditorControls Ref The imperative surface. Get it via `ref`. Defined at `editor.tsx:54`. ```ts interface EditorControls { addBlock: (block: EditorBlock, index?: number) => void; addBlocks: (blocks: EditorBlock[], index?: number) => void; removeBlocks: (ids: Set) => void; moveBlock: (id: string, toIndex: number) => void; updateBlock: (id: string, updates: Partial) => void; getBlocks: () => EditorBlock[]; focus: () => void; deselect: () => void; } ``` Use it for programmatic insertion (slash commands, palette clicks, paste handlers) and for synchronous reads of current state. `deselect()` is currently a stub (line 408). It accepts no arguments and returns nothing. The wiring to `documentEditor.deselect()` was not landed. See [Known Gaps](/docs/editor-known-gaps/). ## Controlled vs Uncontrolled Uncontrolled is the default. Pass `defaultValue`. The component owns the blocks. Read changes via `onValueChange`. ```tsx console.log(blocks.length)} /> ``` Controlled requires both `value` and `onValueChange`. The component reflects the prop on every render. ```tsx const [blocks, setBlocks] = React.useState(initial); ``` Mixing `defaultValue` and `value` is not supported. `value` wins. ## Change vs Commit `onValueChange` is live. Every keystroke that mutates a block fires it. Use it for live UI (counts, badges, dirty state) and for the controlled-mode setter. `onValueCommit` is structural-only. It fires when blocks are added, removed, or moved. It does not fire on content edits. Use it for debounced saves, dirty-flag clearing, or anything that should not run on every character. If you only set `onValueChange`, every keystroke goes to your handler. If you only set `onValueCommit`, content edits never reach you. Most consumers want both: live for UI, commit for persistence. ## Render Path The component renders a flat list of blocks via `DocumentBlock` (line 153). One block per JSX element. The shape per block type lives at [Render Path](/docs/editor-render-path/). Inline content is converted by `renderInlineContent` at line 129. Mark application order is code, link, strikethrough, italic, bold (outermost). The order matters when reading the rendered HTML. ## Internal Architecture The component instantiates `createDocumentEditor` on mount and stores the controls in a ref (line 413). It subscribes to `docEditor.$state` for `[canUndo, canRedo]` updates. It tracks focus via a `selectionchange` listener that calls `findBlockElement(node)` from `cursor-tracker` to derive `focusedBlockId`. The blocks atom (`blocksAtomRef`) is the source of truth. `onBlocksChange` from `document-editor` writes to it. The React render reads from it. Controlled-mode changes to `value` sync into the atom on a `useEffect` (line 457). Cleanup on unmount calls `docEditor.destroy()`. ## Declared But Not Wired The file exports several types that are not yet consumed by `Editor` itself. They are visible in `apps/demo/src/components/demos/EditorPlayground.tsx`, which passes props the component does not declare. This is a real gap. Documenting it here so consumers do not assume the API is complete. The following are exported and used by demo code but not present in `EditorProps`: - `sidebar?: EditorSidebarConfig` — block palette in the demo, not in EditorProps. Type at `editor.tsx:80`. - `rulePalette?: EditorRulePaletteConfig` — rule palette, type at `editor.tsx:92`. - `commandPalette?: { commands: SlashCommand[] }` — slash command palette. `SlashCommand` type at `editor.tsx:65`. - `inlineToolbar?: boolean | { ... }` — floating format toolbar. - `blockContextMenu?: { items: ContextMenuItem[] }` — right-click menu. - `onSaveAsComposite?: (data: SaveCompositeData) => void` — composite save handler. Type at `editor.tsx:73`. The types are intact. The wiring was stripped post-PR #1048 and has not been restored. Consumers building today should compose these surfaces externally and not pass them as props to ``. The full sketch and a fix path live at [Known Gaps](/docs/editor-known-gaps/). ## Disabled Mode Setting `disabled` is non-destructive. Blocks remain rendered. The canvas becomes non-interactive. Programmatic mutation through the ref still works. This is by design: a host can lock the surface during a save without losing state. ## Direction `dir` accepts `'ltr'`, `'rtl'`, `'auto'`. The roving-focus and inline-toolbar primitives respect direction. Setting `'auto'` defers to the browser's directionality detection on the root container. --- ## Editor Composites url: https://rafters.studio/docs/editor-composites/ # Editor Composites. A composite is how teams ship reusable block assemblies. Not a component. Not a template. A serialized block tree with a manifest, an input/output rules contract, and a flat array of blocks that the bridge layer can instantiate, expand, and serialize back out. The format is defined in `packages/composites/src/manifest.ts`. The bridge utilities live in `bridge.ts`. The registry is in `registry.ts`. All three are Zod-first. --- ## Manifest. The manifest is what a composite knows about itself before any blocks are rendered. Schema defined at `manifest.ts:49`. ```ts { id: string; // kebab-case, /^[a-z0-9-]+$/ name: string; category: string; // free-form, teams define their own description: string; keywords: string[]; cognitiveLoad: number; // integer 1–10 solves?: string; appliesWhen?: string[]; usagePatterns?: { do: string[]; never: string[] }; } ``` `id` is kebab-case enforced by the regex at line 53. It is the registry key. `category` is free-form: the schema enforces a non-empty string, nothing else. Teams define their own taxonomy. `cognitiveLoad` is an integer 1–10 matching the five-dimension model documented in [Cognitive Load Scoring](/docs/cognitive-load-scoring/). The bridge clamps this on serialization (see `serializeToComposite` below). The three optional fields (`solves`, `appliesWhen`, `usagePatterns`) were added after the initial format shipped. Old composites in the wild will not have them; the schema accepts their absence. `usagePatterns` carries designer intent as `do` and `never` arrays, the same shape as the do-and-never rules documented in [Editor Rules](/docs/editor-rules/). These fields are not validated against the rules registry; they are narrative guidance encoded alongside the assembly. The complete file schema wraps the manifest: ```ts { manifest: CompositeManifest; input: string[]; // rule names on root blocks output: string[]; // rule names on leaf blocks blocks: CompositeBlock[]; // flat, min 1 } ``` `blocks` is flat. Children are ID arrays, not nested objects. Each block's `parentId` and `children` fields hold string IDs that reference other blocks in the same array. See [Editor Data Model](/docs/editor-data-model/) for the full `CompositeBlock` shape. --- ## Bridge. The bridge layer (`bridge.ts`) translates composites into the editor and back out. Three functions do the work. ### `toBridgeItem` ```ts toBridgeItem(composite: CompositeFile): BlockPaletteItem // bridge.ts:56 ``` Projects the manifest into a sidebar palette entry: `{ id, label: name, category, keywords? }`. No state change, no side effects. Keywords are included only when the manifest has at least one. The palette item is what the editor sidebar renders; it carries nothing from the block array. ### `instantiateBlocks` ```ts instantiateBlocks(blocks: CompositeBlock[], options?: InstantiateOptions): InstantiatedBlock[] // bridge.ts:83 ``` This is where a composite becomes a live canvas insertion. Two things happen unconditionally: ID regeneration and reference remapping. Every block gets a fresh `crypto.randomUUID()`. The old ID maps to the new one in an `idMap` built at line 99. Once all new IDs exist, `children` arrays and `parentId` strings are remapped through that map at lines 161–166. A composite dropped twice onto the canvas produces two fully independent block trees with no shared IDs. When a block's `type` starts with `composite:`, the bridge treats it as a nested composite reference and calls `expandBlocks` recursively (line 108). The `resolveComposite` callback in `InstantiateOptions` is how the caller supplies the nested `CompositeFile`. If the callback returns `null`, the block is replaced with a plain text block reading `Unknown composite: {id}` (line 146). If no `resolveComposite` is provided, `composite:*` blocks fall through to normal instantiation. Recursion is bounded by `maxDepth`, which defaults to 10 (line 95). At or beyond that depth, `expandBlocks` returns an empty array. Circular composite references will silently produce no output rather than a stack overflow. One edge case worth knowing: when a nested composite's blocks have no explicit `parentId`, the bridge remaps them to the parent composite's new ID (line 117). This keeps the expanded tree connected. ### `serializeToComposite` ```ts serializeToComposite(blocks: SerializableBlock[], metadata: SaveCompositeMetadata): CompositeFile // bridge.ts:239 ``` Takes canvas blocks and save-dialog metadata, produces a valid `CompositeFile`. Two throws and several derivations. The function throws if `blocks` is empty (line 244). It throws if `metadata.name` produces an empty kebab string from `toKebabId()` (line 247); a name must contain at least one alphanumeric character. The `id` field is always derived from `name`, never provided directly. Input and output rule names are computed by `deriveIO()` (line 192). Input is the union of rule names on root blocks (blocks with no `parentId`). Output is the union of rule names on leaf blocks (blocks with no `children` or an empty `children` array). Both sets are sorted. This is a v1 heuristic: the rules that enter the assembly at the top and exit at the bottom define the contract. See [Editor Rules](/docs/editor-rules/) for what `AppliedRule` carries. Keywords are auto-derived from block types and rule names by `deriveKeywords()` (line 220). The manifest's `keywords` field in a serialized composite reflects what was actually in the blocks, not what was typed by hand. `cognitiveLoad` is clamped to 1–10 at line 255. If the caller passes nothing, it falls back to `blocks.length`, which is a rough proxy only. Pass an explicit value when the assembly's load is known. Optional intent fields are included in the manifest only if provided (lines 267–269). Omitting them keeps the serialized output clean for teams that do not use them. --- ## Registry. The registry (`registry.ts`) is a module-level `Map` keyed by `manifest.id`. There is no constructor; the module owns the map. ```ts register(composite: CompositeFile): void // registry.ts:17 — throws on duplicate ID get(id: string): CompositeFile | undefined // registry.ts:29 — O(1) getAll(): CompositeFile[] // registry.ts:36 getByCategory(category: string): CompositeFile[] // registry.ts:43 search(query: string): CompositeFile[] // registry.ts:51 ``` `register` throws if the ID is already present. `get` is O(1) map lookup. `getByCategory` filters `getAll()` in registration order. `search` with an empty string returns `getAll()`. `search` runs a fuzzy score against name and all keywords, takes the maximum score across fields, filters to score > 0, and returns results sorted descending. Best match first. The fuzzy algorithm is `fuzzyScore` imported from `@rafters/ui/primitives/typeahead` (line 8), which is the same scorer the editor's typeahead palette uses. A `clear()` function exists for test isolation. It has no other use. --- ## Architecture Notes. Two import paths in this package cross a package boundary that should not exist. `bridge.ts:10` imports `BlockPaletteItem` as a type from `@rafters/ui/primitives/block-palette`. `registry.ts:8` imports `fuzzyScore` from `@rafters/ui/primitives/typeahead`. Both pull from `@rafters/ui`. The composites package should depend only on zod. A shared primitives package or a `@rafters/editor-types` extraction is the correct fix. Both violations are tracked in [Editor Known Gaps](/docs/editor-known-gaps/). The type-only import in `bridge.ts` is zero runtime cost; the value import of `fuzzyScore` in `registry.ts` is a real runtime dependency on the UI package. The registry import is the higher-priority fix. --- ## Editor Data Model url: https://rafters.studio/docs/editor-data-model/ # Editor Data Model The editor is a tree of blocks. Everything else is sugar on top. A consumer who knows these types can read source and write code without guessing. Most of the friction in editor work comes from people guessing at fields that already exist or inventing duplicates of fields that already mean something. Read this page first. Then read the code. ## BaseBlock The shared shape. Defined at `packages/ui/src/primitives/types.ts:171`. ```ts interface BaseBlock { id: string; type: string; content?: string | InlineContent[]; children?: string[]; parentId?: string; meta?: Record; } ``` **`id`** is a UUID. Never collides with anything in the tree. `instantiateBlocks` regenerates IDs on insertion so two clones of the same composite never share IDs. **`type`** is a string, not an enum. The editor knows about `heading`, `text`, `code`, `quote`, `divider`, `image`, `list`, `list-item`. Consumers can introduce their own types and the editor will treat them as default `

` blocks until rendered explicitly. Composites use namespaced types like `composite:login-form`. **`content`** accepts a plain string or an `InlineContent[]` array. The array form preserves bold, italic, code, strikethrough, and link marks across serialization. The string form does not. Both render correctly. Only the array survives a round trip. **`children`** is an array of block IDs. Not nested objects. The block array is flat. Parent and child relationships are reference-only. **`parentId`** is the ID of the containing block. Root blocks have no `parentId`. The serializer derives input rules from root blocks and output rules from leaf blocks (no children, or empty children array). **`meta`** is type-specific. A heading block stores `meta.level` (1–6, clamped at render time). An image block stores `meta.src` and `meta.alt`. A grid block stores `meta.columns`. The shape is `Record` because each block type defines its own. ## EditorBlock `EditorBlock` extends `BaseBlock` with one runtime-only field. Defined at `packages/ui/src/components/ui/editor.tsx:37`. ```ts type AppliedRule = string | { name: string; config: Record }; interface EditorBlock extends BaseBlock { rules?: AppliedRule[]; } ``` Rules are validators. They attach to blocks and describe what's required (input rules on root) or guaranteed (output rules on leaves). A bare string is the rule name. An object form lets the rule carry parameters. The runtime validation engine doesn't exist yet — see [Editor Rules](/docs/editor-rules/) for the gap. ## CompositeBlock The serialization-time shape. Same fields as `BaseBlock`, validated through Zod at `packages/composites/src/manifest.ts:28`. Composites carry their rules array on every block. ```ts const CompositeBlockSchema = z.object({ id: z.string(), type: z.string(), content: z.union([z.string(), z.array(InlineContentSchema)]).optional(), children: z.array(z.string()).optional(), parentId: z.string().optional(), meta: z.record(z.unknown()).optional(), rules: z.array(AppliedRuleSchema).optional(), }); ``` `EditorBlock` and `CompositeBlock` are structurally identical. The split exists because the editor lives in `@rafters/ui` and composites lives in `@rafters/composites` and neither wants to import the other. Same shape, two definitions, validated independently. ## InlineContent Rich text. Defined at `packages/ui/src/primitives/types.ts:188`. ```ts type InlineMark = 'bold' | 'italic' | 'code' | 'strikethrough' | 'link'; interface InlineContent { text: string; marks?: InlineMark[]; href?: string; } ``` A run of text with optional marks. `href` is only present when `marks` includes `'link'`. A paragraph with mixed formatting becomes an array of segments: ```ts content: [ { text: 'The ' }, { text: 'quick', marks: ['bold'] }, { text: ' brown ' }, { text: 'fox', marks: ['italic'] }, { text: ' jumps.' }, ] ``` The MDX serializer at `packages/ui/src/primitives/serializer-mdx.ts` round-trips this faithfully. The legacy `toMdx()` in `@rafters/composites` flattens it to plain text and loses the marks. Use `mdxSerializer` from `@rafters/ui`. Mark application order at render time, defined in `editor.tsx:129`: code, then link, then strikethrough, then italic, then bold. Bold is the outermost wrapper. This matters when you read the rendered HTML and wonder why your `` ended up wrapping everything else. ## Command For the slash command palette. Defined at `packages/ui/src/primitives/types.ts:227`. ```ts interface Command { id: string; label: string; description?: string; icon?: string; category?: string; keywords?: string[]; shortcut?: string; action: () => void; } ``` `action` runs synchronously when the command is selected. Async work belongs inside it. The fuzzy search at `command-palette.ts` indexes `label` and `keywords`. ## FormatDefinition For inline formatter presets. Defined at `packages/ui/src/primitives/types.ts:241`. ```ts interface FormatDefinition { name: InlineMark; tag: string; shortcut?: string; attributes?: Record; class?: string; } ``` The formatter ships presets for bold, italic, code, strikethrough, and link. Custom marks are not supported. The mark types live in the union at `InlineMark` and adding a new mark means changing the type. ## Keyboard Types ```ts type KeyboardKey = | 'Enter' | 'Space' | 'Escape' | 'Tab' | 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'Home' | 'End' | 'PageUp' | 'PageDown' | 'Backspace' | 'Delete'; interface KeyboardModifiers { shift?: boolean; ctrl?: boolean; alt?: boolean; meta?: boolean; } ``` The keyboard handler normalizes `Space` to `' '` internally. That's the one thing to know. ## SelectionRange For low-level selection work. Defined at `packages/ui/src/primitives/types.ts:216`. ```ts interface SelectionRange { startNode: Node; startOffset: number; endNode: Node; endOffset: number; collapsed: boolean; } ``` Most consumers won't touch this. The `cursor-tracker` primitive handles selection plumbing for the editor. ## Palette Item Shapes `BlockPaletteItem` lives in `packages/ui/src/primitives/block-palette.ts`. `RulePaletteItem` lives in `packages/ui/src/primitives/rule-palette.ts`. Both share the same idea: an `id`, a `label`, a `category`, and an optional `keywords` array. The fuzzy search uses label plus keywords. `@rafters/composites/bridge.ts:56` produces `BlockPaletteItem` directly from a composite manifest. That import is the only known type-coupling between composites and ui. See [Architecture](/docs/editor-architecture/) for the violation. ## Composite Manifest The full Zod schema at `packages/composites/src/manifest.ts:48`. ```ts const CompositeManifestSchema = z.object({ id: z.string().regex(/^[a-z0-9-]+$/), name: z.string(), category: z.string(), description: z.string(), keywords: z.array(z.string()), cognitiveLoad: z.number().int().min(1).max(10), solves: z.string().optional(), appliesWhen: z.array(z.string()).optional(), usagePatterns: z.object({ do: z.array(z.string()), never: z.array(z.string()), }).optional(), }); const CompositeFileSchema = z.object({ manifest: CompositeManifestSchema, input: z.array(z.string()), output: z.array(z.string()), blocks: z.array(CompositeBlockSchema), }); ``` `id` is kebab-case and derived from `name` by `serializeToComposite`. `category` is a free-form string. It used to be an enum. It is not anymore. Don't hardcode categories. `cognitiveLoad` is clamped 1 through 10 at serialize time. `solves`, `appliesWhen`, and `usagePatterns` are optional and were added later. Composites in the wild may not have them. ## What's Not A Type Two things people invent that shouldn't exist: A `BlockTree` type. There isn't one. The block array is the tree. Walk by `parentId` and `children` IDs. A `Document` wrapper. There isn't one. The editor takes `EditorBlock[]`. Frontmatter for serialization is a separate object passed alongside, not embedded. If a function would take `{ blocks, frontmatter }`, the fields are separate parameters or a structural object literal. Don't introduce a wrapper type. --- ## Editor Known Gaps url: https://rafters.studio/docs/editor-known-gaps/ # Editor Known Gaps This page exists because the editor surface drifts. Types are exported, code is stripped, two implementations of the same idea coexist. Documenting the gaps explicitly is cheaper than pretending the API is whole. Each entry follows the same shape: where, what, what was expected, who gets hurt, what the fix looks like. ## EditorProps Missing Wiring **Where.** `packages/ui/src/components/ui/editor.tsx:41` (the `EditorProps` interface) and `apps/demo/src/components/demos/EditorPlayground.tsx:438` (the consumer that passes the missing props). **Current behavior.** The component exports `EditorSidebarConfig` (line 80), `EditorRulePaletteConfig` (line 92), `SlashCommand` (line 65), `SaveCompositeData` (line 73), and `RuleConfigField` (line 108). None of these types are referenced inside `EditorProps`. The demo passes `sidebar`, `rulePalette`, `commandPalette`, `inlineToolbar`, `blockContextMenu`, `onSaveAsComposite` to ``. The component receives them as part of the spread `HTMLDivAttributes` and silently drops them. No runtime error. No effect. **Expected behavior.** The props should be declared and wired into the component. Sidebar mounts a block palette. Rule palette mounts a rule list. Command palette mounts a slash menu. Inline toolbar mounts a floating format bar. Block context menu mounts on right-click. Save-as-composite handler fires when the user requests it. **Blast radius.** Every consumer that wants the rich editor experience has to recompose these surfaces externally. The demo already does, which is why the pattern is visible in `EditorPlayground.tsx`. New consumers who read the type exports and assume the props work will spend time debugging until they read the source. **Fix sketch.** Restore the wiring stripped post-PR #1048. Each surface mounts as a portal or sibling of the canvas. The types are intact; only the JSX integration is missing. Estimated work: medium. The primitives that back each surface are all present and functional. ## Inline Formatter Write Path Is Dangling **Where.** `packages/ui/src/primitives/inline-formatter.ts:619` (`serializeSelection`) and `:674` (`deserializeToDOM`). And `packages/ui/src/components/ui/editor.tsx` (no call site). **Current behavior.** The inline formatter applies marks (bold, italic, code, strikethrough, link) directly to the contentEditable DOM. The `serializeSelection()` function exists and walks the DOM to produce `InlineContent[]`. The `deserializeToDOM()` function exists and walks `InlineContent[]` to produce a DocumentFragment. The editor never calls `serializeSelection`. So when a user formats text, the DOM updates and the visual is correct, but `block.content` is never updated. On save, the content shape is whatever it was before the edit, and the formatting is lost. **Expected behavior.** Every inline format operation (toggle, apply, remove) should call `serializeSelection` for the affected block and write the result back into the block's `content` field through the document-editor or block-handler. Round-trip MDX edits should preserve user-applied marks the same way they preserve imported marks. **Blast radius.** Bold, italic, code, strikethrough, and link applied via the inline toolbar do not survive save. Imported marks from MDX render correctly because `deserializeToDOM` runs on the read path. Edits in the DOM render correctly. The save path drops them. Gitpress and any other MDX-round-trip consumer is the most exposed. **Fix sketch.** Hook the inline-toolbar commit and the contentEditable blur. On both, call `serializeSelection` for the focused block, route through `block-handler.updateBlock(id, { content: serialized })`. The bridge functions are correct; only the call site is missing. ## Editor.deselect Is A Stub **Where.** `packages/ui/src/components/ui/editor.tsx:408`. **Current behavior.** `deselect` is exposed on `EditorControls` and is an empty function. Calling it does nothing. **Expected behavior.** It should clear the document-editor's selection state, blur the active contentEditable, and emit any focus-change callbacks subscribers have registered. **Blast radius.** Small but real. Consumers who programmatically need to drop selection (after a save, after a navigation) think they have a way to do it. They do not. **Fix sketch.** Wire `deselect()` to call `docEditorRef.current?.deselect()` if the primitive supports it, plus a manual `(document.activeElement as HTMLElement)?.blur()` as a fallback. The primitive likely needs a parallel method added. ## Two MDX Serializers Exist **Where.** `packages/ui/src/primitives/serializer-mdx.ts` (the full bidirectional one, 873 lines) and `packages/composites/src/serializer.ts` (the legacy one-way `toMdx()`). **Current behavior.** Both ship. The full one in `@rafters/ui` conforms to the `EditorSerializer` interface, handles `InlineContent[]` marks, supports frontmatter, supports MDX JSX, supports round-trip parsing. The legacy one in `@rafters/composites` is one-way (no parser), drops `quote`, `input`, `button`, `image` to `` comments, treats content as a plain string, and does not handle frontmatter. **Expected behavior.** One serializer. The full one is the right one. The legacy `toMdx()` should be removed once internal callers are migrated, or kept only as an internal helper that the bridge layer uses. **Blast radius.** Anyone reaching for "the MDX serializer" can pick the wrong one. The `@rafters/composites` version produces lossy output and looks plausible enough at first glance to ship. **Fix sketch.** Audit callers of `composites/serializer.ts:toMdx`. Replace each with `mdxSerializer` from `@rafters/ui`. Delete the legacy file. Update tests. The visible callers are `bridge.ts` and possibly some demo code; trace from there. ## Composites Imports From UI **Where.** `packages/composites/src/registry.ts:8` (imports `fuzzyScore` from `@rafters/ui/primitives/typeahead`) and `packages/composites/src/bridge.ts:10` (type-imports `BlockPaletteItem` from `@rafters/ui/primitives/block-palette`). **Current behavior.** `@rafters/composites` declares zod as its only intentional workspace dependency. In practice it depends on `@rafters/ui` for one helper function and one type alias. **Expected behavior.** Composites should depend only on zod. The architectural intent is that composites is a pure data-and-validation package any consumer can pull in without dragging the UI layer. **Blast radius.** Today: a circular-ish dependency that complicates package boundaries. Tomorrow: anyone bundling composites for a non-React consumer (a worker, a CLI build pipeline, a service) gets the entire UI primitive layer along for the ride. **Fix sketch.** Inline `fuzzyScore` into `composites/registry.ts`. It's a small pure function. Define `BlockPaletteItem` locally in `bridge.ts` (or in a new `composites/src/types.ts`) with the same shape. Both edits are mechanical. The two packages become structurally compatible without import-time coupling. ## Asymmetric Circular-Reference Depth Limits **Where.** `packages/composites/src/serializer.ts` (legacy `toMdx`, MAX_DEPTH=50) and `packages/composites/src/bridge.ts:96` (`instantiateBlocks`, maxDepth=10). **Current behavior.** The two functions cap recursion at different depths. Most composites instantiate fine because real depth is usually 2 or 3. A pathological composite that nests deeply could be serialized but not instantiated, or vice versa. **Expected behavior.** Either a single shared constant or a documented reason for the difference. **Blast radius.** Low. No reported bugs. The asymmetry is more of a code-smell than an active issue. **Fix sketch.** Pick one number. 10 is probably correct (deep nesting indicates a misuse). Move it to a shared constant in `composites/src/constants.ts`. Update both call sites. ## Rule Runtime Validation Gap **Where.** `packages/composites/src/rules.ts` and `packages/composites/src/built-in-rules/*.ts`. **Current behavior.** `matchRules` does nominal name matching. The Zod schemas in `built-in-rules/` are loaded but never invoked from blocks. There is no engine that walks a block tree, reads each block's `rules`, resolves the rule name to its Zod schema, and validates the block's content against it. **Expected behavior.** A consumer should be able to call something like `validateBlocks(blocks): ValidationResult[]` and get back per-block validation errors based on the rules attached to each block. **Blast radius.** Composites that declare input rules can claim compatibility with consumers that declare matching output rules, even when the actual data shapes don't agree. The compatibility check is by name only. Errors surface late, in user-visible UI. **Fix sketch.** Add `validateBlocks(blocks, ruleRegistry)` to `composites/src/rules.ts`. Walk blocks. For each block with rules, look up the rule's Zod schema in the registry, run `parse()` against the block's content (or the relevant `meta` field per rule), accumulate errors. The schemas exist; only the engine is missing. ## Registry Import-Path Bug In `pnpx rafters add editor` **Where.** Distributed via the rafters CLI, surfaces in any consumer that runs `pnpx rafters add editor`. **Current behavior.** Primitives are installed to `src/lib/primitives/` but their internal imports reference `@/src/components/ui/` instead of `@/src/lib/primitives/`. Affected files include `block-handler.ts`, `block-canvas.ts`, `block-context-menu.ts`, `escape-keydown.ts`, `inline-formatter.ts`, `collision-detector.ts`, `outside-click.ts`, `command-palette.ts`, `canvas-drop-zone.ts`, `slot.ts`, `inline-toolbar.ts`, `typeahead.ts`, `focus-trap.ts`, `roving-focus.ts`, `clipboard.ts`, `keyboard-handler.ts`, `block-palette.ts`, `rule-palette.ts`. Also: the shared `types.ts` primitive is not installed. **Expected behavior.** Primitives' relative imports should resolve to wherever the registry installer placed them. The shared `types.ts` should be installed alongside. **Blast radius.** Every external consumer who installs the editor via the CLI hits this on day one. They have to manually `sed` paths or copy files. **Fix sketch.** Update the registry build to rewrite import paths based on installation target. Include `types.ts` in the editor primitive bundle. Track the registry path through the build instead of hard-coding `components/ui/`. ## Save-As-Composite UI Missing **Where.** `packages/ui/src/components/ui/editor.tsx`. The `SaveCompositeData` type at line 73 and `onSaveAsComposite` callback are exported but no UI exists to invoke them. **Current behavior.** Type-level support is in place. The bridge function `serializeToComposite` in `composites/src/bridge.ts:239` takes blocks and metadata and produces a CompositeFile. There is no in-editor UI to collect the metadata (name, category, description) and trigger the handler. **Expected behavior.** A consumer-driven action (right-click menu item, slash command, toolbar button) collects metadata via a small dialog and calls `onSaveAsComposite({ name, category, description, blocks })`. **Blast radius.** Composite authoring is a designer flow. Without UI, designers can't save composites from inside the editor. Today they edit JSON files by hand or use the CLI. **Fix sketch.** A dialog primitive plus a small form. Wire to a context menu item or toolbar button. Call the prop. The serializer does the rest. Depends on the EditorProps wiring gap above. ## How These Get Fixed These gaps are documentation-first. None of them are getting fixed in the same change as the docs that describe them. The sequence is intentional: write the truth, then write the fix plan, then ship the fix. If a fix is in flight, the entry should still describe the gap as it stands today. Update the entry only when the fix lands. --- ## Editor Canvas Primitives url: https://rafters.studio/docs/editor-primitives-canvas/ # Editor Canvas Primitives. Not a text editor. A set of composable primitives that each own exactly one concern: cursor tracking, block mutation, per-block chrome, or full contentEditable orchestration. These five live in `packages/ui/src/primitives/` and form the selection, focus, and CRUD layer of the editor stack. For data model types (`BaseBlock`, `EditorBlock`, `InlineContent`), see [the data model reference](/docs/editor-data-model/). For the leaf vs. composition classification rules, see [the architecture doc](/docs/editor-architecture/). --- ## block-canvas. **Classification:** leaf primitive. No nanostores dependency. **Purpose:** selection, focus, and keyboard management for a block editor container. It tracks which block is selected or focused and wires keyboard navigation across the block list. **Public surface:** ```ts createBlockCanvas(options: BlockCanvasOptions): BlockCanvasControls ``` Types exported: `BlockCanvasBlock`, `BlockCanvasOptions`, `BlockClickOptions`, `BlockCanvasControls`. **Behavior contract:** the canvas attaches keyboard handlers to its container element via `createKeyboardHandler` from `keyboard-handler` (`block-canvas.ts:262–309`). It does not handle click or keyboard events that originate from editable elements: contenteditable, input, and textarea all pass through to the host (`block-canvas.ts:102`). This means the canvas is safe to mount around contentEditable blocks without double-handling input events. **Quirk:** slash-command menu positioning is hardcoded to `rect.left + rect.width / 2 - 144` (`block-canvas.ts:330`). The 144px offset from the container center is not configurable. If your container is narrower than about 300px, the menu will clip. --- ## block-handler. **Classification:** composition primitive. Uses a nanostores `atom` directly (`block-handler.ts:134`). **Purpose:** orchestrates block-canvas, clipboard, and history into a single reactive editing state machine. One atom drives selection state, focus, undo/redo availability, and clipboard. **Public surface:** ```ts createBlockHandler(options: BlockHandlerOptions): BlockHandlerControls ``` Types exported: `BlockHandlerState`, `BlockHandlerOptions`, `BlockHandlerControls`. **Behavior contract:** the returned `$state` atom exposes `selectedBlockId`, `focusedBlockId`, `canUndo`, and `canRedo`. History snapshots are pushed whenever the `$blocks` atom changes (`block-handler.ts:164–170`). Undo and redo replay those snapshots and surface availability flags on the atom. **Quirk:** block-handler does not manage CRUD. The comment at `block-handler.ts:9–14` is explicit: no creation, deletion, reordering, or content updates. It owns selection, focus, undo/redo history, and clipboard. Anything that mutates the block list is the consumer's responsibility. The pure functions for that are in block-operations; see below. Known gaps: the clipboard integration does not yet support cross-document paste. See [the known gaps reference](/docs/editor-known-gaps/) for status. --- ## block-wrapper. **Classification:** leaf primitive. No nanostores dependency. **Purpose:** hover chrome, drag handle, and action-menu visibility state for a single block. One instance per block in the editor. **Public surface:** ```ts createBlockWrapper(options: BlockWrapperOptions): BlockWrapperControls ``` Types exported: `BlockWrapperOptions`, `BlockWrapperControls`. **Behavior contract:** chrome visibility follows `isHovered || getIsFocused() || isMenuOpen` (`block-wrapper.ts:131`). The wrapper imports `createDraggable` from the drag-drop primitive to handle the drag handle (`block-wrapper.ts:140`). Menu open/close state is driven by the consumer calling the controls; the wrapper only reads `isMenuOpen`, it does not manage it. **Quirk:** because menu state is consumer-owned, you must pass the open state in through the options or controls on every render cycle. The wrapper has no internal menu toggle. This is intentional: it keeps the primitive zero-dependency and lets the consumer wire any menu implementation. --- ## document-editor. **Classification:** composition primitive. Uses a nanostores `atom` (`document-editor.ts:131`). **Purpose:** orchestrates the contentEditable surface with all required leaf primitives. This is the all-in-one option for editors that do not need to compose the primitives individually. **Public surface:** ```ts createDocumentEditor(options: DocumentEditorOptions): DocumentEditorControls ``` Types exported: `DocumentEditorState`, `DocumentEditorOptions`, `DocumentEditorControls`. **Behavior contract:** document-editor wires input-events, clipboard, keyboard-handler, cursor-tracker, block-operations, history, and two serializers (HTML and text) from its imports (`document-editor.ts:30–53`). DOM reconciliation runs on every input event (`document-editor.ts:162`), reading the live DOM and syncing it back to the block model. Markdown shortcuts fire on input: `# ` converts the current block to `heading` level 1, `> ` converts to blockquote, `##` through `####` produce heading levels 2–4 (`document-editor.ts:106–109`). Keyboard shortcuts `Cmd+Alt+0` through `Cmd+Alt+4` convert the focused block type without triggering markdown detection (`document-editor.ts:337–364`). Delete handling has a split: selection-delete (cross-block range) is browser-handled and then reconciled (`document-editor.ts:225–227`). Single-block deletes that cross a block boundary go through block-operations (`document-editor.ts:265`). **Quirk:** because document-editor owns the full contentEditable surface, you cannot layer a separate block-canvas on the same container without event conflicts. Use document-editor when you want the integrated path; use block-canvas directly when you need the lower-level surface. --- ## block-operations. **Classification:** leaf primitive. Pure functions only, no side effects. **Purpose:** all block CRUD as stateless transformations. Takes an immutable blocks array, returns a new one plus metadata about what changed. **Public surface:** ```ts splitBlock(blocks, blockId, offset): SplitResult mergeWithPrevious(blocks, blockId): MergeResult mergeWithNext(blocks, blockId): MergeResult deleteBlock(blocks, blockId): DeleteResult convertBlockType(blocks, blockId, newType, meta): BaseBlock[] insertBlocksAt(blocks, newBlocks, atBlockId, offset): InsertResult blockContentToText(content): string ``` New blocks generated by split and insert operations receive fresh UUIDs from `crypto.randomUUID()` (`block-operations.ts:31`). Type signatures import `BaseBlock` and `InlineContent` from the shared types module (`block-operations.ts:16`). See [the data model reference](/docs/editor-data-model/) for those shapes. **Behavior contract:** no function in this file mutates input. Every result type carries both the updated blocks array and a cursor hint (`focusBlockId`, `focusOffset`, or similar) so the caller can restore selection after applying the change. --- ## How They Compose. The standard wiring chain from lowest to highest level: `block-operations` provides pure CRUD functions with no runtime state. `block-canvas` consumes a container element and tracks cursor position and keyboard navigation with no knowledge of block content. `block-handler` mounts on top of both, adding history and clipboard via a nanostores atom; it is the reactive coordination layer. `block-wrapper` is instantiated once per block and owns that block's chrome, drag handle, and menu visibility. `document-editor` is the all-in-one composition: it mounts a contentEditable surface, wires all the leaf primitives internally, and exposes a single atom and control surface. Not every editor needs all five. A read-only block list needs only block-wrapper. A non-contentEditable selection surface needs only block-canvas and block-handler. The primitives are independent enough to use in subsets; document-editor is the shortcut when you want all of them. --- ## Editor Drag, Drop, and State url: https://rafters.studio/docs/editor-primitives-dnd-state/ # Editor Drag, Drop, and State. Four leaf primitives handle everything that moves or remembers. Two deal with spatial interaction: `drag-drop` and `canvas-drop-zone`. Two deal with state: `clipboard` and `history`. None of them import nanostores. They do one thing, expose a cleanup function, and get out. The composition primitive `block-handler` wires them together for the full editor context. --- ## Drag and Drop. `drag-drop.ts` handles mouse, keyboard, and touch drag across the entire primitive surface. The MIME type is `application/x-rafters-drag-data` with a `text/plain` fallback for cross-origin drops. Touch initiates via a 300ms long-press timer; scrolling more than 10px before the timer fires cancels it cleanly. Keyboard drag is the part that requires attention. State for keyboard mode lives at module level, shared across every `createDraggable` instance on the page. Space picks up and drops. Arrow keys navigate between registered drop zones sorted by vertical then horizontal position. Escape cancels. Screen reader announcements fire through a single visually-hidden `aria-live="assertive"` region. ```typescript const item = createDraggable({ element: blockEl, data: { id: 'block-1', type: 'paragraph' }, onDragStart: (data) => setDragging(data.data), onDragEnd: (_data, effect) => clearDragging(effect), }); // Programmatic keyboard drag item.startKeyboardDrag(); item.moveDown(); item.commitKeyboardDrag(); item.cleanup(); ``` Because keyboard state is module-level, two things follow. First, only one drag can be active at a time. Second, if your test suite leaves drag state behind, the next test inherits it. Call `resetDragDropState()` in `afterEach`. That function removes the announcer element and reinitializes the state object; see `drag-drop.ts:905`. `createDropZone` answers "was something dropped here?" It does not answer "where exactly within the zone?" For that second question, use `canvas-drop-zone`. --- ## Canvas Drop Zone. `canvas-drop-zone.ts` is for block editors where the drop position matters. Not a general-purpose drop zone. A positional insert target. The algorithm reads every `[data-block-id]` child of the container, caches their `DOMRect` values, then compares the cursor's Y coordinate against each block's midpoint. If the cursor is above a block's midpoint, the insertion index is before that block. If below all midpoints, insertion goes at the end. The `dragover` handler is rAF-throttled: each frame consumes the most recent `clientY` and skips calculation if nothing changed. See `canvas-drop-zone.ts:132`. ```typescript const dropZone = createCanvasDropZone({ container: editorEl, accept: (data) => (data as { type: string }).type === 'block', onDrop: (data, index) => insertBlock(data, index), onInsertIndicatorChange: (index, rect) => { if (index === null) hideIndicator(); else showIndicator(rect); }, }); // After any DOM mutation that adds, removes, or reorders blocks: dropZone.recalculate(); dropZone.destroy(); ``` Call `recalculate()` after every DOM mutation that changes block positions. Block positions are cached on `dragenter`, not on every frame. If a block animates into position after a drop, the cached rects will be stale until the next `recalculate()` call. See [Editor Data Model](/docs/editor-data-model/) for the `BlockNode` types that flow through `onDrop`. --- ## Clipboard. `clipboard.ts` wraps the Clipboard API with custom MIME type support and a `text/plain` fallback for environments where custom types are blocked. Permission denial is caught and swallowed; the `write` function does not throw, and `read` returns an empty `ClipboardData` object rather than rejecting. ```typescript const clipboard = createClipboard({ container: editorEl, customMimeType: 'application/x-rafters-blocks', onPaste: (data) => { if (data.custom) applyBlocks(data.custom); else if (data.text) insertText(data.text); }, onCopy: (data) => console.log('copied', data), }); await clipboard.write({ text: 'Fallback text for plain paste targets', custom: { blocks: selectedBlocks }, }); clipboard.cleanup(); ``` The `ClipboardData` type carries three optional fields: `text`, `html`, and `custom`. When writing, all three are written simultaneously as separate MIME blobs inside a single `ClipboardItem`. If `ClipboardItem` rejects the custom type, the implementation falls back to `writeText` with the plain text value. See `clipboard.ts:165`. --- ## History. `history.ts` is a typed undo/redo stack with configurable depth and an optional equality function for deduplication. The default limit is 100 entries. When that limit is exceeded, the oldest entry is dropped: FIFO, not LRU. Batch mode is the thing to know. Inside a `batch()` call, every `push()` updates the current state without recording a history entry. When the batch closes, only the before and after states are recorded as a single undo step. ```typescript const history = createHistory({ initialState: blocks, limit: 50, isEqual: (a, b) => a === b, }); // Multiple typed characters collapse into one undo step history.batch(() => { history.push(applyChar(state, 'h')); history.push(applyChar(state, 'e')); history.push(applyChar(state, 'l')); }); // history.undo() jumps back to the state before all three pushes history.canUndo(); // true history.undo(); history.redo(); history.clear(); ``` Nested `batch()` calls run the inner function immediately without opening a second batch frame. See `history.ts:291`. --- ## All Leaf, No Nanostores. None of these primitives import nanostores. Leaf primitives stay zero-dependency by rule: no shared reactive state, no composition logic, callback injection for everything. See [Editor Data Model](/docs/editor-data-model/) for the types that cross these primitive boundaries. The composition primitive `block-handler` is what wires `clipboard` and `history` through nanostores atoms so they share state across the editor surface. --- ## Editor Formatting Primitives url: https://rafters.studio/docs/editor-primitives-formatting/ # Editor Formatting Primitives. Three leaf primitives handle the formatting layer of the editor: `inline-formatter` applies and removes marks on text ranges, `inline-toolbar` positions the floating button strip above selections, and `command-palette` runs the slash-command menu. None of them import nanostores. None of them depend on each other. Composition happens in the React component that wires them together. For the data types these primitives operate on (`InlineContent`, `InlineMark`, `Command`, `FormatDefinition`) see [Editor Data Model](/docs/editor-data-model/). ## inline-formatter. Not a rich-text abstraction. A DOM manipulation layer that creates and unwraps format elements within a content region. `createInlineFormatter(options)` returns an `InlineFormatterController`. The controller exposes six methods: `getActiveFormats()`, `isFormatActive(mark)`, `toggleFormat(mark, value?)`, `applyFormat(mark, value?)`, `removeFormat(mark)`, and two serialization methods covered below. Five format presets ship with the primitive: `BOLD`, `ITALIC`, `CODE`, `STRIKETHROUGH`, and `LINK`. Toggle and apply handle partial range splits. If a selection covers text that is partly bold and partly not, `toggleFormat(BOLD)` determines the dominant state and applies or removes accordingly. Range splitting for partial removal is complex; see `inline-formatter.ts` lines 400-580 for the implementation. The primitive is SSR-safe: every DOM operation is gated on `typeof window === 'undefined'` and no-ops on the server. ### The write-path gap. `serializeSelection()` (line 619) walks the DOM inside the formatted region and returns `InlineContent[]`. `deserializeToDOM(content: InlineContent[])` (line 674) takes `InlineContent[]` and returns a `DocumentFragment` for mounting. Both directions work. The wiring does not exist. `editor.tsx` never calls `serializeSelection()` after a user formats text. Bold, italic, code, strikethrough, and link are visible in the DOM immediately after the user applies them. They are never written back to `block.content`. On save, the block's content array still holds whatever was there before the user typed a mark. Formatting is lost. Content imported from MDX renders correctly because `deserializeToDOM` is called on load. The read path works. The write path is currently dangling. The bridge functions exist at lines 619 and 674. The call site in `editor.tsx` does not. Track this at [Known Gaps](/docs/editor-known-gaps/). ## inline-toolbar. Not a component. A positioning and configuration utility that a React component calls to know where to render and what buttons to show. `getFormatButtons()` returns `FormatButtonConfig[]`, one entry per supported mark. `adjustToolbarPosition(position, dimensions, ...)` takes a raw selection rectangle and toolbar dimensions and returns an `AdjustedToolbarPosition`. The algorithm prefers above the selection; it flips below if the toolbar would collide with the viewport edge. Eight pixels of viewport padding on all sides. `getModifierKey()` returns `"Cmd"` on Mac and `"Ctrl"` elsewhere, so keyboard shortcut labels in the toolbar UI match the platform without branching in the component. `isValidUrl(string)` and `normalizeUrl(string)` support the link format button: validate before applying, normalize before storing. Exported types: `ToolbarPosition`, `ToolbarDimensions`, `FormatButtonConfig`. ## command-palette. The slash-command layer. `createCommandPalette(options)` returns a `CommandPaletteController` backed by a `CommandPaletteState` object the host component can read. The trigger fires at start-of-line or after whitespace. Typing `/` elsewhere does nothing. Once triggered, the palette filters against the registered `Command` list using `fuzzyMatch(text, query)`, which returns a `FuzzyMatchResult` with a numeric score. Scoring: one point per matched character, two bonus points for consecutive characters, three bonus points for matches at word boundaries. The sort is stable; two commands with equal scores keep their registration order. `CommandPaletteOptions` controls the registered command list and the callback that fires when the user confirms a selection. The controller exposes methods for opening, closing, navigating, and confirming, so a React component can bind keyboard events without importing any keyboard-handling logic directly into the palette primitive. ## Composition note. All three primitives are leaves. They manipulate DOM, compute positions, and score strings. They do not hold reactive state, do not subscribe to stores, and do not render anything. A React component that imports all three and owns a content region is the composition layer; that is where `serializeSelection()` will eventually be called on every format change, closing the write-path gap described above. --- ## Editor Input Primitives url: https://rafters.studio/docs/editor-primitives-input/ # Editor Input Primitives. Not event listeners bolted onto components. Isolated, composable functions that return a cleanup reference and nothing else. Every interactive primitive in the editor composes these. They carry no state. They inject no dependencies. They are the substrate. Types referenced here (`KeyboardKey`, `KeyboardModifiers`, `Direction`) live in [the data model docs](/docs/editor-data-model/). The leaf/composition rule that governs why these are stateless is in [the architecture docs](/docs/editor-architecture/). ## keyboard-handler. Source: `packages/ui/src/primitives/keyboard-handler.ts` The general-purpose keyboard routing layer. `createKeyboardHandler(element, options)` attaches a `keydown` listener and returns a `CleanupFunction`. It normalizes the `Space` key to `' '` via `KEY_MAP` (line 71) so callers work with the `KeyboardKey` union type rather than raw `event.key` strings. ```typescript const cleanup = createKeyboardHandler(button, { key: ['Enter', 'Space'], handler: () => button.click(), preventDefault: true, }); ``` Options that matter: `modifiers` narrows the handler to a specific modifier combination; `capture` moves the listener to the capture phase, which the editor uses to intercept keys before they reach nested components. Both default to off. Three convenience constructors sit on top: `createActivationHandler` wires Enter and Space with `preventDefault`; `createDismissalHandler` wires Escape without `preventDefault` so nested dismissables still see the event bubble; `createNavigationHandler` routes arrow keys by orientation with optional `Home`/`End` support. For complex surfaces, `createKeyBindings` accepts an array of `KeyboardHandlerOptions` and returns a single cleanup that tears down all of them. ## escape-keydown. Source: `packages/ui/src/primitives/escape-keydown.ts` Document-level Escape listener with a single call signature: `onEscapeKeyDown(handler): CleanupFunction`. It attaches to `document`, not to the element, which is the right choice for modal and dismissable surfaces where the triggering element may not have focus. ```typescript const cleanup = onEscapeKeyDown((event) => closeModal()); ``` Use this for dialogs, sheets, popovers, and any surface that owns a layer of the stack. For element-scoped Escape handling, prefer `createDismissalHandler` from `keyboard-handler`. ## focus-trap. Source: `packages/ui/src/primitives/focus-trap.ts` `createFocusTrap(element): CleanupFunction` constrains Tab and Shift+Tab within the container. On mount it queries tabbable descendants using: ``` a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]) ``` It captures `document.activeElement` before mounting and restores focus to that element on cleanup. The list is re-queried on each keydown so dynamically added focusable nodes are picked up without a manual refresh. `preventBodyScroll(): CleanupFunction` is exported separately. It stores and restores `overflow`, `paddingRight`, and scroll position. On iOS Safari it sets `position: fixed` on the body to suppress elastic bounce (lines 113-119), then restores the original scroll position on cleanup. ## roving-focus. Source: `packages/ui/src/primitives/roving-focus.ts` Implements the ARIA roving tabindex pattern. At any moment exactly one item in the container has `tabindex="0"`; the rest have `tabindex="-1"`. Tab moves between the container and the rest of the page. Arrow keys navigate within it. ```typescript const cleanup = createRovingFocus(menuElement, { orientation: 'vertical', loop: true, onNavigate: (element, index) => setActiveIndex(index), }); ``` Items are discovered via `[data-roving-item]` and standard ARIA roles (`menuitem`, `option`, `radio`, `tab`). Disabled items are skipped: the filter checks `disabled`, `data-disabled`, and `aria-disabled="true"` (lines 76-83). Hidden and `display:none` items are also excluded. RTL support comes through the `dir` option on `RovingFocusOptions`. When `dir` is `'rtl'`, `ArrowRight` and `ArrowLeft` invert (line 142-148). `Home` and `End` jump to the first and last items regardless of orientation. Three utility exports round out the API: `focusItem(container, index)` for programmatic focus; `getCurrentIndex(container)` to read the active index without touching tabindex state; `refreshRovingFocus(container, index)` to re-synchronize tabindex after a DOM change, such as when a block is added or removed from the editor canvas. ## cursor-tracker. Source: `packages/ui/src/primitives/cursor-tracker.ts` Pure functions for cursor position within `contentEditable` surfaces. Every block element must carry a `data-block-id` attribute. The tracker finds it via `findBlockElement(node)`, which walks up the DOM to the nearest `[data-block-id]` ancestor (line 37-41). `getCursorPosition()` returns a `CursorPosition` object: `blockId`, `offset` as character count within the block's text content, and `blockLength`. The offset is computed by creating a range from the block start to the anchor node (line 44-49), which handles nested inline elements correctly. The editor subscribes via a `selectionchange` listener and calls `findBlockElement` on the selection's anchor node to derive `focusedBlockId`. `isCursorAtBlockStart()` and `isCursorAtBlockEnd()` are derived checks built on `getCursorPosition()`. `setCursorInBlock(container, blockId, offset)` walks text nodes to place the caret at the exact character offset, clamping to the end of content if the offset exceeds block length. ## The Leaf Rule. All five primitives are leaf primitives. None import nanostores. None carry reactive state. They receive a DOM element and callbacks; they return a cleanup function. State belongs to the caller. This is not a constraint imposed from outside. It is what makes them safe to compose into anything without pulling in unintended reactive dependencies. See [the architecture docs](/docs/editor-architecture/) for the full leaf/composition distinction. --- ## Editor Overlay Primitives url: https://rafters.studio/docs/editor-primitives-overlays/ # Editor Overlay Primitives. Anything that pops over the canvas composes from here. Context menus, popovers, tooltips, dialogs: they are not built from scratch each time. They are assembled from seven leaf primitives that each own one part of the problem. Not a floating-UI framework. A layer of focused contracts. --- ## block-context-menu. `packages/ui/src/primitives/block-context-menu.ts` Right-click and Shift+F10 both open the context menu for a focused block. Shift+F10 positions the menu at the center of the focused block's bounding rect; right-click uses the pointer coordinates. Either way, `positionMenu` clamps to a 4px gap from every viewport edge so the menu never clips. The primitive manages its own focus: on open, focus moves to the first `[role="menuitem"]`. Arrow keys, Home, and End navigate; Enter or Space activates. Escape dismisses. Focus is trapped inside via `createFocusTrap` and restored on close. Blocks are identified by `[data-block-id]` attributes; the primitive walks up the DOM from the event target to find the nearest one. ```typescript const menu = createBlockContextMenu({ container: canvasEl, menu: menuEl, onAction: (itemId, blockId) => dispatch({ itemId, blockId }), }); menu.open(blockId, { x: 120, y: 340 }); ``` --- ## dismissable-layer. `packages/ui/src/primitives/dismissable-layer.ts` One primitive unifying three dismissal signals: pointer down outside, escape key, and focus leaving the layer. Consumers get one `onDismiss` callback instead of wiring three separate listeners. When `disableOutsidePointerEvents` is true, a `