Skip to content
Fineliner

Drawings · For your coding agent

Drawing skill

The kit draws 85 objects; your project will want ones it doesn’t have. The drawing skill teaches your coding agent to draw them in the same pen, from a seed, in your own code.

  • Watering can
  • Carrying a parcel
The skill’s worked example: a plant shop, a watering can and someone carrying a parcel. None of them is in the kit; the skill draws them in a project’s own module, from the kit’s parts.

Install

Next to the kit, from the same registry. It lands in .claude/skills and .agents/skills, for Claude Code, Codex and any agent that reads skills.

Terminal
npx shadcn add @fineliner/drawing-skill

Or copy it from the repository. Then ask your agent for the drawing you need.

Ask in your own words

  • “Our booking app has no bicycle drawing. Add one in the same style for the empty state, above ‘No bookings yet’.”

    An object

    <Drawing object="bicycle" />
  • “One of those Fineliner people, but walking a dog on a lead. It has to look like the kit’s other people, not clip art.”

    A person in a new pose

    <Drawing object="dog-walker" />
  • “A header drawing of our little roastery: a drum roaster, a few sacks of beans, a scale, somebody working there.”

    A scene

    <Drawing scene="roastery" />

What your agent does with it

Draws in your module, not the kit’s
New drawings go in src/drawings, registered with define() and defineScene(), so updating the kit never loses them.
Builds from the kit’s parts
Boxes, rounds and cylinders; people from the kit’s clothes and hair; scenes on the kit’s stage, walls and drawn border.
Follows the style
One line, calm 3/4 views, corners that meet, a few pockets of ink, and every choice taken from the seed.
Looks before it reports
It renders a contact sheet at several seeds and both hands, and checks it against the list the kit’s own drawings were reviewed with.

SKILL.md

The skill, in full

Fineliner’s drawings are not image files. Each one is a small TypeScript function that tells a seeded pen where to go: the same seed draws the same picture on the server and in the browser, and a different seed draws a sibling with its own small differences. A project grows the library by writing more of these functions in its own files and registering them, the same way the kit registers its own objects and scenes.

Your job is to draw what the project needs so that it sits on the page next to the kit’s own drawings as if the same person had drawn it: one thin wobbly line, calm 3/4 views, corners that meet, a few pockets of solid ink, nothing more.

Before you draw

  1. Find the kit. It lives in src/fineliner/ (installed by shadcn add); the engine is src/fineliner/pen/. Look at src/fineliner/pen/objects/*.ts: every kit object is there (on an older kit, before the collections split), and the closest one to what you’re asked for is your best guide (a bicycle starts from how the chair and desk lamp are built; a bakery counter from the desk and the bistro table). Read references/style.md once, before the first drawing of a session.
  2. Check it doesn’t exist. The ids in objects/*.ts, or listObjects() once the collections are imported. Sometimes the answer is an existing object with an option (window takes view, plate takes dish, person takes pose and hair).
  3. Decide what it is. An object (one thing on its own tile, placed in scenes too), a person in a new pose, or a scene (a room composed from objects inside the drawn border). Know where it will appear and how big: a 48 px empty-state spot needs fewer, larger parts than a hero scene.

Where the code goes

Write the drawings in the project’s own module, never inside src/fineliner/. The kit’s files are replaced when the project runs shadcn add again to update, and anything added to them is lost.

src/drawings/index.ts   ← your objects and scenes, registered on import

Import that module (import "@/drawings") at the top of every file that renders <Drawing> with one of your ids, or calls drawing() with one. Registration happens when the module runs, into one library per JavaScript realm, so a Next.js server component that imports it covers the server render of a client <Drawing> too. The browser needs it only to draw there (a reroll, line boil’s later takes): import it in a client component as well for those. A wrong or missing import shows up as the error No drawing object "your-id".

The kit’s own drawings register the same way, one collection at a time: @/fineliner/pen is the engine only. Import the collections your module places or your pages show (import "@/fineliner/pen/objects/indie", or every one with @/fineliner/pen/objects, and the kit’s scenes with @/fineliner/pen/objects/rooms), and install them if the project lacks them (npx shadcn add @fineliner/drawings-indie). A kit without src/fineliner/pen/objects/indie.ts predates the split: there the engine registers everything itself.

assets/example-drawings.ts is a complete, working module (a watering can, a person carrying a parcel, and a shop counter scene). Copy its shape.

Drawing an object

Read references/engine.md for the API. The essentials:

import {
  define,
  objectsIn,
  depth,
  box,
  type Draw,
  type Point,
} from "@/fineliner/pen"

/** A wooden step stool. `at` is where it stands (bottom-left); k is its scale. */
export const stool: Draw = (s, p, r, at, k = 1) => {
  const o = depth(r) // where "back" is, from the seed
  const top = box(s, p, o, [at[0], at[1] - 40 * k], 60 * k, 6 * k, 30 * k)
  // …legs first (behind), then the top box drawn over their ends
}

