The Stroke panel edits the stroke attributes of the selected path(s).
This document is the requirements description from which
workspace/panels/stroke.yaml is generated.
The Stroke panel is one tab in a tabbed panel group (alongside Properties); the tabbed-group container is specified elsewhere. This document covers only the Stroke tab.
The panel edits stroke attributes on the selected elements: weight,
cap style, join style with miter limit, stroke alignment, dash
pattern, arrowheads with per-end shape and scale, arrow alignment,
and a variable-width profile. All ten controls read from and write to
the flat state.stroke_* surface, which is the single source of
truth; the panel's own panel.* fields mirror state.* for
binding convenience and are re-initialised from state.* each time
the panel opens.
When no path is selected, the panel still binds the defaults — edits become the new-path defaults for future draws. When a path (or paths) is selected, edits apply immediately to the selection. Selection- independent controls (like the profile dropdown) always behave the same.
Several fields are conditionally disabled:
- The miter-limit input is disabled when the join style is round or bevel.
- All six dash / gap inputs are disabled while the Dashed Line checkbox is off. Their values persist and reappear when re-enabled.
- When
state.stroke_brush != null, the brush takes over most of the native stroke pipeline. The cap, join (and miter limit), dash (and the six dash/gap inputs), arrowheads (start/end + scale + link + arrow alignment), and profile (+ flip + reset) controls all disable.WEIGHT_INPUT, the active stroke colour, andALIGN_*_BUTTON(for Calligraphic and Bristle brush types only) remain live since the brush renderer consumes them. Disabled values persist and reappear when the brush is removed via the Brushes panel'sREMOVE_BRUSH_STROKE_BUTTON. Seetranscripts/BRUSHES.md§ Stroke styling interaction for the per-brush-type breakdown.
-
WEIGHT_INPUT—number_input. Stroke weight in points; non-negative, decimals allowed. Committed on Enter or blur. Paired with a literal"pt"unit suffix. -
CAP_BUTT,CAP_ROUND,CAP_SQUARE— three mutually exclusiveicon_buttons for line cap style. Exactly one is checked. Icons arecap_butt,cap_round,cap_square. DefaultCAP_BUTT. -
JOIN_MITER,JOIN_ROUND,JOIN_BEVEL— three mutually exclusiveicon_buttons for line join style. Exactly one is checked. Icons arejoin_miter,join_round,join_bevel. DefaultJOIN_MITER. -
MITER_LIMIT_INPUT—number_input. Miter length / stroke width ratio. When the ratio of a miter join exceeds this value, the join auto-converts to bevel. Range ≥ 1. Disabled whenpanel.joinis notmiter. -
ALIGN_CENTER,ALIGN_INSIDE,ALIGN_OUTSIDE— three mutually exclusiveicon_buttons for stroke alignment relative to the path.ALIGN_INSIDEandALIGN_OUTSIDEbehave identically toALIGN_CENTERon open paths. DefaultALIGN_CENTER. -
DASHED_CHECKBOX—checkboxenabling the dash pattern. -
EVEN_DASH_PRESET—icon_buttonthat turns dashing on and sets pair 1 to dash=12 gap=12, pairs 2/3 null. -
DASH_DOT_PRESET—icon_buttonthat turns dashing on and sets pair 1 to dash=12 gap=6, pair 2 to dash=0 gap=6 (a dot with round caps), pair 3 null. -
DASH_1,GAP_1,DASH_2,GAP_2,DASH_3,GAP_3— sixnumber_inputs arranged as three dash/gap pairs under small literal labels ("dash" / "gap"). Pair 1 defaults to 12 / 12; pairs 2 and 3 default to null (blank, meaning unused). All six are disabled whileDASHED_CHECKBOXis unchecked; values persist. -
START_ARROWHEAD,END_ARROWHEAD— twoselects over the same list of 15 arrowhead shapes (see Arrowhead shapes below). -
SWAP_ARROWHEADS_BUTTON—icon_buttonthat exchanges the start and end arrowhead selections, swapping both the shape and the scale value. -
START_SCALE,END_SCALE— twocombo_boxes for arrowhead scale as a percentage of stroke weight. Presets: 50, 75, 100, 150, 200, 300, 400. Free numeric entry is allowed, minimum 1 %. Default 100 %. -
LINK_SCALES_TOGGLE—icon_button(chain icon). When active, changing eitherSTART_SCALEorEND_SCALEupdates both to the same value. Default off. -
ARROW_TIP_AT_END,ARROW_CENTER_AT_END— two mutually exclusiveicon_buttons for arrow alignment mode (seepanel.arrow_align). DefaultARROW_TIP_AT_END. -
PROFILE_DROPDOWN—selectover six variable-width profile options (see Stroke profile below). Defaultuniform. -
FLIP_PROFILE_BUTTON—icon_buttonthat togglespanel.profile_flipped. Only visually meaningful for asymmetric profiles (taper_start,taper_end). -
RESET_PROFILE_BUTTON—icon_buttonthat restoresprofile = uniformandprofile_flipped = falsein one click.
Strings in quotes are literal labels. Bare identifiers are widget IDs.
panel:
- .row: # weight
- "Weight"
- WEIGHT_INPUT
- "pt"
- .row: # cap style
- "Cap"
- CAP_BUTT
- CAP_ROUND
- CAP_SQUARE
- .row: # join style + miter limit
- "Corner"
- JOIN_MITER
- JOIN_ROUND
- JOIN_BEVEL
- "Limit"
- MITER_LIMIT_INPUT
- .row: # alignment
- "Align Stroke"
- ALIGN_CENTER
- ALIGN_INSIDE
- ALIGN_OUTSIDE
- .row: # dashed line + presets
- DASHED_CHECKBOX
- EVEN_DASH_PRESET
- DASH_DOT_PRESET
- .row: # dash/gap pattern (3 pairs)
- DASH_1 (under "dash")
- GAP_1 (under "gap")
- DASH_2 (under "dash")
- GAP_2 (under "gap")
- DASH_3 (under "dash")
- GAP_3 (under "gap")
- .row: # arrowheads (start / end / swap)
- "Arrowheads"
- START_ARROWHEAD
- END_ARROWHEAD
- SWAP_ARROWHEADS_BUTTON
- .row: # scale (start / end / link)
- "Scale"
- START_SCALE
- END_SCALE
- LINK_SCALES_TOGGLE
- .row: # arrow align
- "Align"
- ARROW_TIP_AT_END
- ARROW_CENTER_AT_END
- .row: # profile (dropdown + flip + reset)
- "Profile"
- PROFILE_DROPDOWN
- FLIP_PROFILE_BUTTON
- RESET_PROFILE_BUTTON- Butt Cap (checkmark if active) — sets
panel.cap = butt. - Round Cap (checkmark if active) — sets
panel.cap = round. - Square Cap (checkmark if active) — sets
panel.cap = square. The three cap items form a radio group mirroringCAP_BUTT/CAP_ROUND/CAP_SQUARE.
- Miter Join (checkmark if active) — sets
panel.join = miter. - Round Join (checkmark if active) — sets
panel.join = round. - Bevel Join (checkmark if active) — sets
panel.join = bevel. The three join items form a radio group mirroringJOIN_MITER/JOIN_ROUND/JOIN_BEVEL.
- Close Stroke — dispatches
close_panelwithparams: { panel: stroke }, hiding the Stroke tab.
The dash pattern is six numeric values arranged as three consecutive
dash/gap pairs. The effective SVG stroke-dasharray is built left to
right from the non-null pairs only: if pairs 2 and 3 are null, only
pair 1 contributes. A zero-length dash produces a dot that is visible
when the cap style is round or square (and invisible when the cap
is butt, which is the source of the dot-dash preset's reliance on
round caps).
DASHED_CHECKBOX acts as the master switch:
- When unchecked,
stroke-dasharrayis omitted from the element and all six dash/gap inputs are disabled. The values are preserved inpanel.dash_1…gap_3so the previous pattern reappears on re-check. - When checked, the six inputs are enabled and the dash pattern is applied.
Pair 1 always has numeric defaults (12 / 12); pairs 2 and 3 default
to null. A blank input in pair 2 or 3 means the pair does not
contribute to the dash array. Pair 1 cannot be blank — clearing
DASH_1 or GAP_1 falls back to its default.
The two preset buttons (EVEN_DASH_PRESET, DASH_DOT_PRESET)
enable dashing and overwrite the dash/gap values in a single click:
| Preset | pair 1 | pair 2 | pair 3 |
|---|---|---|---|
| Even dash | 12 / 12 | null | null |
| Dash-dot | 12 / 6 | 0 / 6 | null |
The 15 shapes (identical option list for both START_ARROWHEAD and
END_ARROWHEAD):
| Shape | Appearance |
|---|---|
none |
no arrowhead (default) |
simple_arrow |
filled triangle |
open_arrow |
unfilled triangle |
closed_arrow |
filled triangle with bar at base |
stealth_arrow |
sharp swept-back chevron, filled |
barbed_arrow |
curved swept-back, filled |
half_arrow_upper |
upper half of filled triangle |
half_arrow_lower |
lower half of filled triangle |
circle |
filled disk |
open_circle |
outline circle |
square |
filled square |
open_square |
outline square |
diamond |
filled rhombus |
open_diamond |
outline rhombus |
slash |
perpendicular line across the path |
The renderer flips shapes so they point outward from each end — the same shape value, selected for start vs end, produces a mirrored rendering as appropriate.
SWAP_ARROWHEADS_BUTTON exchanges both the shape selections and the
scale values between the two ends; it does not toggle
LINK_SCALES_TOGGLE.
Arrow alignment (panel.arrow_align) governs where the arrowhead
sits relative to the path endpoint:
tip_at_end(default) — arrowhead tip is at the endpoint; body extends inward along the path.center_at_end— arrowhead center is at the endpoint; tip extends beyond.
PROFILE_DROPDOWN selects a variable-width profile applied along
the path's length:
| Profile | Shape |
|---|---|
uniform |
constant width (default) |
taper_both |
tapers at both ends |
taper_start |
tapers at the start end |
taper_end |
tapers at the end end |
bulge |
wider in the middle |
pinch |
narrower in the middle |
FLIP_PROFILE_BUTTON toggles panel.profile_flipped, mirroring
the profile along the path. Only asymmetric profiles
(taper_start, taper_end) render differently when flipped.
RESET_PROFILE_BUTTON sets profile = uniform and
profile_flipped = false in a single click.
All panel state mirrors the state.stroke_* surface and is
re-initialised from it on panel open (see init: in the yaml):
| Panel key | Source state key |
|---|---|
panel.weight |
state.stroke_width |
panel.cap |
state.stroke_cap |
panel.join |
state.stroke_join |
panel.miter_limit |
state.stroke_miter_limit |
panel.align_stroke |
state.stroke_align |
panel.dashed |
state.stroke_dashed |
panel.dash_1…gap_3 |
state.stroke_dash_1…gap_3 |
panel.start_arrowhead |
state.stroke_start_arrowhead |
panel.end_arrowhead |
state.stroke_end_arrowhead |
panel.start_arrowhead_scale |
state.stroke_start_arrowhead_scale |
panel.end_arrowhead_scale |
state.stroke_end_arrowhead_scale |
panel.link_arrowhead_scale |
state.stroke_link_arrowhead_scale |
panel.arrow_align |
state.stroke_arrow_align |
panel.profile |
state.stroke_profile |
panel.profile_flipped |
state.stroke_profile_flipped |
Every widget commit fires two effects: a set_panel_state for the
mirror key and a set for the corresponding state.stroke_* key.
This dual-write keeps the panel's immediate visual state and the
document's authoritative state in sync without round-tripping through
a re-init.
Attributes are written onto the selected path element:
| Control | SVG / CSS |
|---|---|
| Weight | stroke-width |
| Cap (butt / round / square) | stroke-linecap |
| Join (miter / round / bevel) | stroke-linejoin |
| Miter limit | stroke-miterlimit (omit when join ≠ miter) |
| Dashed + pattern | stroke-dasharray = the non-null dash/gap pairs, flattened left-to-right; omit when dashed=false |
| Align stroke | paint-order / custom — SVG has no native inside/outside stroke, so inside/outside are approximated via path offset; center = native |
| Start / End arrowhead | custom marker references (marker-start, marker-end) resolving to per-shape markers |
| Arrowhead scale | marker markerWidth / markerHeight scaled against stroke weight |
| Arrow align | determines whether the marker reference uses its tip or center alignment variant |
| Profile | custom attribute (jas:stroke-profile); applied at render time to vary stroke width along the path |
Identity-value rule. When an attribute equals its default
(weight = 1, miter limit = 10, none arrowhead, uniform profile,
etc.), the attribute is omitted from the output rather than
written, so defaults appear as absence.
Shortcuts for Stroke panel actions are defined in
workspace/shortcuts.yaml rather than here.
The panel binds directly to the flat state.stroke_* surface via
the dual-write pattern above: every commit updates both the panel
mirror and the state.* key, and on each subsequent panel open the
mirrors are re-initialised from state.*. This means Stroke panel
writes propagate through each app's apply_stroke_panel_to_selection
pipeline (same shape as Character's apply_character_panel_to_selection).
A Stroke-panel edit names the field the user just committed, and writes only what that field owns. Every other stroke attribute is preserved from the element being edited, per element. An edit to a field that owns no element attribute writes nothing at all — not even an undo step.
This is the law because the panel is not a picture of the selection. Most of its controls display panel state that never syncs from the selected element, so an apply that rebuilt the whole stroke would impose values the user never chose and, in most cases, cannot see. The weight field is the sharp case: it shows the selection's real width, but the panel's other fields sit at their defaults, so rebuilding on any edit reset a selected 5pt dashed arrowheaded line to a plain 1pt line. Picking an arrowhead changed the weight. (JYH, 2026-07-24.)
The display side has its own rule, and it is the mirror image of the
apply rule: a control that shows a RENDERED geometry of the selection
must show the SELECTION's value, not a panel default. Weight and
cap / join do this, and the arrowhead group — start / end shape,
start / end scale, and arrow alignment — joins them. It has to: the head
size is 4 × weight × scale% on the canvas, so a Scale field frozen at
its 100 % default while the selected element carries another value shows
a number the head never had. The head then renders at (say) half, and
committing the field's own displayed 100 % — which the field-scoped
apply writes straight onto the element — jumps the head to full, a
change the panel never showed. Draw a line whose new-element default
scale was 50 %, move the default back to 100 %, reselect: the old
display read 100 while the head was 50 (JYH, 2026-07-25, ARROWSCALE).
The fix is display-only and does not touch the apply: the panel now
mirrors the selection's arrowhead group, and because the field shows the
element's true scale, committing it is a no-op instead of a silent jump.
Syncing the display is safe precisely because the apply stays
field-scoped — a truthful display of one attribute cannot leak the
others onto the element. The link-scale chain button stays panel-state
(it is a UI-only flag, not an element attribute). The read seams are the
per-port live overrides: Rust build_live_panel_overrides
(workspace/dock_panel.rs), Swift strokePanelLiveOverrides
(Sources/Interpreter/StrokePanelSync.swift), reference
sync_stroke_panel_from_selection (workspace_interpreter/effects.py),
each gated by an element→panel test.
The attribute groups — a group is the set of attributes one field owns, and is a single attribute except where that is impossible:
| Panel field | Writes |
|---|---|
weight |
the stroke width, taken from the weight input's committed value. This is the ONLY field that reads a panel-committed width, which is what makes it impossible for any other edit to disturb the element's weight. |
cap |
the line cap |
join |
the line join |
miter_limit |
the miter limit |
align_stroke |
the stroke alignment |
dashed, dash_1, gap_1, dash_2, gap_2, dash_3, gap_3, dash_align_anchors |
the whole dash pattern and the anchor-alignment flag. Necessarily wide: a dash array cannot be written a slot at a time, so any dash-family edit re-derives the pattern from the panel's dash fields (empty when the dashed toggle is off). |
start_arrowhead |
the start arrowhead shape |
end_arrowhead |
the end arrowhead shape |
start_arrowhead_scale |
the start arrowhead scale, and only the start scale |
end_arrowhead_scale |
the end arrowhead scale, and only the end scale |
arrow_align |
the arrowhead alignment |
profile, profile_flipped |
the element's width points only; the stroke itself is untouched. The width points are also re-derived on a weight edit, because the profile scales with the weight. |
link_arrowhead_scale |
nothing. The chain is a UI-only flag; toggling it must not push an undo step that changes nothing. |
The two arrowhead scales are deliberately separate groups. Grouping them looks tempting because the link-scales chain moves them together, but the chain does that by committing the sibling field, which applies through that field's own group. Sharing one group bought nothing and cost an unlinked scale edit stamping the panel's sibling scale over the element's own.
Because the apply is field-scoped, every write into a stroke key must be attributed to the key it actually wrote — including writes made by another field's commit behaviour, like the chain mirror. A write blamed on the wrong field applies the wrong group.
"Preserved" means bit-for-bit, and the colour is the attribute where that bites. A stroke colour carries its colour space (RGB / HSB / CMYK) and its own alpha, so a colour that makes a round trip through 6-char hex comes back demoted to RGB, opaque, and quantised to 8 bits. A panel edit owns no part of the colour, so it must hand the element's colour object straight back — never re-derive it. The reference bridge did exactly that round trip on every apply, which made the reference the one implementation that broke its own preserve-the-rest clause; the corpus now carries a CMYK vector and an alpha-bearing vector so no implementation can silently demote a colour again.
Colour is not part of this. A colour pick changes the colour and
nothing else, through the same preserve-the-rest rule (recolor_stroke /
recolorStroke), and so does the fill/stroke swap (Shift+X, the widget
arrow, the Color-panel button) — it swaps the two colours, and it
sources them from the new-element defaults, never per element. The
defaults are what decides nil (no fill / no stroke swaps too), and
per-element sourcing breaks on the commonest case: a Line holds no fill,
so its "own" fill colour is nothing and the swap would take its stroke
away instead of recolouring it. Each port states this once — Rust
AppState::swap_fill_stroke, Swift Controller.swapFillStrokeColors —
and every call site routes there, so the keyboard path and the widget
path cannot drift apart (they had: Swift's widget arrow sourced the
selection while Shift+X sourced the defaults). Resetting
to defaults (reset_fill_stroke) is the one action that legitimately
replaces the whole set of stroke attributes, and workspace/actions.yaml
enumerates what it resets.
The law is stated executably in the reference interpreter
(workspace_interpreter/stroke_law.py, which holds the field → group
table the ports mirror) and pinned across all three live implementations
by test_fixtures/stroke_apply/panel_edit.json.
Per-app entry points (see the corresponding files for details — the names and locations mirror the Character panel wiring):
- Rust (
jas_dioxus):apply_stroke_panel_to_selectioninsrc/workspace/app_state.rs; widget dispatch via the genericrender_*helpers insrc/interpreter/renderer.rskeyed on the enclosingpanel_kind. - Swift (
JasSwift):applyStrokePanelToSelectioninSources/Interpreter/Effects.swift, subscribed through the notify-panel-state-changed dispatcher. - Reference (
workspace_interpreter):stroke_law.pystates the law;apply_stroke_panel_to_selectionineffects.pyapplies it, withsubscribe_stroke_panelthreading the changed key as the edited field. - OCaml (
jas_ocaml):subscribe_stroke_panelinlib/interpreter/effects.ml. FROZEN atfive-port-parity— it still holds the whole-rebuild law and is not being updated to match. - Python Qt app (
jas): the stroke-panel subscription injas/panels/. FROZEN atfive-port-parity; it reaches the law only through the shared reference helper it calls.
Open follow-ups:
- Inside / outside stroke alignment on closed paths requires a geometric offset at render time; canvases currently approximate it with center alignment.
- Variable-width profile rendering (
taper_*,bulge,pinch) is data-model only for some canvases; the full width-varying renderer is a separate task. - The 15 arrowhead shapes exist as marker references; the full per-shape SVG marker set needs to land before every shape renders on every canvas.
Banked by the STROKEWIDTH council (2026-07-24) — decided, deferred, and written down here so the decision does not live only in a commit message:
- Per-element swap-colour sourcing. The fill/stroke swap sources both colours from the new-element defaults, in every port. Sourcing them per element (so a mixed selection swaps each element's own two colours) is a plausible future feature, but it needs an answer for elements that hold only one of the two — a Line has no fill, and swapping its stroke away to nothing is not what the arrow promises. Revisit with the multi-selection story, not before.
reset_fill_stroke_defaultsstill replaces whole attributes. Reset is the one action that legitimately does;workspace/actions.yamlenumerates exactly what it resets. Left as intended semantics, not an oversight — if it ever grows a "reset colours only" mode, that is a new action, not a change to this one.- Display-vs-apply sync. The panel shows the selection's real weight /
cap / join through a per-render PULL (Rust
build_live_panel_overrides, SwiftstrokePanelLiveOverrides) — but a push-stylesync_stroke_panel_from_selectionalso survives in the reference (writing panel state) and in Swift (writing the flat globals, with no production caller). Rust deleted its copy, which additionally mutated the new-element default — a display path must never do that. Two shapes for one job: unifying on the pull is the intended direction and wants its own pass. - The profile stamp on a multi-selection. A
profile/weightedit re-derives width points for the whole selection from ONE profile width: the committed weight for a weight edit, else the FIRST selected element's width. With mixed widths selected, the later elements get the first element's profile scale. Correct fix is per-element re-derivation; needs a decision on what the panel's profile picker means across a mixed selection. The Character-panel clobber.DONE — CHARPANEL, 2026-07-25. Both applies now take the committededitedfield and write only its attribute group, preserving the rest from the element per element, off a shared corpus (test_fixtures/character_apply/panel_edit.json) and a reference law + bridge (workspace_interpreter/character_law.py,effects.apply_character_panel_to_selection). It carried one lesson back for this list: with sixteen attributes over eighteen fields, three groups turn out to be fed by more than ONE panel field, and a field-scoped apply must read those groups' sibling fields from the ELEMENT — reading them from panel state destroyed the element's attribute (an Underline click erasing aline-through). The Stroke law's own multi-field family, the dash pattern, is safe from that only because every dash / gap slot round-trips through the panel's live pull; if that pull is ever narrowed, the same sibling rule applies. Seetranscripts/CHARACTER.md§The sibling rule.- Next panel in line: PARAGRAPH.
apply_paragraph_panel_to_selection/applyParagraphPanelToSelectionstill rebuild every paragraph attribute from panel state on any write — one match arm away from the Character apply in the same files — and both ports mitigate it with precisely the pattern CHARPANEL condemned: a view-layerparagraphPanelLiveOverridespush in Swift'scommitPanelWrite, async_paragraph_panel_from_selectionin Rust. The fix is the same field-scoped move plus the sibling rule for its own multi-field groups (the seven alignment radios collapsing to onetext_align/text_align_lastpair;bullets/numbered_listsharingjas_list_style). Banked in CHARACTER.md's follow-ups too. - Swift's
icon_buttoneffects: not every write notifies. A YAML button'seffects:reach the apply only through the effects that fire thenotify_panel_state_changedhook —set:(per written key) andset_panel_state:(per key). The others that touch panel state,select:chief among them, write straight throughstore.setPaneland only bump the render version, so a panel-state write made that way lands without applying. No Stroke-panel control uses one today (its buttons all commit keyed values), which is why nothing is broken; the gap is in the generic widget layer and closing it means every panel-state-writing effect naming the field it wrote. - Rust
set_app_state_fieldhas nostroke_dash_align_anchorsarm. A YAMLset: { stroke_dash_align_anchors: ... }reaches neither the panel field nor any apply in the Rust port, while Swift'sstrokeRenderKeysfires it. Same decision surface as the global-set:apply question in this list; fix the two together.