v0.121.0 — Drawing improvements
Merged 2026-10-11 (2a3a5e1f, tagged v0.121.0) — built on version/0.121.0.
- [x] Release bookkeeping: v0.120.0's notes move to docs/help/changelog/v0.120.0.md, with a line under Released.
Drawing
- [x] Cursors: when a drawing tool is active, the cursor should change to show the mode.
- Shipped:
utils/drawingCursor.tsbuilds a 32px SVG cursor per tool, lines in the stroke colour, interior in the fill colour (at the palette's opacity), under a halo that is light behind a dark stroke and dark behind a light one. No stroke draws the lines dashed white. Pencil and brush draw from their tip; the shape tools, line and wall show a crosshair at the hotspot with the icon beside it, so the icon does not cover where the shape starts. A browser that will not show the image falls back tocrosshair. - Uses Tabler's outline icons with their paths filled, not the filled icons: a filled Tabler icon is one solid shape with no stroke, so it cannot show two colours, and pencil, brush, line and wall have no filled version. A test renders the real
@tabler/icons-reactcomponents and fails if the copied paths drift, or if the toolbar's icon and the cursor's differ. Wall keepsIconWall(its brick outline fills well); line's fill shows only in its two end dots. - Each drawing mode already has a Tabler icon. We will use their SVG, but modify it to make sure the tool has the desired color and an outline that gives it contrast.
- The stroke color of the cursor will match the current stroke color in the Drawing palette, and the fill of the cursor will match the fill. (Use the 'filled' versions of the Tabler icons to ensure they have an interior to fill)
- The cursors may need to be a bit bigger than their action button counterparts to make sure the user can distinguish the fill from the stroke.
- Line has minimal fill area--we'll have to see how it looks in practice. Wall also doesn't seem like a great candidate; considering border-all, layout-bottombar, and similar alternatives.
- Shipped:
- [x] Hit Area: as the cursor moves, show the area that will be painted if the user clicks/taps. The hit area should be the size of what will be drawn, and should reflect the stroke/fill colors selected, but it's translucent until mousedown. If snapping is enabled, the hit area will snap per the snap settings. Basically, the hit area should look exactly like what will happen on mousedown, but for the opacity.
- Shipped:
DrawingHitAreaLayerdraws, at 40% of the palette's opacity, the drawing a mousedown at the cursor would begin —utils/drawingStart.ts'sdrawingStartFor, whichstartDrawingnow calls too, so the two cannot snap differently. The brush shows its whole nib (in square mode, the squares it would paint); every other tool starts at a point, so it shows a stroke-width dot in the stroke colour where the drawing will begin. Hidden while a drawing is in progress (its preview is what the next click changes), with the cursor off the map, and in the modes whose click runs first (palette Delete and Measure, grid adjust, fog and VBL editing). - The drawing in progress is now drawn at the palette's own opacity (it was 70% of it), so the step from hit area to drawing is visible, and what is on screen while dragging is what gets saved.
- Shipped:
- [x] Contextual Hints: near the cursor, displace enough to not obscure the hit area, a tooltip will show the most important contextual information--namely, how to finalize or cancel the drawing (right click, Escape, etc; whatever is true for that tool).
- Shipped:
DrawingHintfollows the pointer over the map canvas. Before a drawing it says how to start ("Press and drag to draw", "Click a grid corner to start"); during one, how to end it — the drag tools "Release to finish · Esc or right-click to cancel", line and wall "Click to add a point/corner · Right-click to finish · Esc to cancel". It sits past the hit area (or the cursor image, whichever is bigger) and, mid-drawing, on the side away from where the shape grows from. A test pins each claim to the handler that makes it true. - The wall banner offered "Double-click or right-click to finish", but nothing handles a double-click; it now says right-click, as the hint does. (The toolbar's "hold Shift for ellipse/square" labels are the same kind of claim, also unhandled; left for its own fix.)
- Shipped:
- [x] Stroke Size: this section will enlarge somewhat, and offer more options:
- [x] Holding shift while pressing + and - move the number by 10.
- [x] Holding ctrl while pressing + and - move the number by 100.
- ⌘ counts as Ctrl. On a Mac a Ctrl-click is the context menu, so the buttons read it there (and drop a click that follows it). Until the next item lifts the 20px cap, a 100 step lands on the cap.
- [x] Max stroke size 1000px. Min 0 (same as transparent stroke).
- The store's setter and the palette share one clamp (
utils/strokeWidth.ts); both capped at 20 before. The server never capped it.
- The store's setter and the palette share one clamp (
- [x] Stroke size is an editable text area. Accept numbers without a unit (interpret as px), also accept \d+px.
- The field is the shared
AsyncTextInput(style guide 2.2.0), so it behaves like every other edit-then-commit field: Enter or leaving it commits, Escape reverts, and Commit / Revert show while an edit is outstanding. It also takes decimals (0.5) andnone(0), which it shows for 0; an out-of-range number lands on the nearest end; text it cannot read stays in the field with "Type a width in px, like 12 or 12px".pxsits after the field, the −/+ buttons either side.
- The field is the shared
- [x] When cursor is inside the stroke size text input, up/down arrow keys increment the size (and the shift key makes them move it by 10px)
- Ctrl/⌘ moves it by 100, as on the buttons. Each step commits at once, from whatever is typed.
AsyncTextInputgained astepDraftoption for this: the shared field, not a second one.
- Ctrl/⌘ moves it by 100, as on the buttons. Each step commits at once, from whatever is typed.
- [x] Below the +/- and text input is a small slider that also sets stroke width. It should be logarithmic with pips at 10 and 100, and smaller pips between (1-9, then 20-90, then 200-900).
- 1px to 1000px over three decades, in whole pixels; 0 (no stroke) sits at the left end, and only the field and − reach it. 10 and 100 get a taller tick and a number. Dragging redraws a selected drawing as it goes and saves it once, when the drag ends.
- [x] Grid Units: there is a toggle to switch betwen pixels and grid units. This doesn't change the current stroke size; it calculates the converted value given the current stroke size and current grid size. If the grid size is 100, and the stroke is 10px, then it will be 0.1 grid units. Min grid units 0.01, max 10 (same logarithmic range as px).
- A px | grid toggle after the field, which is also its unit label. The width is always stored in pixels; the toggle re-reads it (the grid size is the map's). In grid units a bare number is squares and
12pxis still pixels (0.5guworks in px mode too); the steps are 0.01, 0.1 and 1 square; the slider runs 0.01 to 10 with 0.1 and 1 as its major pips. The unit is kept for the session, like the snap toggles. - Decided: each unit keeps its own range. px runs to 1000px; grid runs 0.01 to 10 squares whatever the grid's size, past 1000px on a grid over 100px (10 squares of 140px is 1400px). Every control and the store's setter clamp to the unit in use, and switching unit pulls the width into the new range: 1000px on a 1px grid becomes 10 squares (10px); 1400px switched to px becomes 1000px. The switch changes the palette's width only, not a selected drawing's. (It was provisionally the other way: the 1000px cap won, so the grid slider's top pinned at 1000px.)
- A px | grid toggle after the field, which is also its unit label. The width is always stored in pixels; the toggle re-reads it (the grid size is the map's). In grid units a bare number is squares and
- [x] Stroke/Fill Colors:
- [x] Add an x button (with a circular background for contrast) to fill and stroke selectors, when their respective color is not "none selected", to quickly set "no fill" or "no stroke".
- A dark disc with a white ×, on the chip's top-right corner. Fill shows it for a colour or a texture and clears both, as No Fill does.
- [x] Stroke color should render as a rectangle with a fairly thick border, the word 'stroke' inside, and the stroke color of the rectangle reflecting the chosen stroke color. When there is no stroke color, change the rectangle's stroke to a dotted line of plain white, and the text to 'no stroke'.
StrokeChip: 72×26, a 4px border in the stroke colour. Both chips are drawn on a dark swatch well (#34343a), so the white "none" looks read on the light skin's white palette too, as they would over a map.
- [x] Fill color shoudl render as a solid rectangle with the word 'fill' inside, the rectangle's fill matching the current fill color. When there is no fill color, change the rectangle to a 0.25 opacity white, and the label to 'no fill'.
FillChip, the stroke chip's size: the fill colour (or texture) with "fill" in dark or light ink, whichever reads on it; no fill is 25% white over the well.
- [x] Stack stroke and fill vertically, making them the same size. They should fit in the same space as currently, but the whole palette may need to get taller to accommodate them if they're too small at current size.
- Stroke over fill, both 72×26, 8px apart (room for the lower chip's ×). Together they are about as tall as the stroke width control beside them, so the strip's height is set by that, not by the chips. Both chips get a faint dark edge, so white reads on the light skin.
- [x] Add an x button (with a circular background for contrast) to fill and stroke selectors, when their respective color is not "none selected", to quickly set "no fill" or "no stroke".
- [x] Multi-select: when the Select tool is active, click-dragging on the map creates a selection rectangle. On mouseup, every drawing within the rectangle becomes selected.
- Shipped: a press on empty map with the Select tool starts the map's selection rectangle in a drawings-only mode (no tokens). "Within" is the rectangle the map already had: a drawing it touches on the current layer counts, not only one wholly inside. The first selected is the primary (handles, text editor, its style in the palette);
drawingStore.selectDrawings/selectedDrawingIds. Clicking a drawing outside the set, deselecting, or closing the palette ends the multi-selection. - [x] The existing Delete button will delete all selected shapes, as will the backspace or delete key; both have a confirmation prompt.
- One
ConfirmDestructive(DrawingDeleteConfirm, mounted by the map) asks for all three ways in: the toolbar's Delete, Delete / Backspace on the map, and Delete / Backspace in an empty text editor, which used to delete at once. Cancel keeps the selection. The red × that floats on a selected shape still deletes just that shape, as before, without asking. A text save still pending when the delete was asked for now lands if it is cancelled, and is dropped if it is not (it used to be dropped either way).
- One
- [x] Changing stroke/fill settings, typing text, changing text properties, and changing opacity all affect all selected drawings. Whenever any property is changed, all drawings receive that property (e.g. stroke width 15px, or fill red), regardless of what their previous property values were. Only the changed property syncs, so if you highlight a bunch of different shapes, then change fill to red, they'll all have red fill, but none of their other values will change at that point.
- Every palette write goes through one
applyToSelection(patch)that writes only the patch's keys to each selected drawing. Text style is merged over each drawing's own text; typing writes the words to all of them, each keeping its own text style. A relative change (font size −/+, stroke width −/+) is worked out from the primary and then set on all, so they end up equal. Metadata stays per-drawing.
- Every palette write goes through one
- Shipped: a press on empty map with the Select tool starts the map's selection rectangle in a drawings-only mode (no tokens). "Within" is the rectangle the map already had: a drawing it touches on the current layer counts, not only one wholly inside. The first selected is the primary (handles, text editor, its style in the palette);
- [x] Brush/Pencil Shapes
- [x] Brush and Pencil will now have a sub-palette active while these modes are active, with Brush Shape options.
NibPalette: a tray hanging under the palette while the pencil or brush is active, with the three shapes drawn as their buttons. The pencil and the brush keep a nib each. A drawing records its nib in its geometry (absent = circle, so every older drawing reads as it did); the server's geometry schema declares it, since zod drops what it does not name. The brush's radius stays in the main palette.
- [x] Brush Shapes available: square, circle, calligraphy (something like a thin lemon in the orientation of a forward slash)
utils/nibGeometry.ts: a stroke is the nib swept along its path — the union (polygon-clipping) of the hull of the nib at each pair of consecutive points, holes kept. Square is axis-aligned; calligraphy is a lens 22% as thick as it is long, at 45°, so a stroke along "\" is broad and along "/" is a hairline. For the brush the nib is the radius-sized area; for the pencil it is the line's cross-section, stroke-wide (filled in the stroke colour). The round nib keeps its old outline and line, so existing drawings are unchanged. The pencil's hit area shows its nib. Hit tests and square mode still treat a nibbed brush as round (a square's corners reach a little past it).- Playtest fix: the round pencil drew spikes (a plus, sometimes an 8-way compass rose) along a wide stroke. It was still the old thick line, one flat-ended quad per segment — fine at the old 20px cap, but at hundreds of pixels each short mouse step became a slab pointing along that step. A pencil line is now its nib swept along the path for every nib; the round one uses the round brush's outline.
- Playtest fix: a thin round pencil line now and then grew a solid wedge ("mini-fills"). The round outline is one ring down the path's left side and back up its right, so it crosses itself wherever the path does and folds on the inside of a sharp turn; the map's triangulator assumes a simple ring and bridged those crossings. polygon-clipping now resolves the ring (non-zero rule) into a simple outer boundary and a hole inside each loop, for round pencils and round brushes alike. A few ms for a typical stroke; a test checks the result never crosses itself at sharp turns or loops.
- [x] Brush tool has a softness slider; pencil is always 100% hard. At 100% soft, only the central point of each brush shape is 100% opaque, and at the edge of the shape, it's 0% opaque.
- A Softness slider (0–100%) in the brush's tray. Each stamp is solid to (1 − softness) of the way to the nib's edge, measured in the nib's own shape, then fades linearly to nothing; stamps combine by max, so a stroke that crosses itself is no darker there.
utils/softBrushMask.tsbuilds that as an alpha mask (one texel per world unit, up to 1024 across) drawn as one textured quad. As decided, a soft brush has no outline. It paints in its fill colour, or its stroke colour when it has no fill; a texture fill does not soften. Square mode stays hard. The preview and the hit area are soft too. - Playtest fix: 0% drew like the brush before softness existed — outlined in the stroke colour and filled in the fill colour, so with no fill just an outline — while every other softness painted. 0% was never saved: a stroke at 0% looked exactly like an old one. Every brush stroke now records its softness, 0 included, and 0% paints like the rest with a crisp edge: the fill colour (a texture fill paints its texture), or the stroke colour with no fill, no outline (
utils/brushPaint.ts). The preview and hit area match. Brush strokes saved before softness keep their old look. - The server's line/pencil geometry schema is now strict: a brush geometry the brush schema refused used to fall through to it and save as a pencil's, radius stripped.
- A Softness slider (0–100%) in the brush's tray. Each stamp is solid to (1 − softness) of the way to the nib's edge, measured in the nib's own shape, then fades linearly to nothing; stamps combine by max, so a stroke that crosses itself is no darker there.
- [x] Brush and Pencil will now have a sub-palette active while these modes are active, with Brush Shape options.
- [x] When both the 'from' and 'to' snap settings are visible, stack them vertically (from on top)
- One column for the shape tools (circle, cone, rectangle), with a shared label width so the two rows' buttons line up. The other tools show only the one row, unlabelled, as before.
- [x] Make the tools 2 rows of 4 tools, not one long line
- A 4-column grid. The narrow-screen check that stacks the palette's two halves measures its first row (
naturalGridWidth) rather than all eight buttons.
- A 4-column grid. The narrow-screen check that stacks the palette's two halves measures its first row (
Misc
- [x] The drawing toolbar's Finish Line button threw the line away: it called the store's
completeDrawing, which builds the drawing and clears it without saving it. It now saves exactly as a right-click on the map does, throughutils/finishDrawing.ts, which every way of finishing a drawing now shares (release, right-click, touch lift, the button). The button now shows for walls too ("Finish Wall"), since right-click finishes both and touch has no right-click. A test fails if anything outside the store callscompleteDrawing()on its own. - [x] The drawing tools' "hold Shift" labels promised what nothing did. Rectangle's "hold Shift for square" is now true: Shift keeps the rectangle square, its far corner going out to the longer side, and pressing or releasing Shift mid-drag changes it without waiting for the mouse to move. Circle's "hold Shift for ellipse" is gone (the label now says "drag out from the middle"): there is no ellipse drawing to make, and the circle type's unused ellipse fields went with it. A test pins each gesture a tool label names to the handler that does it.
- [x] Two more tool labels the batch outgrew: Brush said it "paints the area swept by a round nib" (it has square and calligraphy nibs now, and softness), and Select said only "click to select drawings" (a dragged box selects several). They now read "Brush (paints along the path; nib and softness in the tray below)" and "Select (click a drawing, or drag a box around several)", and the label guard pins both claims, and that no label names one nib.
- [x] CLAUDE.md separates finishing work from releasing it. Agents never merge into or push
main; the owner squash-merges a PR, unless they explicitly ask an agent for one specific merge. Every other branch may be pushed. Programs are new: onerelease/<feature>branch per major feature, built frommilestone/**branches that the program's orchestrator squash-merges and tags (<feature>-m<n>). Release branches keep main's version and keep their notes indocs/help/changelog/<feature>.mduntil the feature ships. The ruleset and push hook that will enforce this are not in place yet.