const T = objectsIn("workshop") // your own collection name
define(T("stool", "Step stool", [120, 100], [20, 90], 1, stool, [
  "furniture",
  "home",
]))
  • s is the sheet you draw on, p the pen, r the shape stream for every choice (sizes, angles, how many). at and k say where and how big; build every coordinate from them so scenes can place and scale the object.
  • Draw back to front. s.blank(p.shape(outline)) hides everything drawn earlier inside that outline; then stroke the outline. That’s how a front leg hides the back one and a cup hides its handle’s roots.
  • Closed outlines are one stroke (p.shape), so corners meet. Inner lines should end on an outline or under a part drawn after them, so the pen’s small run-on lands on a line instead of poking out. box, pad, drum, banded and cylinder already do this.
  • Every random choice comes from r, never Math.random() or Date.now(): the drawing must be the same for the same seed everywhere. Take sizes and angles from r only where variety helps; keep the object recognisable at every seed.
  • The tile (size, at, k in define) is the object’s own frame for <Drawing object>: leave about 8 px of air round the drawing and check that no seed runs outside it.

People and scenes

  • People: references/people.md. Build new poses from the kit’s clothes(), head() and hairBehind(), so they wear the same loose tops, wide trousers and trainers and the same inked hair. People in scenes are usually only their clothes (no head), as in every kit scene.
  • Scenes: references/scenes.md. defineScene(name, { name, label, size, compose }) with the kit’s stage() for the walls, rug, border and put(), then <Drawing scene="name" seed="…" />.

Look at it large, then fix it

A drawing that typechecks can still look wrong, and you only see that at size. Render a contact sheet of the new ids across several seeds and both hands:

node <skill-dir>/scripts/render.mjs --module src/drawings/index.ts --ids stool,shop-counter --seeds a,b,c,d --out drawings-check.png

It needs jiti in the project (most Vite and Next apps have it already; if not, the script says so). For the PNG it uses sharp if the project has it, or a Chrome or Edge on the machine, headless; with neither it writes an HTML page. Don’t add dependencies to the user’s project just for this. It also warns when an object comes within 4 px of its tile’s edge or runs past it, and when a seed gives broken coordinates. Then look at the image and ask:

  • Would it sit unnoticed next to the kit’s chair, mug and turntable? Same line, same calm, same amount of ink?
  • Do corners meet? Does anything poke through a part in front of it, or float without touching what holds it?
  • Is the perspective one perspective (one depth(r) per object), tipping up and to the right?
  • Does every seed still read as the thing, and does the calm hand (--hands calm) look as clean as the lively one?
  • At the size it will be used, is any detail smaller than the pen’s wobble (a scribble)? Drop it or make it bigger.

Fix and render again until the answers are yes. Two or three rounds is normal.

Finish

  • Run the project’s typecheck, and render the page that uses the drawing.
  • Tell the user what you drew, the id(s) and how to use them (<Drawing object="stool" seed="any-string" />), where the code lives, and what you weren’t able to check.
references/style.mdThe look: the rules, and the mistakes that make a new drawing stand out

What a Fineliner drawing is, and the mistakes that make a new one stand out from the kit’s. Read this once before you draw; come back to the checklist at the end when you review a contact sheet.

The look in one breath

One thin navy fine-liner on unbleached paper, drawn by a calm, slightly naive hand. It comes from natural-wine labels and indie record sleeves: everyday things (records, a chair, a plant, a mug), people who are only their clothes or have a dot for each eye, and rooms with plenty of empty wall. Deadpan, never cute.

