The Character panel allows setting properties of text in the selection.
An example is shown in examples/character.png. This document is the
requirements description from which workspace/panels/character.yaml
will be generated.
The Character panel is one tab in a tabbed panel group (alongside Paragraph and OpenType); the tabbed-group container is specified elsewhere. This document covers only the Character tab.
The panel edits per-character attributes of the text in the current selection. It operates on character ranges: when a range of text is selected for editing, each control shows the value shared by every character in the range, or a blank if the characters disagree. When an entire text element is selected as an object (not in text-editing mode), the panel behaves as if every character in the element is selected.
When the caret is placed in a text element with no range selected, the panel is enabled and writes apply to the next-typed-character attribute state (so the user can set up formatting before typing).
When no text element is selected at all, the panel is fully disabled (all controls greyed).
-
FONT_DROPDOWN—enum_dropdownlisting the installed fonts, with a checkmark next to the current font. Typing filters the list (typeahead). A magnifier-with-caret icon on the left is the visible search affordance. Uses standard virtualized scrolling. When the panel-menu entry Enable in-menu font previews is checked, each entry renders in its own typeface; otherwise in a neutral system font. -
STYLE_DROPDOWN—enum_dropdownlisting the styles available for the current font (e.g. Regular, Italic, Bold, Bold Italic). The selected style name is parsed intofont-weightandfont-styleon commit. -
FONT_SIZE_DROPDOWN—numeric_combo. Unit: pt. Range 1–1296 pt. Presets: 6, 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 60, 72. No Auto. Free numeric input allowed. -
LEADING_DROPDOWN—numeric_combo. Unit: pt. Range 0–1296 pt. Presets as for font size. Auto = 120% of the current font size; displayed in parentheses, e.g.(14.4 pt). The 120% default is per-paragraph overridable via the Justification dialog's AUTO_LEADING_VALUE field (see PARAGRAPH.md §Justification Dialog) — when the wrapping paragraph carriesjas:auto-leadingand Character leading is Auto, that percentage replaces the 120% default for that paragraph. -
KERNING_DROPDOWN—numeric_combowith named modesAuto,Optical,Metrics,0. Free numeric input in 1/1000 em. When a named mode is active, the mode name is displayed; when the value is the default0, it appears in parentheses(0). -
TRACKING_DROPDOWN—numeric_combo. Signed free numeric in 1/1000 em; default 0, shown in parentheses. Presets: -100, -75, -50, -25, -10, 0, 10, 25, 50, 75, 100, 200. -
VERTICAL_SCALE_DROPDOWN—numeric_combo. Unit: %. Range 1–10000%. Default 100%, shown in parentheses. -
HORIZONTAL_SCALE_DROPDOWN—numeric_combo. Unit: %. Range 1–10000%. Default 100%, shown in parentheses. -
BASELINE_SHIFT_DROPDOWN—numeric_combo. Unit: pt. Signed; positive values shift the baseline upward. Default 0, shown in parentheses. -
CHARACTER_ROTATION_DROPDOWN—numeric_combo. Unit: °. Signed; positive values rotate clockwise (matching SVGtransform rotate). Default 0°, shown in parentheses. -
ALL_CAPS_BUTTON—icon_toggle(tri-state). When on, renders the selection in uppercase. Mutually exclusive withSMALL_CAPS_BUTTON. -
SMALL_CAPS_BUTTON—icon_toggle(tri-state). Produces small capitals (uppercase-style glyphs sized to the x-height) for lowercase characters, and regular capitals for uppercase characters. Mutually exclusive withALL_CAPS_BUTTON. -
SUPERSCRIPT_BUTTON—icon_toggle(tri-state). Positions the selected text above the baseline (as in the2in H₂O, or the exponent in E=mc²). Mutually exclusive withSUBSCRIPT_BUTTON. -
SUBSCRIPT_BUTTON—icon_toggle(tri-state). Positions the selected text below the baseline (as in the2in H₂O). Mutually exclusive withSUPERSCRIPT_BUTTON. -
UNDERLINE_BUTTON—icon_toggle(tri-state). Underlines the selected text. -
STRIKETHROUGH_BUTTON—icon_toggle(tri-state). Applies strikethrough to the selected text. -
LANGUAGE_DROPDOWN—enum_dropdownlisting languages by ISO 639-1 codes. Sets the language of the selected text (used for hyphenation and line-breaking). -
ANTI_ALIASING_DROPDOWN—enum_dropdownwith valuesNone,Sharp,Crisp,Strong,Smooth. -
SNAP_TO_GLYPH_INDICATOR— decorative icon, non-interactive. Tooltip: "use glyph-based guides". -
SNAP_TO_GLYPH_INFO_BUTTON—icon_button. When the feature is implemented, clicking will open browser-rendered documentation for Snap to Glyph. Currently permanently disabled and marked unimplemented (mirrors theCreate New Swatch…pattern incolor.yaml). -
SNAP_BASELINE_BUTTON,SNAP_X_HEIGHT_BUTTON,SNAP_GLYPH_BOUNDS_BUTTON,SNAP_PROXIMITY_GUIDES_BUTTON,SNAP_ANGULAR_GUIDES_BUTTON,SNAP_ANCHOR_POINT_BUTTON— six independenticon_togglebuttons, one per Snap to Glyph category. See the Snap to Glyph section. -
TOUCH_TYPE_PANEL_BUTTON— icon+label button that appears at the very top of the panel only while the panel-menu item Touch Type Tool is checked. See the Touch Type section.
All attributes operate on the selected text, which may be a tspan (or a range of characters within a tspan) inside a text element. See the Selection model and editing rules section for the tspan split / merge rule.
Strings in quotes ("font size icon", "Snap to Glyph", etc.) are
literal labels or icon references. Bare identifiers (FONT_DROPDOWN,
etc.) are widget IDs.
panel:
- .row: TOUCH_TYPE_PANEL_BUTTON # visible only when touch_type_enabled
- .row: FONT_DROPDOWN
- .row: STYLE_DROPDOWN
- .row:
- .col-2: "font size icon"
- .col-4: FONT_SIZE_DROPDOWN
- .col-2: "leading icon"
- .col-4: LEADING_DROPDOWN
- .row:
- .col-2: "kerning icon"
- .col-4: KERNING_DROPDOWN
- .col-2: "tracking icon"
- .col-4: TRACKING_DROPDOWN
- .row:
- .col-2: "vertical scale icon"
- .col-4: VERTICAL_SCALE_DROPDOWN
- .col-2: "horizontal scale icon"
- .col-4: HORIZONTAL_SCALE_DROPDOWN
- .row:
- .col-2: "baseline shift icon"
- .col-4: BASELINE_SHIFT_DROPDOWN
- .col-2: "character rotation icon"
- .col-4: CHARACTER_ROTATION_DROPDOWN
- .row:
- .col-2: ALL_CAPS_BUTTON
- .col-2: SMALL_CAPS_BUTTON
- .col-2: SUPERSCRIPT_BUTTON
- .col-2: SUBSCRIPT_BUTTON
- .col-2: UNDERLINE_BUTTON
- .col-2: STRIKETHROUGH_BUTTON
- .row:
- .col-6: LANGUAGE_DROPDOWN
- .col-6: ANTI_ALIASING_DROPDOWN
# Snap to Glyph section — visible only when snap_to_glyph_visible is true
- .row:
- .col-6: "Snap to Glyph"
- .col-2: SNAP_TO_GLYPH_INDICATOR
- .col-1: SNAP_TO_GLYPH_INFO_BUTTON # 3-column right-padding is intentional
- .row:
- .col-2: SNAP_BASELINE_BUTTON
- .col-2: SNAP_X_HEIGHT_BUTTON
- .col-2: SNAP_GLYPH_BOUNDS_BUTTON
- .col-2: SNAP_PROXIMITY_GUIDES_BUTTON
- .col-2: SNAP_ANGULAR_GUIDES_BUTTON
- .col-2: SNAP_ANCHOR_POINT_BUTTON- Show Snap to Glyph Options (checkmark if active) — toggles the visibility of the Snap to Glyph section in the panel (the header row and the six category buttons).
- Show Font Height Options (checkmark if active) — reserved for a future font-height options sub-section; no UI yet.
- Standard Vertical Roman Alignment (checkmark if active) — permanently disabled; will be enabled when the Vertical Type Tool ships. Intended behavior described in the Standard Vertical Roman Alignment section.
- Touch Type Tool (checkmark if active) — toggles
panel.touch_type_enabled. When checked,TOUCH_TYPE_PANEL_BUTTONis visible at the top of the panel; when unchecked the button is hidden. - Enable in-menu font previews (checkmark if active) — when on,
each entry in
FONT_DROPDOWNrenders in its own typeface; when off, entries render in a neutral system font.
- All Caps (checkmark if active) — mirrors
ALL_CAPS_BUTTON; both surfaces write the same shared attribute on the selection. Mutually exclusive with Small Caps. - Small Caps (checkmark if active) — mirrors
SMALL_CAPS_BUTTON. Mutually exclusive with All Caps. - Superscript (checkmark if active) — mirrors
SUPERSCRIPT_BUTTON. Mutually exclusive with Subscript. - Subscript (checkmark if active) — mirrors
SUBSCRIPT_BUTTON. Mutually exclusive with Superscript.
- Fractional Widths (checkmark if active) — when on, text uses varying spaces between characters for better optical flow. Turning it off forces whole-pixel spacing, which can make text look chunky. Default: on.
- No Break (checkmark if active) — applied to the current selection, prevents it from being split across two lines by hyphenation or text wrapping.
- Reset Panel — clears every Character attribute on the current selection back to its default (font, style, size, leading, kerning, tracking, scales, baseline shift, character rotation, caps / sub / super / underline / strike, language, anti-alias, fractional widths, no break). Numeric fields return to their parenthesised default display.
- The panel operates on character ranges within a text element. When an entire text element is selected as an object, the panel behaves as if every character in it is selected.
- For enum and numeric fields, a control shows the single concrete value iff every character in the selection agrees; otherwise the field is blank.
- For icon toggles, a button shows on iff every character has the attribute, off iff none do, and a mixed tri-state indicator otherwise.
- Writing to a blank or mixed field applies the new value to every character in the selection (overwriting variation). Leaving a blank field untouched preserves each character's existing value.
- On write, tspans are split at the selection boundaries so the selection lives in one or more tspans sharing the new attribute set. On commit, adjacent tspans that share identical attribute sets are merged back into a single tspan.
- When the caret is placed in a text element with no range selected, the panel is enabled; writes set the next-typed-character attribute state.
- When no text element is selected at all, the panel is fully disabled.
Numeric fields distinguish explicit values from computed defaults:
when a field shows a computed or default value (no explicit override
on the selection), the value is rendered in parentheses — e.g.
(14.4 pt), (0), (Auto). Editing the field commits an explicit
value and the parens are removed. Resetting the field to the computed
default restores the parenthesised display.
The Touch Type tool provides a special editing mode in which
individual letters can be selected, rotated, moved, or scaled while
the text remains fully editable. An example of the panel with the
tool enabled is examples/touch-type.png; an in-canvas session is
examples/touch-type-session.png.
Two pieces of state govern the tool:
panel.touch_type_enabled— toggled by the panel-menu entry Touch Type Tool. When true,TOUCH_TYPE_PANEL_BUTTONis visible at the top of the Character panel; when false, the button is hidden.state.touch_type_active— whether the tool is the currently selected canvas tool. For now, the only activation path is clickingTOUCH_TYPE_PANEL_BUTTON, which toggles this state on and off. When active, the button is highlighted and the six Snap-to-Glyph category buttons are non-interactive (clicks do nothing; visually dimmed).
When state.touch_type_active is true, the canvas cursor changes so
that individual letters can be selected. A single selected glyph
displays a bounding box with four corner handles, four side-middle
handles, and a rotation handle above it.
Gesture-to-attribute mapping:
| Gesture | Writes to |
|---|---|
| Drag letter body, vertical component | baseline_shift (pt) |
| Drag letter body, horizontal component | per-letter dx offset on the tspan (not surfaced in the Character panel) |
| Drag a corner handle | both vertical_scale and horizontal_scale (%); uniform if Shift held, free otherwise |
| Drag a side-middle handle (left / right) | horizontal_scale only |
| Drag a side-middle handle (top / bottom) | vertical_scale only |
| Drag the rotation handle | character_rotation (°); free, Shift constrains to 15° increments |
Storage rule: each touched glyph is guaranteed to live in its own solo tspan; the parent tspan is split at the glyph boundaries if necessary. Already-solo tspans are unaffected. On commit, if all touch-type transforms on a solo tspan have returned to identity (baseline shift 0, scales 100%, rotation 0°, dx 0), the tspan is merged back into its neighbour (via the same merge rule as the general selection model).
Selection: single-glyph only for the initial implementation. Tap to select one glyph; click on empty canvas or press Esc to deselect.
Undo: each pointer-up is a single undo unit.
Snap to Glyph provides snap targets derived from the geometry of editable text, so objects can be aligned to letter features without converting the text to outlines. When an object is dragged near a text element with at least one category enabled, the application generates temporary guide lines from the glyph geometry. The six categories are:
- Baseline — the invisible line letters sit on.
- x-Height — the top of lowercase letters (as in
x,a,e). - Glyph Bounds — the far left, right, top, or bottom edges of a specific letter.
- Proximity Guides — guides near the Baseline, x-height, and Glyph Bounds, based on the glyph's shape and layout. Position is calculated from each character's maximum width or pixel density.
- Angular Guides — for letters with a slant (such as the side of
a
VorA), objects can be snapped to follow that exact angle. - Anchor Points — the mathematical anchor points on the curves of each glyph outline.
See examples/snap-to-glyph-items.png. A. Baseline, B. x-Height,
C. Glyph Bounds, D. Proximity Guides, E. Angular Guides, F. Anchor
Point.
Category model. The six category buttons are independent on/off toggles; there is no master enable/disable. The feature is active on the canvas iff at least one category button is on. The six buttons are non-interactive while the Touch Type tool is active.
Access paths. The Snap to Glyph section can be shown from any of
the following; all share the same panel.snap_to_glyph_visible
state:
- The panel-menu entry Show Snap to Glyph Options toggles the section's visibility.
- Right-clicking selected text (see
examples/snap-to-glyph.png) displays a context menu containing a Snap to Glyph entry that makes the section visible (equivalent to checking the panel-menu entry). - Programmatic visibility changes from actions.
The Vertical Type Tool, which lays out text top-to-bottom (common in East Asian typography), is not yet implemented. The Standard Vertical Roman Alignment panel-menu entry is therefore permanently disabled until the tool ships.
When implemented, the menu entry will control how Latin (Roman) characters and numbers are oriented in vertical-type text:
- Checked (on): Latin characters are rotated 90° clockwise, so they lie on their side and can be read by tilting the head to the right. This is the conventional handling of Latin text in vertical East Asian layouts.
- Unchecked (off): Latin characters stand upright, stacked one on top of another, like the surrounding vertical-type characters.
Panel-local state (not persisted with the document):
panel.touch_type_enabled— whetherTOUCH_TYPE_PANEL_BUTTONis shown.panel.snap_to_glyph_visible— whether the Snap to Glyph section is shown.panel.show_font_height_options— placeholder (no UI yet).panel.in_menu_font_previews— whether font-dropdown entries render in their own typeface.
Shared state (read by the canvas and other panels):
state.touch_type_active— whether the Touch Type tool is the currently selected canvas tool.state.snap_baseline,state.snap_x_height,state.snap_glyph_bounds,state.snap_proximity_guides,state.snap_angular_guides,state.snap_anchor_point— six independent snap-category flags.
Character attributes (font, size, kerning, tracking, etc.) are not panel state; they are written as SVG/CSS attributes on the selected tspans. See the SVG attribute mapping section.
Character attributes live on tspans (or on the parent text element when an attribute applies uniformly to every character in the element):
| Control | SVG / CSS | Notes |
|---|---|---|
| Font family | font-family |
CSS string |
| Font style (Regular / Italic / Bold / …) | font-style + font-weight |
parsed from the style name |
| Font size | font-size |
stored in pt |
| Leading | line-height |
CSS; Auto = omit (inherits 120% × font-size) |
| Kerning (Auto / Optical / Metrics / 0 / numeric) | font-kerning + letter-spacing + jas:kerning-mode |
named modes stored in the custom attribute |
| Tracking | letter-spacing |
em-based, e.g. 0.025em |
| Vertical / horizontal scale | transform: scale(h, v) on the tspan |
identity = omit |
| Baseline shift | baseline-shift |
pt, signed; + = up |
| Character rotation | rotate attribute on the tspan |
per-glyph degrees, SVG-native |
| All Caps | text-transform: uppercase |
|
| Small Caps | font-variant: small-caps |
|
| Superscript / Subscript | baseline-shift: super / sub |
mutually exclusive |
| Underline / Strikethrough | text-decoration: underline / line-through |
|
| Language | xml:lang |
ISO 639-1 |
| Anti-alias | text-rendering + jas:aa-mode |
named mode in the custom attribute |
| Fractional Widths | jas:fractional-widths |
custom; no CSS equivalent |
| No Break | jas:no-break (or wrap in a tspan with white-space: nowrap) |
custom |
Identity-value rule. When an attribute equals its default
(scale(1,1), rotation 0, baseline shift 0, scale 100%, …), the
attribute is omitted from the output rather than written, so
defaults appear as absence.
Shortcuts for Character panel actions (All Caps, Underline, etc.) are
defined in workspace/shortcuts.yaml rather than here.
A Character-panel edit names the field the user just committed, and writes only what that field owns. Every other character 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. An apply that rebuilt the whole attribute set imposed values the user never chose on every control they did not touch, and over thirty-plus fields there is no such thing as a small clobber: nudging Tracking on a 30pt bold italic underlined Georgia run in French with a 4pt baseline shift, a 40pt leading, Crisp anti-aliasing, 15° rotation, 120/90 glyph scales and Optical kerning reset all sixteen attributes to the panel's 12pt sans-serif defaults. This is the same defect class the Stroke panel had (STROKE.md "The field-scoped apply law", JYH 2026-07-24), found while that one was being fixed and banked in its follow-ups; it is fixed here the same way, off one shared corpus. (CHARPANEL, 2026-07-25.)
The two ports had also drifted, which is the second half of the stone. Swift's panel view pushed its live selection mirror into panel state before the edit landed, so an edit arriving through a panel widget mostly survived; Rust had no such sync, so the same edit clobbered. A view-layer mirror is not a field-scoped apply: it does nothing for the per-range route, nothing for a caller that applies without going through the view, and it makes the two ports disagree about what an apply means. Both ports now implement ONE law with no port-specific preservation semantics — Swift's push is deleted, not merely bypassed, because a mitigation the law does not mention is how the two ports came to disagree in the first place.
Field scoping alone was not enough, because three of the fourteen groups are fed by more than one panel field — DECORATION (Underline + Strikethrough), CASE (All Caps + Small Caps) and BASELINE_SHIFT (Baseline Shift + Superscript + Subscript). For those, "the edit names its field" leaves the group's OTHER fields open, and they still have a say in the attribute being written.
The committed field is read from panel state; every sibling field of
its group is read from THE ELEMENT being edited, per element. Panel
state is not a picture of the selection and nothing in this law obliges
it to have been mirrored, so reading a sibling from it destroys the
element's attribute: with text-decoration: line-through on the element
and the panel's strikethrough flag sitting at its false default,
clicking Underline wrote a bare underline and the strikethrough was
gone. Rust did exactly that. Swift did not, only because of the view
push — the same push whose removal this rule pays for.
Two clauses settle the conflicts:
- Where the committed field and an element-read sibling disagree, the committed field wins: it is the one the user just chose. Committing Small Caps on an uppercase element therefore clears the transform and sets the variant. (This is where the ports had diverged: Swift's mirror made the element's All Caps win and the click no-oped; Rust honoured the click. The click wins.)
- A numeric field at its identity carries no intent.
0is exactly what the Baseline Shift input displays while super / sub is set, so committing0leaves the element's super / sub standing. An explicit non-zero shift replaces it. Without this clause the panel's own display value would silently clear a superscript.
The element side of the rule is three inverses named in the law —
decoration_flags, case_flags, baseline_shift_state (same names in
both ports) — and the panel's DISPLAY mirror reads the same three. One
derivation per attribute, in one place.
The corpus gates this with nine vectors marked STALE-SIBLING
DISCRIMINATOR, each of which the reference arm proves RED against a
frozen copy of the pre-repair law; and with a structural gate that keeps
the defect class from coming back — a vector of a multi-field group may
not name a sibling field in its panel delta at all, so it cannot go
green on a mirrored sibling it was handed. (The first cut of this corpus
did exactly that, justifying it as "the panel arrives with strikethrough
already mirrored from the element" — which only Swift's view mirror
produced. All three arms were green while Rust production lost data.)
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 |
|---|---|
font_family |
font-family |
style_name |
the font-weight + font-style PAIR. Necessarily wide: the Style dropdown's entries (Regular / Italic / Bold / Bold Italic) each name a weight and a style together, and no control moves one without the other. An unrecognised style name writes NEITHER — leaving half the pair guessed is worse than leaving both alone. |
font_size |
font-size only — never line-height. Auto leading is an ABSENT line-height, so preserving the attribute bit-for-bit keeps Auto alive and lets it re-derive against the new size. |
leading |
line-height, empty when the committed leading equals the element's font-size × 1.2 (the Auto value). |
kerning |
the kerning attribute |
tracking |
letter-spacing |
vertical_scale |
the vertical glyph scale, and only the vertical |
horizontal_scale |
the horizontal glyph scale, and only the horizontal |
baseline_shift, superscript, subscript |
the one baseline-shift attribute. Three fields sharing a single-attribute group is not a wide group, but it does invoke §The sibling rule: the two fields the user did not commit come from the element, and the committed one wins any conflict with them (with the identity-number clause for a committed 0). |
character_rotation |
rotate |
all_caps, small_caps |
the text-transform + font-variant PAIR. Necessarily wide: the two toggles are mutually exclusive, and turning All Caps ON has to clear a small-caps font-variant — which a one-attribute write cannot do. |
underline, strikethrough |
the whole text-decoration token list. Necessarily wide for the same reason a dash array is: a CSS token list cannot be written a token at a time, so any decoration edit re-derives the list from both flags (alphabetical, so equality never depends on which toggle the user hit first). |
language |
xml:lang |
anti_aliasing |
the anti-alias mode, empty at the Sharp identity |
snap_to_glyph_visible, the six snap_* category flags, touch_type_enabled, show_font_height_options, in_menu_font_previews |
nothing. These are UI-only state (§Panel state); toggling one must not push an undo step that changes nothing. |
The two glyph scales are deliberately separate groups, for the reason the Stroke law's two arrowhead scales are: they are two independent inputs, so a shared group would let an edit of one stamp the panel's value for the other onto the element.
The Auto test reads the ELEMENT's font size, not the panel's. The
whole-rebuild law compared the committed leading against the panel's
font_size × 1.2, which was harmless only because it rewrote the size
in the same breath. Under the field-scoped law a leading edit must not
consult a font-size field the user did not touch: a 30pt element with
a leading of 36 is at its Auto ratio and goes back to an empty
line-height, whatever the panel's own size field happens to say.
The same ruling retires the Auto-leading post-write hook from the
apply path — the apply no longer needs it, because font_size owning
only the size means an absent line-height survives on its own. The
hook stays, in BOTH active ports, as a display concern: Rust's
character_panel_post_write and Swift's characterPanelPostWrite, each
called on every Character-panel field commit in the same position (field
write → hook → apply).
What it protects is not the element but the panel's stored state,
and only after the SELECTION CLEARS. While text is selected all three
implementations show the element's own leading, so they agree whatever
is stored. With nothing selected there is no element to pull from and
the Leading field reads stored state — and a stored (font_size 24, leading 14.4) pair reads as an EXPLICIT 14.4pt leading under the
LEADING group's own Auto test: an override the user never chose, which
the next text object would inherit.
The reference needs no hook, and the asymmetry is exactly located.
The reference holds the panel's leading as an OPTIONAL and stores
nothing else, so absence is Auto and tracks any size for free
(CHARACTER_PANEL_FIELDS["leading"] is None). Both active ports keep a
CONCRETE number in panel state instead — Rust because its leading is a
plain f64 with no absence to offer, Swift because its panel scope is
initialised from the workspace-declared 14.4 and a commit leaves a
number sitting there. (Swift's law does read leading as an optional,
per the cleared-leading path below; it is the store that is never
empty, and the store is what the no-selection display reads.) A concrete
number that no longer matches the size is a lie about Auto, so both ports
must MATERIALISE font_size × 1.2. The hook is that materialisation —
the same representation-gap compensation as the cleared-leading path —
not a third rule about what an edit means. Pinned as a pair:
font_size_commit_then_deselect_shows_the_bumped_auto_leading /
fontSizeCommitThenDeselectShowsTheBumpedAutoLeading.
Clearing the Leading field is Auto, and the three implementations
reach that one outcome from two different shapes. Swift and the
reference hold the panel's leading as an OPTIONAL, so an absent
leading is Auto directly. Rust holds a plain number and cannot
represent absence, so its nullable-clear path materialises the
ELEMENT's Auto value (its own font-size × 1.2) — which the law then
recognises as Auto and writes as the empty attribute. Materialising
from the element rather than from the panel's size field is what makes
the two shapes agree: with a stale 12pt panel over a 30pt element, the
panel-derived value (14.4) would have been written out as an explicit
14.4pt while the other ports wrote nothing.
The law governs both routes that write the document. The
whole-element route lifts each selected element's own sixteen
attributes, overwrites the edited group, and writes back — per
element, so a mixed selection keeps each element's values, and each
element supplies its own siblings. The per-range route (an active
edit session with a character range) builds the same panel override
template and then clears every field outside the group, so
merge_tspan_overrides leaves the range's other attributes alone;
groups with no tspan-level representation (the glyph scales, kerning)
now return before touching the document rather than splitting the
tspans and pushing an undo step that changes nothing. The caret
route primes the edit session's pending next-typed-character override
and touches no document, so its replace-the-whole-template semantics
are unchanged and remain banked below.
Both tspan routes reach the sibling rule through a second spelling of
it, and deliberately so: a tspan stores these attributes in a different
shape (an optional numeric baseline shift, a token-array decoration), so
its override BUILDERS read panel state rather than a base. The panel
state is therefore normalised once — cp_with_element_siblings in Rust,
characterPanelWithElementSiblings in Swift — before the builder sees
it, and each port carries a test asserting the normalised path returns
exactly what the law returns directly. One rule, two representations,
pinned equal.
The law is stated in the live reference
(workspace_interpreter/character_law.py: CHARACTER_EDIT_GROUPS is
the field → group table, MULTI_FIELD_GROUPS the set the sibling rule
applies to, character_with_field the law) with a platform bridge beside
it (effects.apply_character_panel_to_selection, mirroring the Stroke
law's — the level the Stroke campaign's one reference-only divergence
hid at). It is pinned across all three live implementations by
test_fixtures/character_apply/panel_edit.json, whose expected is a
DELTA so "everything else is preserved" is stated directly. The
reference arm additionally asserts the corpus REJECTS both retired laws
— the whole-rebuild one and the stale-sibling one — so the gate cannot
pass vacuously.
The panel defaults are the workspace's, and all three arms check it by
machine. The value a field falls back to when panel state does not
carry it is not a hand-written constant in any arm: the reference
compares CHARACTER_PANEL_FIELDS against the generated bundle, Swift
compares characterPanelDefaults, and Rust compares
CharacterPanelState::default() — key sets in both directions, then
values. The Rust arm was added last and caught its own arm's first
drift on the spot (a kerning default of "" against the workspace's
"Auto", indistinguishable in the apply because both spell the empty
element attribute, visible in the display as a blank combo). leading
is the one field where the arms differ by design: the two nullable arms
omit it, because absence is the sentinel for Auto; Rust must carry the
declared 14.4, because its f64 has no absence to offer.
-
The sibling apply that is still whole-rebuild.
apply_paragraph_panel_to_selection/applyParagraphPanelToSelectionsit one match arm away from the Character apply in the same files and still rebuild every paragraph attribute from panel state on any write — and both ports mitigate that with exactly the pattern this wave condemned: a view-layerparagraphPanelLiveOverridespush in Swift'scommitPanelWrite, async_paragraph_panel_from_selectionin Rust. Paragraph is the next panel in line; the STROKE → CHARACTER → PARAGRAPH baton is handed over intranscripts/STROKE.md's follow-ups. -
The caret route is not field-scoped. It rebuilds the WHOLE pending next-typed-character template from panel state (diffed against the element) and replaces the session's override wholesale. Its siblings now come from the element like everywhere else, but a click still primes all sixteen attributes rather than the edited group. The fix is to restrict the template to the group and MERGE instead of replacing, so successive clicks accumulate per group; it touches no document either way, which is why it is banked and not rushed.
-
A range write cannot express three groups.
Tspancarries no glyph scales and no kerning mode, so per-range Kerning / Horizontal Scale / Vertical Scale write nothing at all (deliberately: stamping the panel's OTHER attributes instead is what this law forbids). Unblocked by adding those fields to the tspan model. -
Super / sub on a RANGE is a live equivalence gap. The two ports' tspan builders disagree, and did before this wave: Swift encodes super / sub as a baseline offset plus a 0.7× font-size shrink, while Rust writes no baseline shift at all when a toggle is on (
Tspan.baseline_shiftis numeric and cannot hold the keyword). Under field scoping the shrink half of Swift's encoding is now correctly cleared — it belongs to the FONT_SIZE group — so Swift writes an offset with no shrink and Rust writes nothing. Neither is right: the honest fix is a keyword-capable tspan baseline shift, after which both ports write the keyword and the canvas applies the shrink at render time as it does for element-level super / sub. -
The same defect class, one level down: a RANGE's siblings still come from the ELEMENT. This wave fixed "the sibling comes from the element, not from panel state" — but on the two tspan routes the base handed to the normaliser is
character_attrs_of(elem)/CharacterAttrs(element: elem), i.e. the ELEMENT's attributes, not the range's own effective tspan values (app_state.rs:2393andEffects.swift:1621for the range route; the caret route atapp_state.rs:2344/Effects.swift:1586reads the same base). So the destructive case the wave was about survives one level down. Reproduced live, in two clicks a user can actually make: select a range and click Strikethrough (the range's tspan gets["line-through"], the element stays"none"), then click Underline on the same range. The sibling strikethrough is read asfalsefrom the ELEMENT, the group writes a bareunderline, and the range's own decoration is gone —["line-through"]→["underline"], the identical failure the element-level rule was written to stop. Symmetric in both ports (same base, same outcome), so it is not an equivalence break, which is the only reason it is banked rather than fixed here.It is also UNTESTED. Swift's
charRangeDecorationEditKeepsTheElementsStrikethroughreads as coverage but is not: its fixture puts the decoration on the ELEMENT (modelWithTextRange(decoration: "line-through")), which is the case that already works. No arm has a vector where the range's tspan carries a decoration the element lacks.The fix shape is the one follow-up 2 needs as well: build the base from the range's EFFECTIVE values (the tspan's own attribute where it has one, the element's where it does not), and MERGE the group's result into the range rather than replacing the token list. Naming it here rather than leaving it implicit, per the corner-case doctrine — a corner case that destroys user data is not allowed to be silent.
-
buildPanelFullOverridesbuilds a whole tspan to throw most of it away. Swift's per-range route constructs the full sixteen-attribute override tspan (Effects.swift:1172) and then clears everything outside the edited group withrestrictTspanOverrides. Correct, and Rust does the same, but it means every per-range edit pays for deriving fifteen values it will discard — including the super / sub font-size shrink whose clearing is load-bearing (follow-up 4). A group-directed builder would make the restriction structural instead of subtractive, and would remove the class of bug where a newly added attribute is derived but forgotten by the restrict table.
Fully wired in Rust, Swift, OCaml, and Python. Editing a Character-
panel control (Size, Leading, a Caps toggle, Baseline Shift, …)
updates the panel scope in the app's state store and then pushes the
edited field's attribute group onto the selected Text / TextPath
elements via each app's apply_character_panel_to_selection pipeline
(see §The field-scoped apply law — the FROZEN OCaml and Python ports
still carry the pre-CHARPANEL whole-rebuild behaviour, per POLICY.md
§1). The inverse direction — panel widgets reflect the selected
element's current attributes — lands through live overrides built on
each render.
Per-app entry points:
- Reference (
workspace_interpreter):character_law.pystates the field → group table (CHARACTER_EDIT_GROUPS), the group → attribute table (CHARACTER_GROUP_ATTRS), the multi-field groups the sibling rule applies to (MULTI_FIELD_GROUPS), the panel-default fallbacks (CHARACTER_PANEL_FIELDS, machine-checked against the generated workspace bundle) and the law itself (character_with_field). The bridge iseffects.apply_character_panel_to_selection, overeffects.character_panel_state(panel scope only — there is no flat-global spelling to fall back to). - Rust (
jas_dioxus):apply_character_panel_to_selection(edited)insrc/workspace/app_state.rs, over the purecharacter_with_groupCharacterEditGroupin the same file (whose three multi-field variants carry the committed field). Panel-to-widget overrides are built insrc/workspace/dock_panel.rs::build_live_panel_overrides. The widget dispatch refactor (Layer 1 below) lives in the genericrender_select / render_toggle / render_number_input / render_text_inputhelpers insrc/interpreter/renderer.rs, switched by the enclosing panel'spanel_kind; each of those eight commit sites passes the field key it just wrote.
- Swift (
JasSwift):applyCharacterPanelToSelection(edited:)and thenotifyPanelStateChangeddispatcher inSources/Interpreter/Effects.swift; the law (CharacterEditGroup/CharacterAttrs/characterWithGroup/characterAttrsForGroup) and the live overrides both inSources/Interpreter/CharacterPanelSync.swift. Widget write-backs flow throughYamlElementView.commitPanelWriteand the per-panel state scope lives onModel.stateStore.characterPanelLiveOverridesis a PULL only —DockPanelView.buildPanelCtxmerges it into the render scope. It must never be pushed back into the store on a commit (that was the pre-CHARPANEL mitigation, and the reason the two ports disagreed); the same goes forCharacterPanel.flipPanelBool, which drives the four hamburger-menu toggles through the same field-scoped apply. - OCaml (
jas_ocaml):apply_character_panel_to_selectioninlib/interpreter/effects.mlwith theState_store.subscribe_panelhook. GTK widget callbacks inlib/interpreter/yaml_panel_view.mlcommit via_write_back_bind. - Python (
jas):apply_character_panel_to_selectioninjas/panels/character_panel_state.py, subscribed through the store's panel notifier.
All four apps' canvases also honor the 11 character attributes directly when rendering text (see the SVG attribute mapping above).
No remaining polish items on the Character panel itself. Open
follow-ups live with the Tspan sequence (TSPAN.md): multi-value
rotate (per-glyph different angles) requires tspan-per-glyph
splitting at serialization time; the uniform single-value case
(every glyph rotated by the same angle) already renders correctly
on all four canvases.