Ydalis-SignStyler icon

SignStyler

Sign editor: formatting toolbar, color picker and live preview.

Last updated 16 hours ago
Total downloads 42
Total rating 1 
Categories Mods Tools Client-side Utility AI Generated
Dependency string Ydalis-SignStyler-1.0.0
Dependants 0 other packages depend on this package

This mod requires the following mods to function

denikson-BepInExPack_Valheim-5.4.2351 icon
denikson-BepInExPack_Valheim

BepInEx pack for Valheim. Preconfigured with the correct entry point for mods and preferred defaults for the community.

Preferred version: 5.4.2351

README

Sign Styler — a Valheim mod (BepInEx 5)

A rebuilt sign-writing interface that makes formatting usable.

What it fixes

Vanilla problem What the mod does
Since patch 0.217.22 the input field renders rich text as you type: <color=red> vanishes the moment you type the >, and a <size=5> shrinks what you typed past legibility — often leaving Ctrl+A and start over as the only way out Rendering is disabled inside the field, so tags stay visible and editable
50 characters max, tags included — <color=red>Hello</color> <size=100>there</size> already fills the budget for 11 visible characters Limit raised to 400 by default, configurable up to 2000
No visual feedback before committing Live rendered preview under the field, plus a visible/total character counter
Tags typed by hand Toolbar: character styles that report what is active under the caret, a direct entry per color target backed by an HSV picker with palette and history, and sliders for every numeric tag
Icons only reachable by pasting a character from a web page, with no way to know which ones the font has An icon board listing every glyph the sign's font can actually draw

Keyboard shortcuts while typing: Ctrl+B, Ctrl+I, Ctrl+U.

Scope

The mod adds nothing to the game. Valheim's signs already accept TextMeshPro rich text and that system works fine; what it lacks is a usable way to write it. So: drive the existing engine, raise the limit that made styling impractical, put an editor in front of it.

Out of scope, deliberately: dynamic content of any kind (counters, clock, player stats, chest contents, placeholders resolved at display time), new tags or effects such as glow or animated rainbow, new prefabs, sign sizes, recipes or fonts, and any custom save data. A sign shows the text you typed, stored in its normal text field.

That restraint is what buys the rest — see the compatibility section below.

The color picker

The toolbar carries one row per color target — Text colour and Highlight — each showing the color that target currently holds and opening the picker already pointed at it. There is no generic color button any more: the choice between the two used to be made after opening the picker, at the bottom of the panel, which is the wrong end of the interaction.

The picker itself is a panel to the right of the sign dialog:

  • a saturation/value square and a hue strip, textures generated at runtime in 64×64 — no asset bundle to ship
  • a two-way bound hex field, with Copy and Paste against the system clipboard
  • an alpha slider with a percentage readout
  • a palette of saved swatches: + save stores the working color, right-click on a chip removes it
  • a recent row, the session history, fed by every apply
  • an apply button that states what it is about to do — Color selection / Color word at caret, or Highlight … on the other target — because with nothing selected the mod applies the tag to the word under the caret rather than refusing
  • Insert empty tag at caret, which writes the tag pair and parks the caret between the halves, for coloring text you have not typed yet

Clicking a swatch loads the color into the picker, it does not apply it — otherwise every color you looked at was a color you had already written into the sign.

The two entries are read back from the sign's own text every time a panel opens: the last <color> and the last <mark> in the string, falling back to opaque black and a fully transparent highlight on a sign that carries neither. So they describe the sign in front of you rather than the one you edited before it. The palette and the recent row are the opposite — deliberately shared for the whole session, since carrying a color from one sign to the next is the point of having them.

What gets written:

  • text target → <color=#RRGGBB>, or #RRGGBBAA when alpha is below 1
  • highlight target → <mark=#RRGGBB(AA)>, at whatever alpha the slider says. An opaque <mark> used to be forced down to 0.4 on the assumption it would hide the text; on a sign it does not — the board draws the highlight behind the glyphs.

If the selection already sits inside a <color> tag, picking a new one rewrites that value instead of nesting a second pair.