Rules

  1. One pen, one weight. Every line is the same monoline; the CSS sets its width, so never draw a line twice to make it bolder, and never draw sketchy double strokes. Thin parts are one line: a bicycle’s tubes and spokes, a lamp’s stem, a cord, a lead, a chair’s rail at small sizes. Draw both edges only of parts with real thickness at the size of use (a table top, a leg of a sofa, a pot’s rim); doubling every tube makes a drawing heavy and technical.
  2. Contours, not tone. No hatching, shading, gradients or drop shadows. A thing is its outline plus the few inner lines that explain it (a drawer’s handle, a book’s pages, a pot’s rim).
  3. Ink is rare. s.fill makes solid ink; use it for at most two or three small places per object, where the eye should land: a record on a platter, the soil in a pot, a lampshade’s inside, buttons, hair. A large solid shape (a black animal, a dark coat, a heap of beans) needs a reason the drawing can’t do without; a precedent in the kit (its black cat) is not one. A drawing with more fill than outline has left the style.
  4. The wash is for art. s.wash is a thinned second tone for things like wine in a glass, a sticky note or a pot’s rim band. One washed area per object at most; many objects have none.
  5. Flattened 3/4 view. Objects are seen a little from above and from the front-left, receding up and to the right at the angle depth(r) gives. Round things are ellipses seen from above (drum, cylinder). One object has one perspective; neighbours may disagree a little. Things that read best from the side (a bicycle, a dog, a car) are drawn in profile.
  6. A calm hand. The pen wobbles on its own. Your geometry should be clean and deliberate: straight where the thing is straight, symmetric where it is symmetric. The naivety is in simple shapes and slight wonkiness, not in errors.
  7. Air. Leave space inside and around a drawing. Fewer, larger parts read better than many small ones.
  8. Deadpan subjects. No faces on objects, no mascots, no speech, no lettering inside drawings beyond scribbled lines (scribble) where a label or a screen would have text.

How the kit’s drawings get clean (learned the hard way)

These are the fixes the kit’s own library needed. A new drawing that skips them looks rough next to it.

  • Closed outlines in one stroke. p.shape(points) draws a closed outline as one run whose ends meet mid-edge, so corners join. p.edges(points) draws every edge separately, and each overshoots its corners: rectangles turn into crossed sticks. Use edges only for a deliberately loose, sketchy part, which is rare.
  • Lines end on something. An open line’s ends run on a little past where you put them (up to about 4.5 px with the lively hand). End inner lines on an outline (the overshoot lands on that line), or under a part drawn later that blanks them, or pull the end back a couple of pixels. A seam across a box ends just inside its edges.
  • Back to front with blanks. Draw what is behind first, then s.blank(p.shape(outline)) and stroke the outline of what is in front. Legs run up into a table top drawn after them; stems go into a pot whose front is drawn after them. Joins then look made, not drawn.
  • Small details are scribbles. Anything shorter than about 6 px at the size of use becomes a knot of wobble. Use a dot (p.disc filled) instead of a tiny line, or leave it out.
  • Rings look like rings. A small closed circle reads cleaner as p.disc(...) stroked (s.stroke(p.disc(...))) than as p.ring, which deliberately runs past its start like a quick hand.
  • People don’t read old. Hair is a filled shape (outlined hair reads as grey); clothes are loose sweaters, hoodies, open overshirts and tees, wide trousers with rolled hems or a short skirt, and trainers. The kit’s clothes() and head() already do this.

Review checklist

Look at the contact sheet at several seeds, both hands, and ask:

  • Would it pass unnoticed between the kit’s chair, mug and turntable?
  • Do all corners meet? Does any line poke through a part in front of it, past a rim, or out of the silhouette?
  • Does every part touch what holds it (a handle its cup, a shade its arm, a person their chair)?
  • One perspective? Round tops are ellipses, boxes recede up and to the right.
  • Is ink rare and purposeful, with no large solid shape it could do without? Is there at most one washed area?
  • Are thin parts single lines, and only thick parts drawn with both edges?
  • Does every seed still read as the thing? Is anything outside the tile?
  • At the size it will be used, are there scribbles where details were meant to be?
references/engine.mdThe pen engine: coordinates, the sheet, the pen, shapes and seeds

Everything here is exported from @/fineliner/pen (the kit’s src/fineliner/pen/index.ts). The engine is framework-free TypeScript: no React, no DOM, so a drawing runs on the server, at build time and in the browser.

