Tutorial: A Paint App

Step 3: The Features

Pencil, Brush, Eraser, New Drawing and Save Drawing — and the decisions inside each.

Five features. Written from outside, as a user would describe them, with the decided parts spelled out and the obvious parts left alone.

A shared base first

All three drawing tools behave the same way at the edges — activation, cancellation, what counts as one undo step. Write that once.

src/features/DrawingTool.psy:

use "@psy/morphic"

export feature DrawingTool:
    description: Behaviour common to every drawing tool.

    behaviour:
        Pointer-down begins a stroke, dragging extends it, pointer-up ends it.
        Activating a different tool ends any stroke in progress first.

    constraints:
        - One stroke is one undo step, from pointer-down to pointer-up
        - Escape during a stroke cancels it and restores the pre-stroke bitmap

This is a feature and not a concept because it describes behaviour a user can observe. StrokeModel is the concept underneath it — the machinery. This is what that machinery looks like from the outside.

Pencil

src/features/Pencil.psy:

use "@psy/morphic"

import { DrawingTool } from "./DrawingTool"

export feature Pencil extends DrawingTool:
    description: Draw a hard single-pixel line.

    behaviour:
        + super

        Dragging draws a one-pixel line in the foreground colour, following
        the pointer. The line is hard-edged: no anti-aliasing, no opacity
        ramp, every touched pixel is set to exactly the foreground colour.

    edgeCases:
        - A click without a drag paints exactly one pixel.
        - Dragging outside the canvas and back continues the same stroke; the
          pixels outside are discarded rather than clamped to the edge.

+ super composes the inherited behaviour with the local paragraph rather than replacing it — see Composing. The generated file will carry both.

.claude/skills/pencil/SKILL.md:

---
name: pencil
description: "Feature: Draw a hard single-pixel line."
---

# Pencil

## Behaviour

Pointer-down begins a stroke, dragging extends it, pointer-up ends it.
Activating a different tool ends any stroke in progress first.

Dragging draws a one-pixel line in the foreground colour, following the pointer.
The line is hard-edged: no anti-aliasing, no opacity ramp, every touched pixel is
set to exactly the foreground colour.

## Constraints

- One stroke is one undo step, from pointer-down to pointer-up
- Escape during a stroke cancels it and restores the pre-stroke bitmap

## Edge Cases

- A click without a drag paints exactly one pixel.
- Dragging outside the canvas and back continues the same stroke; the pixels
  outside are discarded rather than clamped to the edge.

## 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.

Both paragraphs, joined by a blank line. constraints came through inherited and untouched. We wrote the shared behaviour once.

"No anti-aliasing" is the whole reason this feature has a definition. It is the one thing about a pencil somebody would get wrong, because anti-aliasing is what you would do if nobody told you otherwise.

Brush

src/features/Brush.psy:

use "@psy/morphic"

import { DrawingTool } from "./DrawingTool"

export feature Brush extends DrawingTool:
    description: Draw a soft, sized stroke.

    behaviour:
        + super

        Dragging draws a stroke of the current brush size in the foreground
        colour. The stroke is anti-aliased and its edge falls off according to
        the hardness setting: at 100% it is a clean circle, at 0% it fades to
        nothing at the radius.

    options:
        - Size, 1 to 200 pixels
        - Hardness, 0 to 100 percent

    edgeCases:
        - A click without a drag stamps the brush once.
        - Overlapping passes within a single stroke do not darken each other.
          Accumulation happens between strokes, not within one.

.claude/skills/brush/SKILL.md:

---
name: brush
description: "Feature: Draw a soft, sized stroke."
---

# Brush

## Behaviour

Pointer-down begins a stroke, dragging extends it, pointer-up ends it.
Activating a different tool ends any stroke in progress first.

Dragging draws a stroke of the current brush size in the foreground colour. The
stroke is anti-aliased and its edge falls off according to the hardness setting:
at 100% it is a clean circle, at 0% it fades to nothing at the radius.

## Constraints

- One stroke is one undo step, from pointer-down to pointer-up

- Escape during a stroke cancels it and restores the pre-stroke bitmap

## Options

- Size, 1 to 200 pixels
- Hardness, 0 to 100 percent

## Edge Cases

- A click without a drag stamps the brush once.
- Overlapping passes within a single stroke do not darken each other.
  Accumulation happens between strokes, not within one.

## 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.

That last edge case is the interesting one. "Overlapping passes do not darken each other" is a real decision with a real implementation consequence — you have to accumulate the stroke into a scratch buffer and composite once. Write it here and nobody has to discover it from a bug report.

Eraser

src/features/Eraser.psy:

use "@psy/morphic"

import { DrawingTool } from "./DrawingTool"

export feature Eraser extends DrawingTool:
    description: Paint the background colour back over the drawing.

    behaviour:
        + super

        Dragging paints the background colour with a hard square stamp of the
        current eraser size.

        The eraser does not make pixels transparent. A drawing has no
        transparency — it is one opaque bitmap — so erasing is painting.

    options:
        - Size, 1 to 200 pixels

    edgeCases:
        - Changing the background colour does not retroactively change already
          erased pixels. They were painted with the colour that was current.