The hex field is a clone of the vanilla input field, so it inherits the norse font and skin. Its listeners are cleared on creation so typing in it never submits the panel.

Character styles

B, I, U, S and no parse light up when the style is in force where the caret sits. That state is read back from the tags in the string on every frame the text or the caret moved, not tracked from clicks — the field lets you edit the tags by hand, and a set of switches that only knew about its own clicks would start lying the moment you did.

B lighting up does not mean the sign will look bold: see the tag table below for what the board actually honours.

The icon board

The icons players put on signs — crossed swords over an armoury, a hammer over a workbench — are ordinary glyphs of the sign's own font, one character each. The icons button opens a grid of them to the left of the panel; clicking one inserts it at the caret.

The grid is read from the font asset at runtime, never from a list in the source. The board walks the sign's TMP_FontAsset, its fallback chain and TextMeshPro's project-wide fallbacks, and keeps the codepoints a keyboard cannot type: symbols, arrows, geometric shapes, dingbats, and the private use area, which is where a game keeps its own icon set. Whole scripts are excluded by range — a CJK fallback would bury the icons under thousands of ideographs. Two consequences worth having: a game update that adds or drops an icon shows up without a code change, and a glyph on the grid is by construction one the sign can draw, so nothing comes out as an empty box.

Search and categories. The font decides how many glyphs there are, and on a rich one that is hundreds; paging through them to find a skull is not a way to find a skull. The search box filters the grid as you type, matching Unicode's own character names — sword finds ⚔, skull finds ☠ — with every term required, so crossed sword finds crossed swords and black star does not drag in every star. Typing a codepoint works too, bare or with the U+ you copied along with it. The category button cycles through the Unicode blocks this font turned out to hold, built from the discovered glyphs rather than from a fixed taxonomy, so it can never land on an empty page. The private use area is offered as Game icons, which is the only label true of it.

The names come from an embedded table generated from UnicodeData.txt and the CLDR short names — 5675 entries, shipped as text in the assembly rather than as a C# literal so it stays diffable and costs its bytes once. It labels glyphs; it never decides which ones exist. A glyph Unicode does not name — the game's own icons, which Unicode leaves unnamed by design — still appears on the grid and is still reachable by codepoint. Regenerate it against a newer Unicode release with python tools/gen-glyph-names.py, which downloads from unicode.org and rewrites data/glyph-names.txt; nothing is downloaded at build time and nothing at runtime.

The caption line under the grid names the last icon inserted and gives its codepoint, which is what you paste into a bug report or type on a machine where the mod is not installed.

No emoji on the grid? Then the sign's font has none, and the board is telling you the truth rather than offering buttons that write empty boxes. The BepInEx console reports the count on every open — Icon board: 412 glyphs in Valheim-AveriaSerifLibre, 9 Unicode blocks. Emoji reach a sign only through a font that can draw them; this mod never ships one, and a <sprite> tag would need an asset bundle.

An icon costs one character against the limit, the same as a letter — unlike a <sprite> tag, which is why the board inserts characters and not tags.

Numeric tags and offsets

Each numeric tag has a button that reveals a row holding the tag's name, an editable value, a slider and a remove button. The key behaviour: rather than stacking a new <voffset> on every click, the mod finds the tag already present before the caret, binds to its value and rewrites that fragment in place while you drag. The preview follows in real time — which is what makes offsets usable, because those values are found by eye, not by typing.

The field is there for the values that are not. A size or a rotation is often a number you already know, and dragging to exactly 150% on a 20 → 400 rail is a game of patience. Type it and press Enter; the slider follows. A decimal comma is accepted, a value outside the tag's range is clamped to it rather than written through, and anything unparseable restores the bound value instead of guessing.

size takes two forms, and they do not mean the same thing. <size=150%> scales whatever size the text already has; <size=500> sets a fixed point size, which is what turns the board's auto-scaling off — the trick behind text floating over a sign. The row follows what you type: a bare number asks for points, a number with % asks for a percentage, and the slider's range switches with it (20 → 400 for percent, 1 → 800 for points). Clicking the size button on a sign that already carries one binds to the form that is written there, so an existing <size=500> is not quietly rewritten as 400%. A fresh tag still starts at 150%.