Contents

  • Coordinates
  • A draw function
  • The sheet: what a mark is
  • The pen: how a line goes
  • Geometry helpers
  • Shapes
  • Seeds and the shape stream
  • Registering an object
  • Placing objects inside other drawings

Coordinates

SVG coordinates: x to the right, y down. A drawing’s at is usually its bottom-left or bottom-centre (say which in the function’s comment) and everything is built upward from it, scaled by k:

const P = (u: number, v: number): Point => [at[0] + u * k, at[1] - v * k] // u right, v up, in the object's own units

Write the object in its own units (a mug 40 wide, 44 tall) and let P place it. Angles in arc are degrees, 0 to the right and 90 down.

A draw function

type Draw = (
  s: Sheet,
  p: Pen,
  r: Stream,
  at: Point,
  k: number,
  options: DrawOptions,
) => unknown

What it returns is for scenes that stack things: desk returns its top as a Box so a scene can put a lamp on top.top(0.9, 0.6). Return nothing if nothing sits on it.

options are choices a caller can make instead of the seed: kind, pose, view, dish, hair, glasses, beard, black, wine, top, w, h, d, legs. Reuse these names; destructure with a seeded default ({ kind = r.pick(["round", "square"]) } = {}) so the object works alone and on command.

The sheet: what a mark is

Call Draws
s.stroke(d) a pen line
s.fill(d) solid ink (rare)
s.wash(d) the thinned second tone, for art (wine, a sticky note)
s.paper(d) flat paper over ink, for small holes (a record’s label, a bulb inside a dark shade)
s.blank(d) hides everything drawn before it inside the shape; later marks are unaffected

d is SVG path data from the pen. Layering is by order: draw back to front and blank before each front part.

The pen: how a line goes

Call Use for
p.shape(points) a closed outline in one stroke, corners meeting; also the shape to blank or fill
p.path(points) an open run through points (a sleeve’s edge, a stem); its two ends run on a little
p.line(a, b) one straight stroke; both ends run on a little
p.edges(points, closed?) each edge its own stroke, overshooting every corner (rarely what you want)
p.disc(c, u, v?) a closed ellipse outline, for fills, blanks and clean small rings
p.ring(c, u, v?) a drawn ellipse that goes a little past its start, like a quick hand
p.round(c, u, v, from, turn) points round an ellipse (radians), to feed into path or shape

u and v are the ellipse’s axis vectors: dot(c, r) gives [c, [r, 0], [0, r]] for a circle, so s.fill(p.disc(...dot(c, 3 * k))). HANDS[p.hand].over is how far this hand runs past an end, if you need to pull an end back by it.

Geometry helpers

add(a, b, f?), lerp(a, b, t), times(v, f), length(v), dist(a, b), turn(pt, centre, degrees), arc(c, rx, ry, a0, a1, n?) (points on an ellipse), rect(x, y, w, h) (corners from the top-left), roundRect(x, y, w, h, rad) (a rounded rectangle’s loop, for p.shape), dot(c, r), deg, snap, last.

Use Math.sqrt, never **, Math.pow or Math.hypot: they round differently between engines and a last-bit difference can change how many points a stroke gets, so server and browser would draw different pictures.

Shapes

Helper Draws
depth(r) the object’s “back” direction, up and to the right at a seeded angle; use once per object
box(s, p, o, at, w, h, d) an oblique box (front face w × h, bottom-left at at, receding d along o); returns Box
pad(s, p, o, at, w, h, d, rad) a padded box with rolled top corners (cushions, arms of a sofa); returns Box
banded(s, p, x, y, w, h, rad, cut) a rounded rectangle with a band across its top (a browser’s tab bar, a camera’s top)
cylinder(s, p, c, rx, ry, h) an upright cylinder; c is the centre of its top
drum(s, p, c, [rt, rb, h, e], inside?, wash?) a round seen from above (pot, lampshade, base); inside() draws what stands in it
handle(s, p, x, y0, y1, reach) a cup’s handle on its side
scribble(s, p, r, x, y, w, lines, gap?) wavy lines of text for menus, notes and screens
sprig(s, p, a, b, size) a twig with small solid leaves

