Tutorial: A Paint App

Step 4: The Components

Four things in the codebase, what each is responsible for, and what must stay true.

Now the things you could point at and open. Four of them, and we are going to make the compiler check that each one declares which domain it lives in.

First, require a domain

Extend the component keyword before writing any. src/framework.psy:

import { Component as MorphicComponent, Domain } from "@psy/morphic"

export abstract spec Component extends MorphicComponent as component:
    abstract domain: Domain

    testing:
        Every component has a unit test for each of its invariants. A component
        that touches the bitmap also has a test that renders a known stroke and
        compares pixels.

Two things just happened:

  • Every component must now declare domain, and it must be a real domain — a typed reference, not a string.
  • Every component inherits a testing section, written once.

Modules now use "./framework" instead of use "@psy/morphic" for components. See making the vocabulary yours.

CanvasSurface

src/components/CanvasSurface.psy:

use "../framework"

import { Canvas } from "../domains"

export component CanvasSurface:
    description: Owns the pixel buffer and everything written into it.
    domain: Canvas

    responsibilities:
        - Own the pixel buffer and its fixed dimensions
        - Composite a stamp onto the buffer at a position
        - Snapshot and restore the buffer for undo
        - Never decide what to stamp. It is told a shape and a colour.

    invariants:
        - The buffer is allocated once per drawing and never resized
        - A stamp outside the bounds is clipped, never an error
        - Snapshots are taken before a stroke begins, not during it

.claude/skills/canvas-surface/SKILL.md:

---
name: canvas-surface
description: "Component: Owns the pixel buffer and everything written into it."
---

# CanvasSurface

## Responsibilities

- Own the pixel buffer and its fixed dimensions
- Composite a stamp onto the buffer at a position
- Snapshot and restore the buffer for undo
- Never decide what to stamp. It is told a shape and a colour.

## Invariants

- The buffer is allocated once per drawing and never resized
- A stamp outside the bounds is clipped, never an error
- Snapshots are taken before a stroke begins, not during it

## Testing

Every component has a unit test for each of its invariants. A component that
touches the bitmap also has a test that renders a known stroke and compares
pixels.

## Authority

This describes the product as it is meant to work. Where the implementation
disagrees, treat this as correct unless the task is explicitly to change it.

Note the fourth responsibility. "Never decide what to stamp" is a negative responsibility, and it is worth more than the three above it — the plausible wrong move is to give the thing that owns the pixels some opinions about brushes, and this rules it out at the point somebody would look.

Note also that domain: Canvas produced no section. Reserved names and typed references are not rendered as content; only strings and string lists become sections. See what does and does not become one.

ToolPalette

src/components/ToolPalette.psy:

use "../framework"

import { Tools } from "../domains"

export component ToolPalette:
    description: Which tool is active, and the options for it.
    domain: Tools

    responsibilities:
        - Hold the set of available tools and which one is active
        - Hold the current options for each tool independently
        - Produce the stamp for the active tool on request

    invariants:
        - Exactly one tool is active. There is no null state.
        - Each tool remembers its own options. Changing brush size does not
          change eraser size.

That second invariant is the sort of thing that is obvious to a user, invisible in code, and wrong in roughly half of all first implementations.

ColorPicker

src/components/ColorPicker.psy:

use "../framework"

import { Tools } from "../domains"

export component ColorPicker:
    description: The foreground and background colours.
    domain: Tools

    responsibilities:
        - Hold the foreground and background colour
        - Present the palette and the current pair
        - Emit a colour change; never apply one itself

    invariants:
        - Colours are opaque RGB. There is no alpha channel anywhere.

Three lines, one of them negative, and the invariant ties straight back to the concept — a drawing has no transparency, so a colour cannot have one either. That consistency is not an accident; it is what having a concept gets you.

FileStore

src/components/FileStore.psy:

use "../framework"

import { Files } from "../domains"

export component FileStore:
    description: Reading and writing PNG files.
    domain: Files

    responsibilities:
        - Encode the buffer as PNG and write it to a path
        - Decode a PNG into a buffer
        - Track the current file path and the unsaved-changes flag
        - Never prompt. It reports what it needs; the UI asks.

    invariants:
        - Writes go to a temporary path and are moved into place
        - A decode failure leaves the current drawing untouched

.claude/skills/file-store/SKILL.md:

---
name: file-store
description: "Component: Reading and writing PNG files."
---

# FileStore

## Responsibilities

- Encode the buffer as PNG and write it to a path
- Decode a PNG into a buffer
- Track the current file path and the unsaved-changes flag
- Never prompt. It reports what it needs; the UI asks.

## Invariants

- Writes go to a temporary path and are moved into place
- A decode failure leaves the current drawing untouched

## Testing

Every component has a unit test for each of its invariants. A component that
touches the bitmap also has a test that renders a known stroke and compares
pixels.

## Authority

This describes the product as it is meant to work. Where the implementation
disagrees, treat this as correct unless the task is explicitly to change it.

The contract earning its keep

Try leaving out the domain:

export component Toolbar:
    description: The row of buttons along the top.

    responsibilities:
        - Show the available tools
src/components/Toolbar.psy:5:1 error PSY3015: `Toolbar` does not define abstract
property `domain`.
    src/framework.psy:4:5: `domain` is required here

Or naming the wrong kind of thing:

export component Toolbar:
    description: The row of buttons along the top.
    domain: Pencil
src/components/Toolbar.psy:3:13 error PSY4001: `Toolbar.domain` must be `Domain`
but `Pencil` is a Feature.
    src/framework.psy:4:5: `domain` is declared here

Six lines in src/framework.psy bought a compiler-checked architecture map. Every component is placed, every placement is real, and nobody can add one that floats.

Check

psy check
check: no problems found in 17 file(s)

Next: The References

On this page