Every other tag has a single unit, so on those the suffix is optional on input: 0.5 and 0.5em both read as 0.5.

Tag Default range Unit
size 20 → 400, or 1 → 800 %, or none for a fixed point size
voffset -3 → 3 em
line-height 30 → 200 %
cspace -0.3 → 1 em
rotate -90 → 90 degrees
alpha 0 → 255 hex #XX

Ranges live in TagSpec.All (src/NumericTags.cs), one line per tag — adding another one (mspace, margin, indent, width, pos) is a one-liner.

The remove button deletes the bound opening tag and its closing counterpart. Binding picks the last tag of that name before the caret whose scope is still open: with two nested <voffset> on the same line it grabs the nearest one to the left, which may not be the one you meant.

What to know about offsets

Sign text is auto-centered and auto-scaled. A voffset on its own barely moves anything, because the engine reframes around it. The combination that works is a fixed size — which disables scaling — plus an offset or line breaks. That is the trick behind text floating above a sign.

voffset also adjusts line height to accommodate the shift. For a clean move without stray spacing, compensate with line-height. Likewise rotate disturbs character spacing and can make glyphs overlap: cspace is what pulls that back.

Installation

  1. Install BepInEx for Valheim (the denikson pack, currently 5.4.23.x).
  2. Drop SignStyler.dll into Valheim/BepInEx/plugins/SignStyler/.
  3. Launch once — the config file appears at BepInEx/config/fr.anthony.signstyler.cfg.

Client-side only. Sign text lives in the sign's ZDO and is rendered by TextMeshPro, so other players see the styled result without installing anything. Only the author of the sign needs the mod.

Building

What you need

  • .NET SDK 8 or later. On Windows: winget install Microsoft.DotNet.SDK.8. That is the only install required — the project pulls Microsoft.NETFramework.ReferenceAssemblies from NuGet, so there is no need for the .NET Framework Developer Pack or a Visual Studio workload.
  • Valheim installed, for the game DLLs under valheim_Data/Managed.
  • BepInEx installed at least once, for BepInEx.dll and 0Harmony.dll. With a manual install these sit under <game>/BepInEx/core; with Thunderstore Mod Manager, r2modman or Gale they live inside the manager's profile folder instead. The project probes the game folder and the Default profile of all three, and says which one it picked.

No IDE is required. VS Code with the C# extension, or Rider, both handle the project fine.

Build

dotnet build -c Release -p:ValheimDir="C:\Program Files (x86)\Steam\steamapps\common\Valheim"

To stop passing the path every time, copy Directory.Build.props.example to Directory.Build.props and set your own paths there. MSBuild picks it up automatically, and the file is gitignored — do not hardcode personal paths in the csproj, they would be committed to a public repository along with your user name. The first build restores one NuGet package; everything else is referenced straight from your own install, so there is nothing else to download.

When BepInEx comes from a mod manager, pass the profile folder and the project derives core and plugins from it:

dotnet build -c Release -p:ValheimDir="E:\SteamLibrary\steamapps\common\Valheim" -p:ThunderstoreDir="$env:APPDATA\Thunderstore Mod Manager\DataFolder\Valheim\profiles\Default"

To locate the profile: Get-ChildItem "$env:APPDATA\Thunderstore Mod Manager" -Recurse -Filter BepInEx.dll -EA SilentlyContinue | Select-Object -First 3 FullName

-p:BepInExCore="..." still exists as a last resort for an unusual layout.

Hot reload

With ScriptEngine installed, build with -p:HotReload=true and the DLL lands in BepInEx/scripts instead. Press F6 in game to reload it without restarting — the plugin unpatches Harmony and destroys its UI in OnDestroy, which is what makes that safe.

Close and reopen the sign panel after a reload: the toolbar is rebuilt on the next RequestText, not on the reload itself. And keep only one copy of the DLL around — one in plugins and one in scripts means the plugin loads twice.

Deployment

The DeployToValheim target copies the built DLL into the plugins folder sitting next to whichever BepInEx was found — the manager's profile, not the game folder, when you use one. A rebuild is enough, then restart the game.