A Box gives you points on it: front(u, v) on the front face (u left to right, v bottom to top, 0 to 1) and top(u, v) on the top face (v front to back). Put things on a box with top(u, v).

Seeds and the shape stream

r is the drawing’s shape stream: r.next() (0 to 1), r.between(a, b), r.signed() (−1 to 1), r.chance(p), r.pick(list). Every composition choice comes from r. Take the draws in a fixed order: a draw inside an if shifts every draw after it for the seeds that take the other branch, which is fine, but don’t make the number of draws depend on anything but r and options.

The pen has its own stream for its wobble. Line boil redraws the same composition with another pen, so a drawing’s shape must never depend on the pen (don’t call p.next() for composition).

Registering an object

const T = objectsIn("bakery") // any collection name
define(T("croissant", "Croissant", [140, 90], [20, 78], 1, croissant, [
  "food",
  "breakfast",
]))

T(id, name, size, at, k, draw, tags): the id is what <Drawing object="croissant"> takes; the name is its accessible name; size is the tile [width, height] in px; at and k are where and how large the object draws on its tile. Defining an id again replaces it (handy with hot reload). Ids are global: prefix yours if they could clash with a kit id (listObjects() lists every registered one: the kit collections you imported and your own).

Placing objects inside other drawings

place(s, id, seed, hand, at, k?, options?, take?) draws a registered object into a sheet you are drawing, with its own fork of the seed. You can also call one of your draw functions directly from another (sack(s, p, r, at, k, {})): it shares the caller’s pen and stream, and options is required, so pass {} when there are none. Scenes use it through stage().put (see scenes.md); an object can use it too, to hold another (a person carrying a record).

references/people.mdPeople: clothes, hair, poses and what they hold

The kit’s people are young and dressed for everyday: loose sweaters, hoodies, open overshirts and tees, wide trousers with rolled hems or a short skirt, and trainers. Hair is a pocket of ink. Faces are two dots, a line for a nose and a short mouth. In scenes, people are usually only their clothes, with a collar ring where a head would be (the “visitor”), as on the label the style comes from.

Reuse the kit’s pieces rather than drawing bodies from scratch, so a new figure matches the others.

The pieces

All exported from @/fineliner/pen/objects/people, which also registers the kit’s people (on a kit from before the collections split, from @/fineliner/pen).

clothes(s, p, r, at, k, { pose, top, bottom, stride, nobody }) // → { hands: [left, right], neck }
hairBehind(s, p, headCentre, k, hair) // long hair, a ponytail: draw before clothes()
head(s, p, r, headCentre, k, { hair, glasses, beard }) // face, ears, hair in front: draw after clothes()
HAIR // "bob", "crop", "fringe", "bun", "curls", "long", "ponytail", "waves"
TOPS // "sweater", "hoodie", "overshirt", "tee" (a tee shows bare arms: not for a headless visitor)
  • at is between the feet on the floor; the figure is about 193 units tall at k = 1, and the head’s centre is [at[0], at[1] - 178 * k].
  • Inside clothes() the units run up as negative v: a point is [at[0] + u * k, at[1] + v * k], the neck at v = -163, the waist at -90. Use the same convention for anything you add on the body.
  • pose: "stand", "wave" (right arm up), "mug" (right forearm forward, hand in front of the chest) or "walk" (arms swinging, feet apart). hands[1] is the right hand: put what they hold there.
  • stride spreads the feet; nobody: true leaves out hands and feet (the visitor).

A person holding something

export const parcelCarrier: Draw = (
  s,
  p,
  r,
  at,
  k = 1,
  { hair = r.pick(HAIR) } = {},
) => {
  const c: Point = [at[0], at[1] - 178 * k]
  hairBehind(s, p, c, k, hair)
  const { hands } = clothes(s, p, r, at, k, { pose: "mug", top: r.pick(TOPS) })
  head(s, p, r, c, k, { hair })
  // The parcel goes last, in front of the arm that holds it.
  const o = depth(r)
  box(s, p, o, add(hands[1] as Point, [-8 * k, 8 * k]), 34 * k, 22 * k, 14 * k)
}

For a visitor (only clothes), skip hairBehind and head, pass nobody: true, pick the top from the first three TOPS, and draw the collar ring: s.stroke(p.ring([at[0], at[1] - 162.5 * k], [8 * k, 0], [0, 2.6 * k])).