.claude/skills/eraser/SKILL.md:

---
name: eraser
description: "Feature: Paint the background colour back over the drawing."
---

# Eraser

## Behaviour

Pointer-down begins a stroke, dragging extends it, pointer-up ends it.
Activating a different tool ends any stroke in progress first.

Dragging paints the background colour with a hard square stamp of the current
eraser size.

The eraser does not make pixels transparent. A drawing has no transparency — it
is one opaque bitmap — so erasing is painting.

## Constraints

- One stroke is one undo step, from pointer-down to pointer-up
- Escape during a stroke cancels it and restores the pre-stroke bitmap

## Options

- Size, 1 to 200 pixels

## Edge Cases

- Changing the background colour does not retroactively change already erased
  pixels. They were painted with the colour that was current.

## 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 eraser does not make pixels transparent is the single most valuable sentence in this tutorial. Every developer who has used a modern image editor will implement alpha erasure by reflex. The definition says why not, in one clause, tied back to what a drawing is — which we established in Step 1.

New Drawing

Not a tool. Still a feature — it is behaviour a user invokes.

src/features/NewDrawing.psy:

use "@psy/morphic"

export feature NewDrawing:
    description: Start a fresh drawing.

    behaviour:
        Asks for a width and height, then replaces the current drawing with a
        new bitmap filled with the background colour.

        If the current drawing has unsaved changes, the user is asked first and
        may cancel. Cancelling leaves everything exactly as it was.

    constraints:
        - Dimensions are between 1 and 10000 pixels on each side
        - The default is the size of the previous drawing, not a fixed size
        - Creating a new drawing clears the undo history entirely

    edgeCases:
        - Confirming discard is irreversible. Undo does not bring back the
          previous drawing, because the history went with it.

.claude/skills/new-drawing/SKILL.md:

---
name: new-drawing
description: "Feature: Start a fresh drawing."
---

# NewDrawing

## Behaviour

Asks for a width and height, then replaces the current drawing with a new bitmap
filled with the background colour.

If the current drawing has unsaved changes, the user is asked first and may
cancel. Cancelling leaves everything exactly as it was.

## Constraints

- Dimensions are between 1 and 10000 pixels on each side
- The default is the size of the previous drawing, not a fixed size
- Creating a new drawing clears the undo history entirely

## Edge Cases

- Confirming discard is irreversible. Undo does not bring back the previous
  drawing, because the history went with it.

## 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 default is the size of the previous drawing" is a tiny decision that is invisible in code and obvious to a user the first time it is wrong.

Save Drawing

src/features/SaveDrawing.psy:

use "@psy/morphic"

export feature SaveDrawing:
    description: Write the drawing to a PNG file.

    behaviour:
        Writes the bitmap to disk as a PNG. If the drawing has been saved
        before, it overwrites that file without asking. Otherwise it asks for
        a path.

        After a successful save the drawing is no longer considered to have
        unsaved changes.

    constraints:
        - PNG only. There is no format choice.
        - Saving never modifies the bitmap or the undo history
        - The file is written to a temporary path and moved into place, so a
          failure leaves the previous file intact

    edgeCases:
        - A failed write reports the reason and leaves the drawing marked as
          having unsaved changes.
        - Saving with no changes since the last save still writes the file.
          It is not a no-op, because the file may have changed underneath us.

.claude/skills/save-drawing/SKILL.md:

---
name: save-drawing
description: "Feature: Write the drawing to a PNG file."
---

# SaveDrawing

## Behaviour

Writes the bitmap to disk as a PNG. If the drawing has been saved before, it
overwrites that file without asking. Otherwise it asks for a path.

After a successful save the drawing is no longer considered to have unsaved
changes.

## Constraints

- PNG only. There is no format choice.
- Saving never modifies the bitmap or the undo history
- The file is written to a temporary path and moved into place, so a failure
  leaves the previous file intact

## Edge Cases

- A failed write reports the reason and leaves the drawing marked as having
  unsaved changes.
- Saving with no changes since the last save still writes the file. It is not a
  no-op, because the file may have changed underneath us.

## 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.

Write-to-temp-and-move is an implementation detail that has escaped into a constraint, deliberately. It is a guarantee to the user — your old file survives a crash — and stating it as behaviour is how it survives a refactor.

Check the lot

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

Six feature files, all compiling, all inheriting correctly. Try breaking one to see the contracts work:

export feature Smudge:
    description: Blur pixels together.
src/features/Smudge.psy:3:1 error PSY3015: `Smudge` does not define abstract
property `behaviour`.
    node_modules/@psy/morphic/psy/framework.psy:31:5: `behaviour` is required here

A feature with no stated behaviour is not a feature. The vocabulary says so, and the compiler enforces it.

Next: The Components

On this page