- Prefer switch statements over long if-else chains when branching on the same value.
- Prefer early returns over nested if-else blocks. Return early for guard clauses to keep the main logic at the top indentation level. In React components, guard on null data at the top (
if (!glyph) return null;) instead of scatteringglyph?.foo ?? fallbackthroughout the JSX. - Prefer
async/awaitwithtry/catchfor asynchronous control flow. - Avoid
.then(...)/.catch(...)chains in application code when the same flow can be written clearly withawait. - Avoid
void promise.catch(...)in React event handlers and normal command handlers. Use anasyncfunction withtry/catchinstead. - Use
void promise.catch(...)only for genuinely fire-and-forget boundaries that cannot be awaited by the caller, such as Electron menu callbacks. Keep those cases local and log or surface the failure. - Avoid async IIFEs assigned into state, for example
state = (async () => { ... })().catch(...). Extract a named helper so the shared-state/memoization code and the async work are readable separately. - Do not prefix commit messages or pull request titles with
[codex].
- Domain types are plain nouns.
Glyph,Contour,Point,Anchor— notGlyph,GlyphInfo,GlyphState,GlyphRenderData. If you need a modifier, it should describe the kind of thing (EditableGlyph,RenderContour), not append generic suffixes. - Avoid
-Data,-Info,-Statesuffixes on types unless it genuinely represents transient mutable state (e.g.TextRunRenderStatefor a signal value consumed by a render pass). If the type represents a domain concept, name it after the concept. - Signals are named
*Cell; values use the plain noun. ASignal<Glyph | null>isglyphCell, and the unwrapped value isglyph. Do not introduce new dollar-prefixed signal names; those are legacy style. - Use
track(cell)for invalidation-only subscriptions. Insidecomputed/effect, usetrack(fooCell)when the code needs to rerun onfooCellchanges but does not use the value. Useconst foo = fooCell.valuewhen the value is actually read.
When completing a feature, check ROADMAP.md and check any box if we have completed it in the new feature. Always add tests to verify behaviour.
Tests use TestEditor from @/testing/TestEditor (real Editor + real NAPI). Assert on state, not mock calls. See /writing-tests skill for canonical rules, templates, banned patterns, and the fake-test checklist — trigger it any time you add, rewrite, or review a .test.ts file.
All UI components must wrap Base UI primitives:
- ALWAYS check if a Base UI component exists before creating a custom implementation
- Wrapper components live in
packages/ui/src/components/{componentName}/ - Import Base UI as
import { Component as BaseComponent } from "@base-ui-components/react/component" - Export a wrapped version that applies project styling and extends the Base UI props
- Use the same name as Base UI (e.g.,
Separator,Input,Tooltip)
Example wrapper structure:
import { Separator as BaseSeparator } from "@base-ui-components/react/separator";
export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, ...props }, ref) => (
<BaseSeparator ref={ref} className={cn("project-styles", className)} {...props} />
),
);This project uses pnpm (v9.0.0) as its package manager.
pnpm dev- Start the Electron app in development modepnpm dev:app- Start with watch mode
pnpm format- Format code with Prettierpnpm format:check- Check formatting without modifying filespnpm lint- Lint code with Oxlint (auto-fix)pnpm lint:check- Lint code without auto-fixpnpm typecheck- Type check with tsgocargo fmt- Format Rust code (run after any Rust changes)cargo clippy- Lint Rust code (run after any Rust changes)
pnpm test- Run tests oncepnpm test:watch- Run tests in watch mode
pnpm build:native- Build Rust native modulespnpm build:native:debug- Build native modules in debug modepnpm package- Package the applicationpnpm make- Build and create distribution
pnpm generate:glyph-info- Generate glyph data, decomposition, charsets, and FTS5 search indexpnpm glyph-info:repl- Start interactive REPL with GlyphInfo pre-loaded
pnpm clean- Clean build artifacts and node_modulespnpm check-deps- Check for unused dependencies
apps/desktop/src/- Electron app (main, preload, renderer, shared)crates/- Rust workspace (shift-font, shift-backends, shift-bridge, shift-store)packages/- TypeScript packages (types, geo, font, ui)
- Geometry utilities (Vec2, Curve, Polygon) → import from
@shift/geo - Glyph-domain geometry (contour traversal, segment parsing, tight/x bounds) → import from
@shift/font - Core types (Point2D, Rect2D, PointId, ContourId) → import from
@shift/types - NEVER duplicate package code in app layer
- If you need functionality from a package, import it; don't copy it
- Do not synthesize fake point IDs for geometry-only operations
- Canonical glyph geometry APIs:
parseContourSegments,deriveGlyphTightBounds,deriveGlyphXBounds
@shift/*for imports from packages (external-facing shared code)@/*for app-wide imports (from renderer/src root)- Relative imports (
./,../) only within the same module directory - Never mix import styles for the same module
- Never use inline type imports such as
import("@shift/types").PointId,import("@/types/hitResult").ContourEndpointHit, orimport("../core").ToolEvent. Always use top-level imports:import type { PointId } from "@shift/types",import type { ContourEndpointHit } from "@/types/hitResult", orimport type { ToolEvent } from "../core".
- Domain types belong in
/types/{domain}.ts, not in implementation files - NEVER define types (interfaces, type aliases, enums) directly in classes or service files
- Types should be imported from dedicated type files
- Re-export types from their domain's index.ts for public API
- NEVER re-declare types that exist in
@shift/types(generated from Rust). Import from@shift/types; for derived views (e.g. readonly, nested) use the domain pattern inpackages/types/src/domain.ts
- Generated types (from Rust via ts-rs) live ONLY in
packages/types/src/generated/. Runcargo test --package shift-coreto regenerate. They are the single source of truth for shapes and field names (e.g.familyName,versionMajor, notfamilyorversion). - Domain types (e.g.
Point,Contour,Glyph) live inpackages/types/src/domain.ts. They MUST derive from generated types (e.g.Readonly<PointSnapshot>,Omit+ composition). Seedomain.ts: same field names, no re-declaration of structure. - App layer: NEVER re-declare types that exist in
@shift/types. ImportFontMetadata,FontMetrics, snapshot types, etc. from@shift/types. If you need a narrowed or immutable view, define it inpackages/types(e.g. domain.ts) as a type derived from the generated type, not as a new interface in the app. - Bridge and native layer are typed with
@shift/types; engine and UI use those types and the same field names (e.g.familyNamein the UI, notfamily).
- Single classes should not exceed 500 lines
- If a file grows beyond 300 lines, evaluate splitting by responsibility
- Prefer composition over monolithic classes
- NEVER create Manager, Store, or Cache wrapper classes. NativeBridge is the single interface to Rust. Do not wrap it in FooManager, FooStore, or FooCache. If you need derived data, compute it at the call site — NAPI calls are ~50μs.
- NEVER create CONTEXT.md files. These are agent-generated dumps that go stale. Use
docs/architecture/for architecture docs.
Rules enforced by scripts/oxlint/shift-plugin.mjs are omitted here — the linter catches them. The rules below are conventions not covered by lint:
- Use Point2D in function signatures. Never create
(x, y)/(Point2D)overloads withtypeofresolution code. - Blank lines between logical blocks. Separate guard clauses, branches, and return statements with blank lines.
- Do not add methods to Editor without justification. Editor.ts is a facade with 150+ delegation methods. Ask: does it add logic? Can it be a pure function? Does it belong on NativeBridge?
editor.createDraft() returns a GlyphDraft for drag operations (translate, rotate, resize, bend). The draft separates JS preview (every frame) from Rust persistence (once at end):
setPositions(updates)— callsglyph.apply()directly. JS-only, no NAPI, no Rust. Fires internal Glyph signals which trigger render effects.finish(label)— syncs final state to Rust viarestoreSnapshotonce, records undo.discard()— restores JS model from base snapshot. Rust was never modified.
NEVER call bridge.setNodePositions() inside the draft hot path. That sends N individual NAPI struct marshals to Rust per frame. For glyphs with thousands of points this causes ~450ms frames + GC pressure. The draft exists specifically to avoid this.
Render effects track glyph.contours and glyph.anchors, not a bridge glyph identity cell. Bridge glyph identity signals are for loaded/unloaded identity changes. Glyph data changes propagate through the Glyph model's internal signals. #patchPositions fires the contour cell with a new array reference so glyph-level effects see the change.
Before creating new documentation or exploring unfamiliar subsystems, consult docs/architecture/index.md. It maps every repo area to its canonical DOCS.md and lists API boundaries. Use /docs to update module documentation following the standard format.
Run python3 scripts/context-drift-check.py to validate doc freshness and link integrity.
- Documentation routing & API boundaries: Read
docs/architecture/index.md - Signal patterns & Editor conventions: Read
lib/editor/Editor.tsheader comments - Tool structure & behavior system: Read
lib/tools/core/BaseTool.ts - Command organization: Read
lib/commands/core/Command.ts