A pose clothes() doesn’t have

Sitting, kneeling, seen from behind, bending: draw the figure yourself in the same proportions and the same clothes. The kit’s src/fineliner/pen/objects/seated.ts is the worked example (someone at a desk, from behind, on an office chair): the torso as one closed outline, sleeves as closed shapes swung from the shoulder, hair from behind as a filled cap, then the chair drawn over them. Read it before drawing a new pose. Keep:

  • the proportions: head about a sixth of standing height, shoulders about 46 units wide, sleeves about 10 wide;
  • the clothes’ details: a rib band at the hem and cuffs of a sweater or hoodie, a hood lying on the back, an overshirt’s open fronts and collar;
  • hair as a filled shape, never an outline;
  • hands as small rings (p.ring, radius about 3.6 units), only on people with heads.

Avoid

  • Outlined hair, bald heads with glasses, buttoned-up collars, tucked-in waistbands, long skirts and black shoes: together they make everyone look retired.
  • Detailed faces (eyebrows, noses with nostrils, teeth), fingers, muscles. People are as simple as the objects.
  • Mascot energy: big smiles, thumbs up, speech bubbles.
references/scenes.mdScenes: the stage, walls and placing objects in a room

A scene is a room drawn inside the hand-drawn border, composed back to front from objects. The kit’s four (room, studio, bistro, work) are in src/fineliner/pen/objects/rooms.ts (pen/scenes.ts on an older kit); read one before writing your own. A scene places objects by id, so import the collections it uses at the top of your module (import "@/fineliner/pen/objects/indie" for the window, say).

Adding a scene

import { defineScene, stage, type Box, type HandName } from "@/fineliner/pen"

function shop(seed: string, hand: HandName, take: number) {
  const { s, p, r, put, w, h, m, floor, walls, rug, border } = stage(
    seed,
    hand,
    take,
    "shop",
  )
  walls()
  put("window", "window", [r.between(0.24, 0.3) * w, floor - 124], 0.85)
  // …furniture back to front, then what stands on it, then people…
  border()
  return s
}

defineScene("shop", {
  name: "Shop",
  label: "A shop counter with a till, bread and someone buying a parcel", // the accessible name
  size: [640, 440],
  compose: shop,
})

Then <Drawing scene="shop" seed="anything" /> or drawing({ scene: "shop", seed }).

The stage

stage(seed, hand, take, name) returns:

Field What it is
s, p the scene’s sheet and pen, for anything you draw directly (a counter, a doorway, a floor curve)
r the room’s stream: every layout choice comes from it, so every take has the same composition
put(id, key, at, k?, options?) draws a registered object (yours or the kit’s); key names its seed fork, so two speakers differ
w, h, m the sheet’s size (640 × 440) and the border’s margin (14)
corner, floor x where the side wall meets the back wall, y where the back wall meets the floor
walls(), rug(), border() the room’s lines, a sweeping floor curve, and the frame (draw it last)

name goes into the sheet’s mask ids, so two scenes from one seed on a page don’t collide: pass the scene’s own name.

Composing

  • Order is depth. Walls, then what hangs on the back wall (window, picture, shelf), then furniture from back to front, then what stands on it, then people and pets in front, then border(). Each object blanks what was drawn before it, so something drawn later always sits in front.
  • Nearer is lower and larger. Things standing on the floor at the back wall stand at y ≈ floor + 40…80; things near the viewer stand near h - m - 14, scaled up a little. A standing visitor near the front is k ≈ 1.25; a desk at the back wall k = 1.
  • Stack with boxes. Furniture that returns a Box (desk, shelf) gives you its top, so top.top(0.9, 0.6) is the right back of the desk: put a lamp there. Things further back (higher v) go first.
  • Leave air. Most of the wall stays empty. Two or three things on the wall, one piece of furniture with a few things on it, one or two people: that’s a full scene.
  • Seeded variety, same story. Take positions from r.between(…) within a small range, and use r.chance(…) for swaps (a cat or a plant, a monitor or a laptop), never for the scene’s main subject.
  • Check the seams. In the contact sheet, look at every place where two objects meet: legs on the floor, a person in a chair, a lamp on a desk. Nothing should float above what holds it or sink into it.

Also in the skill: scripts/render.mjs, which draws the contact sheet; the worked example above; and three test prompts.