No assembly publicizer is needed: private members such as TextInput.m_panel are reached through AccessTools.

The game DLLs referenced by the csproj are the usual Unity and TextMeshPro modules, plus assembly_valheim and assembly_utils. One is there for a single line and looks out of place otherwise: UnityEngine.IMGUIModule, for GUIUtility.systemCopyBuffer behind the picker's copy and paste buttons. Nothing in the mod draws IMGUI, and the clipboard is exposed nowhere else.

Configuration

One setting, in BepInEx/config/fr.anthony.signstyler.cfg:

General / CharacterLimit — 400 by default, bounded 50 to 2000. It is read again every time a sign is opened, so a change made through Configuration Manager takes effect without restarting the game.

Everything else drives the game's text engine and is not up for negotiation. Those values are grouped in src/Settings.cs, each read from exactly one place in the code.

Lowering the limit below the length of existing text does not truncate it: the sign keeps its content, but the toolbar refuses any edit that would exceed the new bound.

Tag cheat sheet

<b> <i> <u> <s>          bold / italic / underline / strikethrough
                         note: <b> has no visible effect on a sign — the Norse font
                         ships no bold face, and the fallback weight is null
<color=#FF0>             short hex form, cheapest in characters
<size=150%> <size=24>    percentage of the current size, or a fixed point size — the
                         fixed form is what switches the board's auto-scaling off
<voffset=1em>            vertical shift, enables text floating off the sign
<line-height=80%>        compensates the shift voffset introduces
<cspace=0.2em>           character spacing
<rotate=15>              rotation in degrees, counter-clockwise when positive
<alpha=#80>              opacity, no closing tag
<align=left>             alignment
\n                       line break (2 characters, cheaper than <br>)

Quotes around tag values can be omitted, and so can closing tags — technically invalid, but tolerated, and it saves characters on a tight budget.

Emphasis that actually shows on a board: size, color and character spacing. <b> is a valid tag the font cannot honour, and <i>, <u>, <s> work because TMP derives them geometrically rather than from a font face. A <size=140%> in a lighter tone reads far better than a bold that never renders.

A sign is rendered twice from the same string — the board carries its own TMP component, the hover label is drawn by the HUD with different settings. A tag can therefore apply to one and not the other:

board hover label
<color> applied ignored, always white
<mark> applied, behind the glyphs ignored
<i> <u> <s> applied ignored
<b> ignored (no bold face in the font) ignored
<size>, layout tags applied applied — and they push the piece name and use prompt out of shape, since your text is only the first line of that block

That is vanilla behaviour, not something this mod can reconcile; the two previews under the field show both results side by side, each reproducing what its renderer actually does.

Reproducing a renderer means reading the renderer, not the component. Three places where the two disagree, all found in game and all worth knowing before "fixing" the code that handles them:

  • The board's text widget reports a pale, near-white color. What lands on the plank is solid dark, because TextMeshPro multiplies that vertex color by the material's _FaceColor and the sign's material carries a dark one. The preview does the same multiplication; reading widget.color alone gave a washed-out grey preview of text that is black in game.
  • The board's widget carries overrideColorTags = true, yet the board does honour <color>. The hover label carries false, yet a colored sign still reads plain white when hovered. Both previews are set from the observed result, not from the flag.
  • <mark> is drawn behind the glyphs on a sign and on top of them in a plain UI canvas, which is why the board preview nudges an opaque highlight to translucent — preview only, the stored text keeps exactly what you typed.

Repository layout

SignStyler.csproj              local references to the game's DLLs
pack.ps1                       build plus both publication archives into dist/
manifest.json, icon.png        Thunderstore metadata (256x256 icon)
docs/nexus-description.bbcode  description ready to paste on Nexus
src/                           the mod

Publishing

pack.ps1 produces two archives in dist/:

  • SignStyler-<version>.zip — mirrors the game folder (BepInEx/plugins/SignStyler/). This is what Nexus expects and what Vortex reads as-is.
  • SignStyler-<version>-thunderstore.zip — manifest.json, icon.png, README.md and CHANGELOG.md at the root, the DLL under plugins/.

Before the first release, fill in the name in LICENSE, and the exact BepInEx dependency string in manifest.json — Thunderstore rejects an identifier that does not exist, so copy it from the package page.

On Nexus, set the category, upload a preview image (an in-game screenshot of the panel beats the icon), and flag the mod as client-side only. On Thunderstore, tick the AI Generated category — it is mandatory there, and mods have been pulled for omitting it.

Compatibility with other sign mods

Sign mods fall into three families. Only the first one collides.

Replaces the sign editor — run one or the other. RunicSigns and BetterSigns substitute their own editor for the vanilla text prompt. RunicSigns goes further and makes captions literal: rich text is not executed at all, styling comes from per-sign properties (one color, one alignment, one size for the whole caption, colors snapped to a named palette). It also stores appearance in custom fields, so it has to be installed on the server and on every client at the same version. Different philosophy, same hook — do not run it alongside this mod.

Touches the same input field — redundant. ComfySigns also disables tag rendering in the field and raises the character limit. Nothing breaks, but the last one loaded wins and there is no reason to stack them.

Renders the output — complementary. AdvancedSigns (font, position, size), Glowing Signs (restores the pre-0.214.300 unlit rendering). They act on Sign, this mod acts on TextInput. They read the same vanilla tags this toolbar writes.

Displays dynamic data — unrelated. Signs (jcdcdev), ZenSign, IconSign and similar mods put chest contents or world stats on signs. No shared surface.

The design choice behind all of this: the mod writes nothing but ordinary vanilla sign text into the ZDO. No custom field, no schema, no version handshake. Other players see styled signs without installing anything, the server needs nothing, and uninstalling leaves every sign exactly as it was.

Known fragility

Valheim 1.0 shipped on 9 September 2026. A major release almost always breaks Harmony patches. Before debugging this mod, check that BepInEx and your other mods have caught up with that version.

The mod depends on three points of the game's API, any of which can move on an update:

  1. TextInput.RequestText(TextReceiver, string, int) — patched as a postfix
  2. TextInput.m_panel — read by reflection, falling back to the singleton's own GameObject
  3. The first TMP_InputField found inside that panel, excluding the mod's own clones

All of it is wrapped in try/catch with explicit logging: if the API changes, the plugin complains in the BepInEx console instead of failing to load.

Toolbar placement (above the field), preview placement (below it) and picker placement (to the right, top edge lined up with the toolbar's) are all computed from the panel container's RectTransform after a forced canvas update. The picker is a side panel rather than a block inside the toolbar column precisely because the palette and the history made that column tall enough to run off the top of the screen.

It hangs from its own top edge and is then clamped back inside the canvas, which is why it survives a palette growing by a row. When the panel is taller than the screen the top wins and the palette is what gets cut — the header and the target tabs have to stay reachable. The clamp runs on the frames following an open and whenever the panel's height changes, never on a hidden panel: an inactive RectTransform has no layout to measure.

One limit separates this from a real rich-text editor: the style toggle only recognises an immediate wrapper, so with <b><i>word</i></b> the bold button will not see the <b> through the <i> and will add a second one. That would need a proper tag-tree parser rather than substring comparisons.

The button state is a separate mechanism and does not share that limit: it counts opening and closing tags before the caret, so a style is reported as active whatever it is nested inside.

Prior art

ComfySigns already covers part of this ground: plain-text editing, a limit raised to 999, a configurable font, a draggable panel, visual effects. It has no toolbar and no live preview — that is where this mod adds something. Worth reading as an implementation reference if a game patch breaks something here.

Support

Bug reports and feature requests: GitLab Issues. Pick the Bug template and keep its headings — it asks for the BepInEx log — Valheim/BepInEx/LogOutput.log, or the same path inside the profile folder when using r2modman or Gale. Without it a report is mostly guesswork.

Credits

The code was written by Claude (Anthropic) from my specification, through several rounds of review. Scope, design decisions, review, testing and maintenance are mine.

Disclosed here and flagged AI Generated on Thunderstore, per the platform's rule.

License

MIT.