Commit Graph
100 Commits
Author SHA1 Message Date
JonKazama-Hellion 63c68327dd Merge branch 'chore/plugin-description' into main
Shorter installer description, corrected locale count, punchline
unified across both manifests.
2026-08-20 18:20:39 +02:00
JonKazama-Hellion 0f9ced8f86 Merge branch 'chore/orphan-strings' into main
30 resource keys for the webinterface, which left the code in May.
Every key verified against the whole repo before deletion.
2026-08-20 18:20:39 +02:00
JonKazama-Hellion 60e3dd87a2 docs(manifest): shorter plugin description, and one that leads with the point
The installer text ran 908 characters and opened by naming a category
rather than saying what the plugin does. It now says it in the first
sentence and stops at 367.

Two things were also wrong rather than long: it claimed 24 locales when
there are 25, and the punchline in repo.json did not match the one in
the yaml, so the store listing and the plugin details showed different
sentences.

Attribution is unaffected -- it lives in NOTICE.md, README.md and the
licence, which is where the notices belong. This field is a product
description.
2026-08-20 18:16:35 +02:00
JonKazama-Hellion 5ab4312cad chore(i18n): drop the webinterface strings, the feature left in May
The HTTP server, its routes and the Svelte frontend went out with
c2801c4 on 2026-05-02. The 30 resource keys behind them stayed, in all
25 languages, and the generated designer kept a property for each.

Verified before deleting, not assumed: every key was matched on word
boundaries against all 382 repo files excluding the resource bundles
themselves, and none appears anywhere. No dynamic resource access exists
in this codebase either -- no ResourceManager.GetString with a literal,
no using alias, no interpolated key name -- so the caller scan is
trustworthy here.

The designer is regenerated by a Visual Studio tool that does not run on
a CLI build, so its properties are removed by hand alongside the resx.
2026-08-20 08:37:09 +02:00
JonKazama-Hellion c1d6ca3bbd docs(readme): carry the version badge and header to 2.0.3
Security Scan (reusable) / Security Scan (push) Successful in 24s
Security / scan (push) Successful in 23s
Build / Build (Release) (push) Successful in 32s
Forge Announce / Post changelog to Hellion Forge (push) Successful in 6s
Release / Build and attach release ZIP (push) Successful in 29s
2026-08-20 07:58:45 +02:00
JonKazama-Hellion 4ab9202533 Merge branch 'feature/v2.0.3' into main
Pop-out context menu, screenshot mode toggle, and colours with no alpha
byte. Smoke-tested in game on all three before the merge.
2026-08-20 07:58:18 +02:00
JonKazama-Hellion 5e15d34eeb chore(release): 2.0.3 -- pop-out context menu, screenshot mode
Three fixes, all of them things that simply did not work: the context menu
in pop-out windows, the screenshot mode toggle, and text the game colours
without an alpha byte.

The 1.5.6 changelog block moves out of the plugin manifest to stay inside
the four-subblock limit; it remains in docs/CHANGELOG.md.
2026-08-20 07:58:13 +02:00
JonKazama-Hellion b9feb8650f chore(comments): drop the spec task codes the last pass missed
Codes like POP-1c or B4b-2 name a task in a planning document, not
anything in the code. A reader has no way to resolve them and they age
into noise the moment the document is closed. Where a code was used as a
reference, the sentence now names the function it meant.
2026-08-20 07:54:45 +02:00
JonKazama-Hellion 0f9858a3d3 fix(screenshot-mode): the toggle never wrote its change to disk
Screenshot mode is a persisted setting, but neither of the two toggles
saved the config. Turning it on only stuck when some unrelated save
happened to run afterwards -- and once it was stored, turning it off never
reached the file at all, so it came back on with every plugin load.

Both toggles save now. An install currently stuck on it needs one click.
2026-08-20 07:54:38 +02:00
JonKazama-Hellion a3379818eb fix(popouts): the context menu never opened outside the main window
Right-clicking a name or an item inside a pop-out did nothing at all.

One payload handler is shared by the main window, every pop-out and the
input preview, and it holds a single popup state. The main window is
registered first, so it draws first, finds no open popup in its own scope,
reads that as "closed" and clears the state -- before the window that
actually opened the popup gets its turn.

The popup now belongs to the surface that opened it. The others leave its
state alone instead of dropping it. The rule itself sits in its own helper
because the handler pulls in Dalamud and cannot be loaded from a test.
2026-08-20 07:54:32 +02:00
JonKazama-Hellion d5c7db9f43 fix(colours): text the game colours without an alpha byte rendered invisible
The game hands out some of its configured colours as ARGB with the alpha
byte left at zero. The byte swap then moved that zero into the alpha slot
and the text drew fully transparent.

Two guards, and they belong together: forcing alpha on a colour that is
zero everywhere would turn "no colour" into opaque black, which slips
past the invisible-text fallback in ChunkUtil and paints over the colour
the renderer would otherwise inherit.
2026-08-20 07:54:19 +02:00
JonKazama-Hellion 6e24885241 docs: the privacy claim gets stronger, so the documents have to say so
Security Scan (reusable) / Security Scan (push) Successful in 24s
Security / scan (push) Successful in 24s
Build / Build (Release) (push) Successful in 26s
Removing BetterTTV took out the plugin's only outbound network call, and five
documents were still describing it as present. PRIVACY.md led with "one outbound
network call exists by design" -- the opposite of what shipped an hour ago, and
the one claim in that file people actually check.

PRIVACY.md now opens with none at all. The section that described the call is
kept and marked as removed rather than deleted, so the claim can be read against
what it replaced: the startup fetch meant BetterTTV saw an IP as soon as the
plugin loaded, whether an emote ever appeared or not. Worth leaving visible.
BetterTTV also comes out of the third-party table, which now lists only the two
parties nobody using this plugin through Dalamud can avoid.

SECURITY.md listed the EmoteCache HTTP client as in scope for reports. There is
nothing there to report on.

THIRD_PARTY_NOTICES.md named the call as current; both entries it has ever
carried are now historical.

README dropped the pointer to the outbound call and the switch for it, a
stability bullet about a crash fix in code that no longer exists, and the line in
the Chat 2 migration that moved an emote cache directory nothing reads.

ROADMAP gained 2.0.1 and 2.0.2, and a section for what the 2.0.0 push left
behind: 47 overlong comment blocks, channel names translated in only ten of the
25 languages, an orphaned EmoteCacheV1 directory on anyone who ran an older
build, and the deprecated GlobalScaleSafe call that is the last compiler warning
in the build.
2026-08-19 23:41:23 +02:00
JonKazama-Hellion 16730d5ed9 chore(release): 2.0.2 -- BetterTTV out, placeholders fixed
Forge Announce / Post changelog to Hellion Forge (push) Successful in 9s
Security Scan (reusable) / Security Scan (push) Successful in 27s
Security / scan (push) Successful in 27s
Build / Build (Release) (push) Successful in 34s
Release / Build and attach release ZIP (push) Successful in 31s
BetterTTV emote support is removed rather than switched off. Its shared-emote
endpoint went behind authentication and that is where nearly all of them came
from; what was left is the 65-entry global set, eleven of those on the plugin's
own known-broken list, so 54 largely static images from Twitch's early days.
Exactly one is animated, and that one is 492 frames at 140x140 -- 37 MB of
texture memory for a single emote, uploaded one frame at a time. Not worth a
network call and an on-disk cache on every start.

Gone with it: the download path, the cache directory, the GIF renderer, the
settings section, the block list, and 13 translation keys across 50 resource
files. 1567 lines. The plugin now makes no outbound network calls at all, which
is the claim PRIVACY.md has always wanted to make without an asterisk.

EmotePayload and its MessagePack type byte stay, deliberately. Around a thousand
rows in a two-month-old database carry them, and dropping the type would make
those fail to deserialise -- the history is the one thing 2.0.0 promised not to
touch. Nothing writes one any more and a stored emote renders as the code that
was typed, which is what the sender saw when they typed it.

Five descriptions in the Window tab printed {0} where the plugin name belonged,
handed to the widget directly instead of through string.Format the way the rows
around them do. Every language was affected including English; German surfaced
it because the placeholder lands at the start of the sentence there. A test now
walks every placeholder-carrying resource string, finds its uses, and fails on
an unformatted one -- verified by putting the defect back and watching it go red.

New preview images. The old set was from 2026-05-08, older than every cycle in
2.0.0, and showed stacked ImGui defaults to anyone browsing the installer. Taken
with screenshot mode on so no character names reach a public repo. The wizard
takes the theme picker's slot.
2026-08-19 23:34:41 +02:00
JonKazama-Hellion 39e2d91288 docs(images): new preview shots, taken on the rebuilt interface
Security Scan (reusable) / Security Scan (push) Successful in 23s
Security / scan (push) Successful in 23s
Build / Build (Release) (push) Successful in 28s
The three in the plugin installer were from 2026-05-08 and showed the plugin as
it looked before any of the window rebuilds -- older than every cycle that
shipped in 2.0.0. Someone browsing the installer saw stacked ImGui defaults.

Taken with screenshot mode on, so the character and world names are replaced
rather than published: the chat shot would otherwise carry a free company
conversation onto a public repo.

The wizard replaces the theme picker in the set. It is the first surface a new
user meets and it now has something to show; the theme picker is reachable from
the settings shot behind it.

Manifest still points at the old file in this commit -- the URLs are checked
against the pushed tree, so the new image has to exist there before anything can
reference it.
2026-08-19 23:33:56 +02:00
JonKazama-Hellion 3309386fbe docs(readme): the front page still described the plugin as it was at v1.1.0
Security Scan (reusable) / Security Scan (push) Successful in 23s
Security / scan (push) Successful in 23s
Build / Build (Release) (push) Successful in 28s
It called the theme engine "a step toward a distinct UI look and feel", which
was true when it was written and stopped being true somewhere around the fourth
window rebuild. It also pointed new readers at `/hellionchat`, a command that
does not exist -- the family is `/hellion` -- and put the wizard at three
profiles when it has had four since May.

The intro says what the plugin is now: a fork by origin, with everything above
the message store rebuilt, and the differences from upstream named as features
rather than as a rewrite plan. The paragraph no longer repeats what the upstream
section further down already explains in more detail.

Added the plugin notice the wizard shows every user. The repo front page is the
other place someone lands first, and it was the only one of the two that said
nothing.

Counted the themes rather than trusting the number I was about to write: ten
built-in palettes, not seven.

Both remaining `/hellionchat` and "24 locales" mentions sit under headings that
say "kept for context" and describe the state at that version. Those stay wrong
on purpose.
2026-08-19 22:52:19 +02:00
JonKazama-Hellion 38c6707970 ci(security): hold API responses in a file instead of piping them onward
Security Scan (reusable) / Security Scan (push) Successful in 25s
Security / scan (push) Successful in 25s
Build / Build (Release) (push) Successful in 28s
Semgrep blocks on gha-curl-pipe-shell, and it is pointing at the publish step
added an hour ago. The rule exists for `curl https://…/install.sh | bash`:
remote content reaching an interpreter. What this actually did was pipe a JSON
response into a python3 -c inline script -- the interpreter and its program both
live in the workflow file, and the server only ever supplied data.

A false positive, then, but the rule cannot see the difference between an
interpreter reading a program from stdin and one reading data, and neither can
the next person to read the step. Responses go to a file and are read from
there. Same shape as the finding suggests, and worth having anyway: a response
that is on disk can be looked at when a call misbehaves, instead of vanishing
into a pipe.

Verified by falsification rather than by a green run: the same ruleset against
the previous revision of this file reports 1 blocking finding, against this one
zero. Without that check a passing scan only proves the rule was not loaded.
2026-08-19 22:49:07 +02:00
JonKazama-Hellion a4c4e15c3b chore(release): 2.0.1
Forge Announce / Post changelog to Hellion Forge (push) Successful in 8s
Security Scan (reusable) / Security Scan (push) Failing after 24s
Security / scan (push) Failing after 24s
Build / Build (Release) (push) Successful in 34s
Release / Build and attach release ZIP (push) Successful in 30s
Same-day hotfix on 2.0.0, nothing user-facing. It exists because the 2.0.0
archive was built before the MessagePack lift, and because the fixed release
workflow needs a tag to prove itself on -- 2.0.0 cannot, since Gitea reads the
workflow from the tagged tree and that tree still holds the broken version.

Not force-moving the 2.0.0 tag: its release object exists with the archive
attached, and someone may already have pulled it.
2026-08-19 22:46:10 +02:00
JonKazama-Hellion 226174bf12 ci(release): publish with curl instead of a go action, and lift MessagePack
Security Scan (reusable) / Security Scan (push) Failing after 23s
Security / scan (push) Failing after 23s
Build / Build (Release) (push) Successful in 30s
The v2.0.0 tag built fine and then died on its last step: gitea.com's
release-action declares `using: go`, the runner has to compile it, and act
cannot -- exec: "go": executable file not found, exit 127, after a green build.
The zip existed and never got attached, so the Discord announcement went out
while the download link pointed at nothing.

This is a known failure. It was diagnosed on another repo in June and the note
from then says in as many words that this repo carries the same pattern and
should migrate before its next release. It did not, and here we are.

The publish step is a plain curl call against the Gitea API now, running in the
job image with curl and python3, independent of go, the action cache, and
whatever @main happens to point at. Idempotent by design: a re-run reuses an
existing release and replaces the asset rather than failing on the duplicate,
which is exactly the state a recovery run finds.

MessagePack moves from the 3.1.4 floor to 3.1.7, which is what the trivy scan
was failing on. The range already allowed it -- NuGet resolves the lower bound
of a range, and trivy reads it the same way, so the floor is the version that
counts. The advisories are recursion depth in Skip and an LZ4 decompression
fault, both reachable only through crafted input; this plugin serialises its own
payloads and reads back its own bytes from a local database, so the practical
exposure is someone who already has write access to the file. Lifted because it
costs nothing and a scan that stays red for a known-harmless reason is how a real
finding gets missed later.
2026-08-19 22:44:55 +02:00
JonKazama-Hellion e316a9b400 Merge branch 'feature/v2.0.0' into main
Security Scan (reusable) / Security Scan (push) Failing after 24s
Security / scan (push) Failing after 24s
Forge Announce / Post changelog to Hellion Forge (push) Successful in 11s
Build / Build (Release) (push) Successful in 33s
Release / Build and attach release ZIP (push) Failing after 30s
2026-08-19 22:36:26 +02:00
JonKazama-Hellion 97d31bb8e4 fix(defaults): the values everyone had set by hand become the defaults
The 2.0.0 reset put every install on the shipped defaults for the first time, and
that exposed which of them had never actually been used. The sidebar was the
loudest: 44 pixels, a width carried over from the v1.2.0 icon-only layout and left
in place long after the sidebar started drawing labels beside those icons. Every
tab name came out clipped. Nobody had noticed in cycles because everyone had
widened it by hand -- 160 in the config this was measured against.

Default 160, and the floor moves from 40 to 130: where a tab name stops being
readable in German, which is the longest of the 25 languages, rather than where
the icons stop fitting. Anyone who wants it slimmer wants the collapsed layout,
and that is a separate width.

Glyph ranges had the same shape of problem with a worse outcome. They were only
ever filled when someone picked a language explicitly, so an install left on
"follow Dalamud" -- the default -- got none at all. That went unnoticed while
configs accumulated ranges over months; a fresh config has none, and a tester on
a Korean, Chinese, Cyrillic or Greek client would have come out of this update
reading boxes. The load path derives them from the Dalamud UI language too now.

The rest are preference defaults taken from a config that has been in daily use
across every one of these cycles: compact density off, title bar off, compact
timestamps on, compact tell tabs on, honorific glow on, 100 messages of tell
history preloaded, inactive opacity 0.75, command help on the right. New tell
tabs open as pop-outs, because the wizard's closing step tells the user to try
/tell and watch exactly that happen.

Two values were deliberately not carried over. SeenPopOutInputHint and
SeenPopOutHeaderHint are not preferences, they are "this user has seen it"
markers -- shipping them as true would mean no new user ever sees the hints that
explain a feature people did not find on their own. The greeted toggle stays off
as well: it is opt-in for people who greet.

Also dropped the light-bulb emoji from the wizard's closing hint in all 25
languages. UI icons come from the icon font here, not from emoji.
2026-08-19 22:34:35 +02:00
JonKazama-Hellion 89c73be71c feat(release): 2.0.0 -- nine cycles ship, and the config starts over
v1.6.0 through v1.15.0 were never published. The whole window layer was being
rewritten from ImGui defaults to custom drawing, and repo.json deliberately kept
its links on v1.5.6 so nobody could update into a half-finished state. That work
lands here in one release.

The config is not migrated, it is replaced. A config carried across nine cycles of
window rebuilds holds values chosen against surfaces that no longer exist, and
starting over is the only way to be sure every install is on the same defaults.
The message database is a separate file and is not touched. The old config is
copied to HellionChat.json.pre-2.0.0.bak first, so rolling back to 1.5.6 stays a
file copy rather than an evening of clicking settings back in.

Config schema 27. The reset runs from the constructor, before LoadAsync seeds the
default tabs, and the self-test now fails if the tab list is empty at /xlperf --
that ordering breaking would leave every user with no tabs and nothing else in the
plugin would notice.

Default layout gains an Emote tab: custom emotes, standard emotes and echo. A
tester asked for it, emotes get lost between system notices otherwise.

Manifest, README, roadmap and the four changelog consumers are on 2.0.0. The
release notes lead with what was fixed rather than what was redrawn -- retroactive
cleanup that could never be applied, a compaction that reported deleting nothing
while deleting everything, pinned tell tabs that came up empty for a session. And
with the one behaviour change users should know about: the channel grid is
authoritative over storage now, so anyone who had unticked channels while the
unknown-channel failsafe was on stores less than they did before.
2026-08-19 22:15:40 +02:00
JonKazama-Hellion 16557213cd chore: comments, second pass -- the task codes the first pass missed
The first sweep matched a character class that swallowed the digit, so a bare
B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead
of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6,
P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11,
UI-12, XC-8 and API-3.

Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string,
#L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2
explains a colour-channel order. Those look like codes and are not.

Also translated the eight German comments left in the theme files and
ImGuiUtil. Seven of them described what a palette does to which channel, which
is worth reading -- just not in a second language in an otherwise English
codebase.
2026-08-19 22:03:12 +02:00
JonKazama-Hellion 5b738e6885 chore: comments say what the code does, not which task produced it
A comment that reads "MUST stay in lockstep with TryGetActiveCrossfade (K8)"
helps nobody outside the plan that used to have a K8 in it, and the plans are
not in this repo. Same for "Spec FR-4", "plan §B.2", "Sub-Task 4.4" and the
F/R/M/A/S round codes scattered through the style engine and the self-tests.

Personal names go too. "tester feedback from Jin (v1.4.7)" and "Flo decision
2026-06-15" carry the reason fine without naming anyone -- the version and the
reason are the parts a reader can act on, and a public repo should not need a
cast list to be read.

The rule applied throughout: keep the why, drop the reference. Version numbers
stay, since those resolve through the changelog. 77 files.

ChunkUtil also carried 281 lines of commented-out code -- an older ToChunks
variant and two helpers with no callers, inherited and never removed. Deleted;
git remembers them.
2026-08-19 21:50:31 +02:00
JonKazama-Hellion 52237fda7e feat(wizard): the profile cards and the welcome page speak the window's language
The cards were the last surface still drawn from ImGui defaults: four emoji on
stock child frames, sized to a fixed 2x2 grid that clipped the longer privacy
descriptions in half the supported languages.

They are tiles in the row language now -- a resting surface, an accent bar down
the left edge when chosen, a held hover over both, tracked caps for the heading.
The emoji are FontAwesome glyphs from the icon font the rest of the plugin
already uses. Each card measures its own height from its text and the pair in a
row takes the taller of the two, inside a scrolling frame, because cutting a
privacy choice off mid-sentence is not a thing this plugin gets to do.

Step three moves onto ToggleSwitch rows and a drawn theme field with a popup
built from PopupRow, using the transient widget overloads throughout: the
Func/Action pair saves on every click, and this step is staged until Finish.

The welcome page is opaque, unlike every other window here. Those are read at a
glance over the game; this one is read once and carries a privacy decision. The
fox sat on a hardcoded off-white rectangle that read as paper taped to the
window -- it is a disc now, tinted from the theme accent and only lightened as
far as the black linework needs, measured rather than set.

Three defects surfaced while doing it, all older than this work:

- The GDPR notice for full history had been translated into 25 languages and
  drawn nowhere since the four-step rewrite dropped it on 2026-05-18. It is on
  the card where the choice is made.
- Two cards claimed to be recommended: the badge sat on casual while the data
  minimisation heading still said "(recommended)" in every language. The suffix
  is gone, and the word it carried became the badge label.
- The wizard had no way back into it at all. /hellion wizard reopens it, and
  OnOpen resets the staged state so a second run cannot commit picks from a
  first one the user never saw.

The welcome text drops the fork framing: Chat 2 and this plugin have diverged
far enough that the codebases no longer line up, so it reads as history in a
muted line rather than as a justification up front. In its place is the notice
that plugins are a grey area in this game and do not belong in public channels.
Channel names in it come from Language.<lang>.resx per language, so the German
build says Sagen/Rufen/Schreien and the Polish one says Say/Yell/Shout, which is
what a Polish player actually sees on an English client.
2026-08-19 21:50:21 +02:00
JonKazama-Hellion 5958bceb1c feat(wizard): the chrome speaks the window's language (block A of v1.15.0)
The wizard is the first thing a tester ever sees and it was the last surface
still drawn in ImGui defaults -- stock buttons under a hardcoded forge bronze
that ignored the active theme entirely, since v1.5.2.

Block A is the frame: the settings backdrop at full strength, pagination dots on
the contrast-checked theme accent (current step filled, the rest rings, so the
position reads by shape), step-3 section headers in the tracked-caps-and-fading-
rule language every finished window uses, the primary action as the chamfered
accent pill, and back/skip as ghost links. The selected profile card's border
and the step-4 accents follow the theme now too.

Still open, deliberately: block B gives the profile cards their real shape --
including replacing the emoji icons, which have stood against the global
no-emoji-as-UI-icon rule since the cards were built -- and block C moves the
step-3 checkboxes onto ToggleSwitch rows and the theme list onto PopupRow.
2026-08-19 19:44:39 +02:00
JonKazama-Hellion 6fadbb1659 Merge branch 'feature/v1.14.0' into main
The input row cycle: two ghost buttons and a menu where five filled defaults
sat, a field with its own surface and focus rail, popups built from the
sidebar's row shape, a softened seam to the conversation, and hover fades at a
human pace. Plus the style lab and the preflight guard for the RGBA/ABGR trap.

Local state only. repo.json stays on 1.5.6.0.

Note on shape: these commits were made directly on main by mistake and were
re-homed onto feature/v1.14.0 before this merge, so the branch-per-cycle rule
holds in the history even where it slipped in the moment.
2026-08-19 19:35:51 +02:00
JonKazama-Hellion dcc8ff1867 chore(release): close the v1.14.0 cycle
Version to 1.14.0 in the csproj, changelog and roadmap for the local state. Not
published: repo.json stays on 1.5.6.0, links untouched.

The changelog leads with what the row became and is honest about how it got
there: every style decision in this cycle was made by looking at variants in the
lab, in-game, against the live theme -- and the two recurring traps of the day
(the RGBA/ABGR contrast swap, popups that do not size to their content) each
left a guard behind rather than just a fix.

Smoke-tested continuously by Flo through the evening rather than at a single
gate: the contrast bug, the clipped menu, the hover speed and the seam hardness
were all his catches, live, and each is named in the log where it was fixed.
2026-08-19 19:35:35 +02:00
JonKazama-Hellion 9e62ed762d fix(style): slower still -- the fade takes a third of a second now
The halved rates were still judged much too fast, so: 3/s in, 2/s out. Rise in
roughly 330ms, settle in 500ms. The textbook says hover fades live around 150ms;
the textbook does not play this game, and the person who does gets the vote.

Timing guard moved with it.
2026-08-19 19:29:01 +02:00
JonKazama-Hellion eeffb5bc6b fix(style): the hover fade ran at light speed, and stopped dead
Tester feedback via Flo, aimed at the sidebar tabs but true everywhere: 14/s in
means a full fade in seventy milliseconds, which is five frames -- fast enough
to read as a flicker rather than a fade. Halved to 6.5/s in and 4/s out, which
puts the rise around 150ms and the settle around 250ms, where UI fades usually
live.

And the curve was linear: one speed the whole way, then a dead stop. Consumers
now get a smoothstep over the linear state -- soft start, soft landing -- while
the state itself stays linear, because the evict rule and the advance step are
written against it. Every hover in the plugin inherits both changes through the
one Query call, so the sidebar, the popups, the ghost buttons and the message
rows all breathe at the same pace.

The timing test now guards the other direction: it fails if anyone drifts the
rate back toward light speed.
2026-08-19 19:26:47 +02:00
JonKazama-Hellion 12a445e08f feat(sidebar): soften the seam and let the active tab reach its content
Tester feedback, relayed by Flo: the transition from the tab list to the chat
field is too hard, and the tabs could present themselves better. Three causes,
three changes, no structural touch -- tabs stay tabs, per the standing decision.

The full-width border line under every row was a ladder of hard cuts. It is a
fading rule now, starting past the icon column and dissolving before the right
edge -- the same shape the section headers have used since v1.11.0, at about
half the opacity.

The gap between the sidebar group and the message area was a bare strip of
window background with a hard edge on both sides. A faint surface wash fades
across it toward the messages, turning the cut into a seam.

And the selected tab bridges that gap: its active fill extends across the
spacing so it touches the conversation it selects -- the classic tab metaphor,
attached instead of adjacent. The bridge is a RowStyle knob (default zero), so
sidebar rows opt in and nothing else inherits it. Hover fills picked up the
standard three-pixel rounding on the way.
2026-08-19 19:20:22 +02:00
JonKazama-Hellion 8a9692a71d fix(chat): size the popups from their rows, not the rows from a guess
An ImGui popup does not grow for draw-list content, so both new popups carried a
fixed minimum width -- and German outran it within a day. The menu clipped
Schnellauswahl and Chat ausblenden mid-word; the channel picker would have done
the same to Freie Gesellschaft on a narrow theme font.

PopupRow gets a CalcWidth that measures label plus icon plus padding under the
faces that will draw them, and both popups take the widest visible entry as
their width. Locale-proof by construction: whatever language writes the longest
string sets the size.
2026-08-19 19:13:30 +02:00
JonKazama-Hellion 3e46600fc3 fix(style): the popup text fed RGBA into the contrast helper -- and a guard so this stops recurring
Fifth and sixth occurrences of the same defect in one day, this time in PopupRow
and three spots in the lab. The menu entries and the channel picker were
unreadable at rest on the green theme and only became legible on hover, because
the hover lerp pulls toward a correctly converted accent -- which is exactly the
symptom the tester reported.

The shape of the mistake is always identical: a raw ThemeColors member (RGBA)
handed to EnsureContrast (ABGR). The compiler cannot see it, both layouts are
uint, and a stray extra RgbaToAbgr around the call makes the result look
plausible while measuring a contrast between two colours that are never on
screen.

So this commit is mostly the guard. preflight Block G runs
scripts/verify-colour-channels.sh, which flags any raw theme member in either
argument of EnsureContrast across the UI tree. Falsified before trusting: broken
deliberately, it goes red; and on its very first real run it caught three
offenders in the lab that a hand-rolled grep had missed minutes earlier.
2026-08-19 19:09:22 +02:00
JonKazama-Hellion d7a308b522 feat(style): option three across the board, every colour contrast-bound
Flo picked the third variant in all four lab sections, with one warning attached:
bind the glyph and text colours to the contrast helper or the theme, or they
drown. The warning was well aimed -- the ghost buttons from the previous commit
were feeding RGBA into EnsureContrast, the same channel-order mistake the header
made this morning, and the pill text was raw TextPrimary on an accent fill with
no check at all. Every colour in the row now goes through EnsureContrast against
the surface it actually lands on.

What changed shape:

The icon buttons glow. Flat at rest, and on hover a soft fill with an accent
glow border rising on the held hover value -- DrawGlowBorder's first caller ever.
The lab version of that glow had its alpha in the wrong byte (DrawGlowBorder
reads RGBA, ApplyAlpha writes ABGR), so what Flo approved was a full-alpha glow
with a dimmed red channel. Fixed in both places, with the alpha byte set by hand.

The channel pill is chamfered, the segmented control's corner language, with the
white depth gradient kept. The rounded Pill widget stays untouched for the
status bar.

The channel header trades its fading rule for a tenth-opacity accent wash from
the top edge. Colour as atmosphere rather than as a box -- at this strength it
survives the violet themes that killed the filled bar in v1.11.0.

The lab stays in permanently, by Flo's call: a dev playground for seeing ideas
in-game against the live theme. Its radios now default to what shipped, so the
window doubles as a record of which variant won.
2026-08-19 19:04:05 +02:00
JonKazama-Hellion 6bdfecb1df feat(chat): the input row learns the window's own language
Five filled plates become two ghost buttons and a menu. The plates were ImGui
defaults sitting between a drawn pill and a drawn status bar -- three shape
languages in the one row a user works in, which is what the tester's screenshot
made obvious. The ghosts follow the sidebar's icon buttons: nothing at rest, a
held hover fill, the glyph lifting toward the accent.

Symbols stay outside the menu because they are used mid-sentence. Theme,
settings, screenshot and hide move in; the screenshot toggle was allowed in only
because its state moved to the status bar first, as an accent pill that shows
while the mode is on. A privacy state behind a closed menu answers nothing.

The input field paints its own rounded surface and hands ImGui a transparent
frame, so the widget draws only text and caret -- the Boutique.Inputs technique
from Character Select+, no rebuild of the widget itself. Focus is a two-pixel
accent rail on the left edge: the same mark the rows, the popups and the message
list already use for "this is where you are".

The channel pill gets the CS+ treatment, a faint white gradient and a one-pixel
light along the top edge. White over the fill rather than a second hue, so it
reads as depth in every palette.

And the popups that open from the row are made of PopupRow now -- the sidebar
row's shape, which is also exactly what CS+ draws for its own popup entries. The
channel picker was the reported case: a drawn pill opening a list of naked
selectables, the style breaking mid-click.

A hundred-odd pixels of button reserve go back to the input field.
2026-08-19 18:30:15 +02:00
JonKazama-Hellion df6296024b feat(style): a lab window for the input-row decision
Four elements, three variants each, drawn side by side in real ImGui against the
live theme. Reachable with /hellion lab.

The alternative was drawing mockups, and mockups are what sent this cycle wrong
once already -- they predate the settings window learning that structure is
typography and only controls get a fill. A picture also cannot show the part that
matters most here: switching themes switches which variant works.

Everything it draws comes from helpers that were already in DrawListExtensions
and had almost no callers. DrawSlipPolygon and DrawVerticalGradient had one
between them, in SegmentedControl. DrawGlowBorder had none at all.

Temporary, and deliberately not behind DEBUG: the decision happens in the build
Flo runs. It comes out once the variants are picked.
2026-08-19 18:22:34 +02:00
JonKazama-Hellion 6449a51daa fix(chat): the channel header had a plate the settings window gave up weeks ago
The mockup draws this band as a filled surface, and I built it that way. The
settings window used to draw its section headings the same way -- and dropped it
in v1.11.0, with the reason written into SectionHeader: a fill reads fine against
blue themes and vanishes against violet ones, because its only distinction from
its surroundings is hue. Tracking is shape, and shape survives every palette.

Side by side with yesterday's build the difference was obvious: the settings
window sets VERHALTEN and TASTENKÜRZEL as tracked caps with a rule that fades
out, while the chat window right next to it carried a lit bar. One window was
three weeks ahead of the other.

So the plate is gone and a fading rule runs between the channel name and the
world, stopping short of it. Same shape the settings headings have had since
ced7ea0.

Worth writing down because the mockups are where this came from, and they are
from before that lesson. They are the starting point of the style track, not its
target -- the target is how far ImGui can be pushed, and the plugin has been past
these drawings since v1.11.0.
2026-08-19 18:08:37 +02:00
JonKazama-Hellion 0abbbc8f8a docs(roadmap): the sidebar comes off the list, the input bar goes on
Both reasons the channel-rows idea existed are gone. It was written when the
sidebar was flat and unstyled; v1.10.0 gave it row surfaces and it already groups
pinned and auto-tell tabs under headings. What was left was a change to how the
plugin is operated rather than how it looks -- and tabs are what users have
learned to use.

Next two are the input bar and the first-run wizard, the last surfaces still
drawn in ImGui defaults.
2026-08-19 17:48:39 +02:00
JonKazama-Hellion de094a8d4f Merge branch 'feature/v1.13.0' into main
Typography cycle. Named type roles instead of one size, a channel header above
the conversation, timestamps in a column of their own, rows with a surface, and
local plus server time in the status bar.

The heaviest finding was not typographic. Screenshot mode reached one of the four
surfaces that draw a tab name, and an auto-tell tab is named Player@World -- so a
picture of the default view named the conversation partner while every message
below it was anonymised. All four share one rule now.

Config version 26. Local state only; repo.json stays on 1.5.6.0.
2026-08-19 17:40:26 +02:00
JonKazama-Hellion b57ca23b9f Merge branch 'feature/v1.12.0' into main
Reconnection cycle. Export, the tab editor, database maintenance and pinning were
unreachable after the v1.6.0 window rebuild; all four are back, the settings
window is translated into 25 languages, and the channel grid decides what gets
stored.

Local state only. repo.json stays on 1.5.6.0 and the download links are
untouched.
2026-08-19 17:40:20 +02:00
JonKazama-Hellion 64f48b1131 chore(release): close the v1.13.0 cycle
Version to 1.13.0 in the csproj, with the changelog and roadmap entries for the
local state. Not published: the public release stays at v1.5.6, repo.json keeps
its 1.5.6.0 manifest and all three download links are untouched.

The changelog leads with the screenshot-mode gap rather than with the typography,
because that is the part that changes what a user's own screenshots contain. An
auto-tell tab is named "Player@World", and three of the four surfaces that draw a
tab name had no rule about it -- so a picture of the default view named the
conversation partner while every message below it was anonymised. Anyone who has
shared a screenshot from an older build should know that.

Config version 26 is in there for the same reason. Its migration marks existing
tell tabs as partner-named, and it says plainly what it cannot do: a tab promoted
to permanent before this version keeps its name and loses every marker, so
nothing in the stored data says where that name came from.

Known issues carry the honest tail: the header recomputes its widths every frame
instead of on the status bar's tick, and all three font handles now rasterise the
full glyph range -- the cost of letting the channel name use the smaller face
without breaking on an umlaut.

The migration self-test asserts what v26 actually does now, rather than only that
the version number moved.
2026-08-19 17:34:49 +02:00
JonKazama-Hellion 3b5001e616 feat(chat): local and server time side by side, and a quieter header
Both clocks in the status bar, in the game's own LT/ST notation. Anyone agreeing
on a time across regions reads them off one line instead of doing the arithmetic
in their head.

Server time is not computed here and must not be. Framework.GetServerTime() hands
it over, so the plugin follows whatever Square Enix does with it -- a local UTC
conversion would be right today and quietly wrong the day that stops holding.
Umbra reads it the same way. The slot drops out on its own like every other one,
and it is empty while logged out, because then there is no server to read a clock
off.

The header gives up its clock in exchange. With both times in the status bar it
would have been the third copy of the same number on one screen, and the header's
job is to answer where you are, not what time it is.

Its title also moves down to the meta size. Tracked caps at body size read like a
headline, and the header is meant to answer a question rather than announce one.
That only works because the meta face now carries the full glyph range: it was
built with an ASCII-sized one on the assumption it would only ever draw clocks
and world names, and a single umlaut in a tab name would have broken it. All
three delegate handles rasterise the full set now -- the honest cost of the
change, and the reason the size distinction is worth having at all.

Two things fell out along the way. The detail no longer needs a flag saying "draw
me in the body face", because there is no glyph the meta face cannot reach. And a
culture-pinning test lost its subject when the clock left, so it asserted nothing
and is gone rather than repaired.
2026-08-19 17:29:20 +02:00
JonKazama-Hellion 439c919d77 feat(chat): a button for screenshot mode, where it can actually be found
It has only ever lived in the right-click menu on a player name. For a privacy
feature that is the same as not existing -- reported as missing by a tester who
has been running the plugin for months and never found it.

Now a camera in the input row, next to hide-window, and lit in the accent colour
while active. A mode whose state you cannot see is worse than no mode: the whole
point is knowing whether the names on your screen are real before you press the
screenshot key.

The button reserve goes from 130 to 156 to fit it. No new string -- the context
menu's label is already translated into all 25 languages and says exactly what
the button does.
2026-08-19 17:13:06 +02:00
JonKazama-Hellion e2b6e7a992 fix(chat): the header measured contrast against the wrong colour space
EnsureContrast works in ABGR. I handed it the theme's RGBA on both arguments, so
it swapped red and blue in the foreground and in the background, measured a
contrast between two colours that were never on screen, and returned a result in
the wrong order -- which then went through RgbaToAbgr a second time.

On a violet surface with a teal accent that came out as dark bordeaux on dark
violet: the exact unreadable pairing the call was there to prevent. Reported from
a real screenshot, not from a test, because nothing here is testable without a
draw frame.

Every existing caller in the codebase passes ABGR -- SettingsPalette hands over
_palette.Abgr(...), SegmentedControl uses fields literally named LabelAbgr and
TrackAbgr. Mine were the only three that did not, and all three were written in
this cycle.
2026-08-19 17:09:05 +02:00
JonKazama-Hellion 80ec7450c8 feat(chat): stop repeating the same minute on every line
The comparison value comes from the message data, not from a variable carried
between rows. In 1.5.6 the loop walked every message and skipped invisible ones
with a dummy, so what it remembered was the last *visible* stamp. The virtualised
list only iterates the visible window, so the row above that window was never
drawn at all -- a carried variable would hold whatever was on screen before the
last scroll, and the first stamp after every jump would be wrong.

No predecessor means draw. Scrolling into the middle of a log would otherwise
swallow the only stamp on screen.

Both draw paths now pass an index; the linear one was a foreach and had none.

The setting is on by default and existed with translations in twenty-three
languages -- Catalan and Italian had kept the English string, so those two are
done now. It needs no fingerprint entry: the column stays reserved when the stamp
is suppressed, so hiding one changes no row's height.
2026-08-19 12:07:39 +02:00
JonKazama-Hellion b63e1eda9b fix(privacy): one rule for tab names, applied to all four surfaces
The header was the only place that knew a tab name can be a person. The sidebar,
the tab strip and a pop-out's window title drew the same "Player@World" string
untouched, so a screenshot of the default view still named the partner while the
messages underneath were anonymised. Guarding one surface out of four guards
nobody.

The rule sits in one place now and all four read it. Names are replaced rather
than blanked: a nameless tab in a strip of tabs is worse to use than a
placeholder, and the sidebar has no room to explain itself. The salt is drawn
fresh on every plugin load, the same reasoning the message path uses -- a stable
label would let two screenshots taken weeks apart be tied together.

The tab strip resolves once and both measures and draws that value. Measuring one
string and drawing another would have sized every tab wrong the moment the mode
came on, which is the kind of thing that looks like a layout bug and gets fixed
in the wrong place.
2026-08-19 12:04:55 +02:00
JonKazama-Hellion 1604186aa1 test(style): pin the type scale table, and mark three mirrors
The arithmetic had tests from the first commit; the table in front of it did not.
Factors is indexed by the enum, so a role inserted in the middle shifts every
factor below it -- and each one still resolves to a plausible size, which is
exactly why nothing would have failed.

Three files were missing their TEST-MIRROR marker despite having mirrors. The
marker is how the drift check finds them.
2026-08-19 12:01:31 +02:00
JonKazama-Hellion 8fea9113b9 fix(privacy): the screenshot guard was reading a field that gets wiped on purpose
This morning's fix hung on TellTarget, and TellTarget is routing state that the
codebase clears deliberately. StripTellBindingOnPromote sets IsTempTab false,
empties TellTarget, and keeps the name -- so a promoted tell tab is called
"Player@World" permanently while carrying neither marker, and falls through both
possible checks. That state survives restarts. A pinned tab whose binding did not
survive a save is the same hole with a different cause; the auto-tell service
logs that case as expected and repairs around it.

The flag is set where the name is built from a partner and is not cleared by
promotion. Renaming clears it, because at that point the user typed it.

Config v26 carries it backwards for tabs that already exist: anything still
holding a tell binding or the temp flag got its name from a partner. Tabs
promoted before this version cannot be recovered -- nothing in the stored data
says where their name came from -- and renaming one has the same effect anyway.

Two more things the header was giving away. Its icon for an auto-tell tab is
derived from the partner and stable across sessions, which is three bits of
linkable information on a picture meant to be shareable; the message path
re-salts its name hashes on every load precisely to avoid that, so screenshot
mode now falls back to a plain envelope. And a world name that is not ASCII --
the CN and KR clients have those, and we ship translations for both -- was being
drawn in the meta face, which carries ASCII and a middle dot. It would have come
out as question marks, the same defect the split was built to prevent.

Plus two that are not privacy: the header had no FontsReady gate, alone among
the drawing components, so its band height and baseline offset were wrong in
exactly the frames this cycle made more common. And a long tab name ran past the
band and got cut mid-glyph at the window edge; it fits now, the way the honorific
header already did it.
2026-08-19 12:00:16 +02:00
JonKazama-Hellion 0399b68d8c fix(settings): let the preview show what the log shows
The preview was four flat lines of text on a plain field. After this cycle the
real log has a channel header above it, a fixed timestamp column, and system
messages in italics -- so the preview had quietly become a picture of a window
that no longer exists. That is the same defect as a widget with no call site,
just pointing the other way: something on screen that stopped tracking what it
describes.

The reserved band grew with it. It is a fixed height that the sidebar mock also
divides by, so adding a row inside without raising it would have pushed the last
message out of the space -- the recurring drawing-into-unreserved-space mistake
this project keeps stepping on.

Preview stamps are fixed rather than live. A clock ticking inside a settings
panel pulls the eye away from the setting being changed.
2026-08-19 11:50:19 +02:00
JonKazama-Hellion 63d1b34b00 feat(chat): two densities that actually look different
Card density puts the sender on its own line with the body indented onto the text
column beneath it, and six pixels of air after each one. That air is what makes a
card read as a card, and it goes through the measured row height so the clipper
plans against it rather than around it.

System messages go italic in both densities. Nobody said them -- it is the game
talking -- and italics carry that in every palette. Colour would have been the
obvious alternative and is the wrong tool twice over: the rule this cycle runs on
says typography solves what typography can, and the channel colours already in
those chunks come from the game and are not ours to dim.

The italic face falls back to the game's own italic rather than to upright text
when the custom one is switched off, so the distinction survives either setting.
2026-08-19 11:48:46 +02:00
JonKazama-Hellion 81e4c9367a feat(chat): give each row a surface to sit on
A wash from the left at a tenth opacity, a two-pixel accent bar on the edge, both
fading in and out on the held hover value rather than snapping.

Two draw paths, because the height arrives at two different times. On a normal
frame the cached height is already correct -- a chat message does not change
height after its first measurement -- so the surface goes down before the text
and costs nothing. On the frame after the cache is dropped no row knows its
height yet, and that is the only time the draw-channel detour is needed. Without
it the entire list would flash bare for one frame after every window resize,
which is not rare: width and display scale are both in the fingerprint.

The gradient and the rounding cannot be one call. AddRectFilledMultiColor writes
four fixed vertices and takes no rounding parameter, so the rounded base goes
down first with the gradient inside it. At two pixels the bar has no visible
corners at all and needs neither.
2026-08-19 11:47:46 +02:00
JonKazama-Hellion ba16ab59e3 feat(style): a scope for drawing behind text that has not been measured yet
Needed on exactly one frame: the one after the height cache is dropped, when no
row knows its own height yet. Every other frame the cached height is already
right -- a chat message does not change height after its first measurement -- and
the caller paints the fill directly without coming near this.

Modelled on LightlessSync's SettingsCardScope, which had already worked out the
two things that make draw channels dangerous. Nesting a splitter into itself
asserts, and Dalamud does not compile asserts out, so the user gets an error
dialog rather than a glitch -- hence the depth count. And a forgotten merge is
not a dropped frame but a permanent one: the commands stay in the channel buffers
and never reach the draw list, then the next frame's split walks into the assert.
Hence the finally, by way of the struct's Dispose.

Fill switches to the background channel and switches straight back, so a caller
cannot leave the channel hanging even by returning early.
2026-08-19 11:46:40 +02:00
JonKazama-Hellion 611dd368cb feat(chat): give the timestamp its own column, and the show-timestamps box its effect back
The stamp used to be text at the head of the line with two spaces after it, so
every sender name started wherever the previous stamp happened to end. It sits in
a fixed column now, measured once per draw from the widest shape the current
format can produce, and the names line up.

The column stays reserved when the stamp is hidden. Collapsing it would make a
per-tab switch change every row height in that tab, and the height cache would
have to carry wrap positions rather than just the format.

tab.DisplayTimestamp has a reader again. It was in 1.5.6 at two call sites and
lost both when cf4705e retired the old chat window; the tab editor has been
writing a setting nobody read since. Same class of defect the last cycle spent
itself on, found in passing here.

The sender draws in the heavier face and the stamp in the smaller one, both
dropped onto the body baseline -- ImGui aligns a row by its top edge, so without
that the stamp would hang. All three faces follow the same FontsEnabled or
UseHellionFont pair every other push site follows; with the game font selected
there is no heavier or smaller variant and the row falls back to one face.

Card density gets the two-line treatment only where there is a sender. A system
message has none, so a header row would be a stamp alone on a line -- an empty
gesture. Those stay single-line in both densities.
2026-08-19 11:46:01 +02:00
JonKazama-Hellion a841942b41 fix(layout): the clock format and the per-tab timestamp switch move row heights
Two more axes the height cache never knew about, and the second one is not even
wired up yet -- it is about to be.

Use24HourClock changes the stamp from 15:45 to 3:45 PM. The stamp sits at the
head of the line with SameLine(0,0), so the wrap width every following word is
measured against shrinks. Rows that wrap near that boundary get a different
height, and nothing dropped the cache.

DisplayTimestamp is per tab, which is why BuildLayoutFingerprint now takes the
tab. It only ever read Plugin.Config before -- the per-tab part of this system is
the gate, one per tab, comparing fingerprints that knew nothing about tabs. The
switch is dead today and gets its reader in the next commit; the axis goes in
first so the cache is right the moment it starts doing something.

Both are toggles, so they sit in the discrete half and skip the settle window.
2026-08-19 11:44:30 +02:00
JonKazama-Hellion ad7421fbed feat(chat): the sample the timestamp column measures against
Eights rather than zeroes. In most faces 8 is the widest digit, and a column
measured from 00:00 gets undercut by a 10:38 -- which would shove the name column
right on exactly that row, the misalignment the column exists to remove.

Two samples because the twelve-hour form is wider. That difference is why
Use24HourClock has to enter the layout fingerprint: switching it moves every wrap
position after the stamp, and the height cache never knew.
2026-08-19 11:43:38 +02:00
JonKazama-Hellion 56a9f3f474 fix(privacy): the header gave away what the log was hiding
Screenshot mode anonymises sender names in the message list. The channel header
I added yesterday sat above that list and showed two things it should not.

The home world, on the right. It narrows a player down almost as far as the
character name does, and the mode exists so a picture can be shared.

Worse, the tab name on the left. AutoTellTabsService builds a tell tab's name as
"Player@World", so a tell conversation had the partner's name and world set in
tracked caps directly above a log where every message had been anonymised. The
one place a reader looks first was the one place still naming them.

The name is suppressed only where it actually names someone -- a tab with a tell
target set. General or Trade stay readable, because they identify nobody, and a
self-named tab is the user's own text.

Found by asking what the new surface shows rather than by a test failing. Nothing
here was failing.
2026-08-19 11:42:13 +02:00
JonKazama-Hellion e810d2479d refactor(style): drop the type role that could never be pushed
TypeRole.Header had no call site and could not get one. The channel header is set
apart by small caps and tracking, not by size, so the role resolved to exactly
Body -- a value that would sit in the enum being equal to another value forever.

That is the precise thing this style track exists to prevent. Five widgets shipped
once with no call site at all, and the rule that came out of it says no piece
lands without one in the same pass. Writing a caller just to satisfy the rule
would have been worse than the rule.

Also cleans two comments the pop-out rewrite left pointing at things that are
gone: a close button that lived on the removed title row, and a method
description with no method under it.
2026-08-19 10:22:25 +02:00
JonKazama-Hellion dfc0cda806 fix(chat): four things the header review found, all of them visible
The self-test was the worst of them, because it is the only tool that makes block
A judgeable at all and it destroyed itself on use. It returned Fail while the
atlas was not ready, and its own weight buttons trigger a rebuild -- which is
asynchronous, not synchronous as the comment claimed. Click a weight, watch the
step go red. It waits now, like the two existing steps that had already worked
this out.

The translated stand-in was drawn in the meta face, whose glyph range is ASCII
plus a middle dot. Fifteen of the twenty-five translations reach outside that, so
"not logged in" would have rendered as a row of question marks in Japanese,
Russian, Korean, Greek and eleven others -- and the measured width would have
been the width of the question marks, so the right edge would have drifted too.
The plan said to keep it on the body face and the comment in FontManager says so
as well; the code simply did not. The detail is two parts now rather than one
string, and each part is measured under the face that draws it.

The header was the only place in the UI pushing RegularFont directly, without the
FontsEnabled-or-UseHellionFont check every other push site makes. With both
toggles off the window draws in AXIS and the header would have drawn in
Inter-Light, at a different size, in a band measured against a third one.

And the icon sat on the text baseline. FontAwesome is a fixed-width handle built
at Dalamud's own size and does not follow the plugin's font setting, so at any
other body size it hangs. The sidebar already knew this and centres against the
row; the header does the same now.

Two smaller ones came along: the minimum-height threshold was compared unscaled,
which would have dissolved it at higher display scales, and the height passed
into that check included the input row -- so "is there still room to read" was
measuring the wrong thing. Both callers now say what sits below them.
2026-08-19 10:21:03 +02:00
JonKazama-Hellion 100290ea4d feat(chat): put the channel header above both windows
Drawn before the scroll child in each, so it stays put while the log moves. The
child's height is deliberately left alone: it is given as a negative value, and
ImGui resolves those against the space still available from the current cursor --
which the header has already reduced. Subtracting it a second time would have
opened a gap of exactly the header's height above the input row, in both windows.

Where the name shows follows one rule: never twice on the same screen. The
sidebar layout is the only place nothing else carries it, so that is the only
place the header says it. The top-tab strip carries it, and a pop-out with its
title bar on carries it in the title.

The pop-out's plain title row is gone, replaced by the header. The comment it
left behind is worth keeping in mind -- an earlier header row was removed exactly
because it repeated the tab name one line below the title bar. That warning is
now the rule rather than a reason to have no header at all.

The trailing detail is the home world and the clock, and the clock follows the
same Use24HourClock setting the message timestamps do. Two clock formats in one
window, with the header sitting directly above a column of timestamps, would be a
defect rather than a preference. Culture is pinned for the same reason it is
pinned in the message list.
2026-08-19 09:58:29 +02:00
JonKazama-Hellion 74f9ff7f16 feat(chat): a header that says which channel you are in
Small caps with wide tracking, not a smaller size. The mockup asks for one pixel
below body text, and one pixel would have cost a whole additional font handle at
full glyph range -- tab names are free user input and can be CJK. Tracking reads
the same in every palette and costs nothing, which is the same argument that
settled the section headings in the settings window.

Only the world and the clock use the meta face. Its glyph range is ASCII plus a
middle dot, so anything that gets translated has to stay on the body face.

Two things it will not do. It drops the tab name where one is already on screen:
a pop-out with its title bar on carries the name in the title, and the top-tab
strip carries it too. Repeating it one line below is the exact defect that got an
earlier header row removed, and the comment left behind at that removal is what
made this rule. And it disappears entirely below a minimum message-area height --
the window minimum is 260px, already shared with the honorific header, the input
row and the status bar, and all of it scales together. A header that leaves two
readable lines is worse than no header.

Both decisions are arithmetic and sit in ChannelHeaderLayout with tests. The
gallery gets an entry despite the header needing a tab and the font handles,
because that window exists precisely because pieces once shipped without a call
site.
2026-08-19 09:55:13 +02:00
JonKazama-Hellion 0551116a0c refactor(sidebar): let the channel header resolve the same icon
Visibility only. The header has to show the icon the sidebar row shows, and the
lookup table alone would not do it: it only answers for a tab with an explicitly
chosen icon, and the default is none. Most tabs reach their icon through the
derivation this method wraps.
2026-08-19 09:52:49 +02:00
JonKazama-Hellion 64f7d9b975 style: let csharpier reflow the widened tuples
Formatting only. The five-element fingerprint tuple and its field declaration ran
past the line limit, and preflight block E is stricter than the check I had been
running per task -- it caught what the narrower filter did not.
2026-08-19 09:51:53 +02:00
JonKazama-Hellion 81456c981b test(selftest): show what each type role actually resolves to
Block A ends with no call site in the message list -- that arrives in block C --
so without this step there would be nothing to look at and two helpers with no
caller at all.

It draws rather than asserts, because asserting proves the wrong thing here.
SimplePushedFont pushes nothing at all when a handle is not ready, silently, and
the text then renders in whatever face was already active. A step that compares
two numbers and reports Pass would sail straight past that. So this one puts a
timestamp, a sender and a body line next to each other and lets them be looked
at, with expected-against-actual printed underneath.

The three weight buttons exist because the alternative was three builds and a
plugin restart between each, and nobody compares a typeface across a restart.
RebuildDelegateFonts is synchronous on this thread, so the sample row picks up
the new rasterisation on the next frame.
2026-08-19 09:50:56 +02:00
JonKazama-Hellion 0916f8d1fb feat(style): put mixed sizes on a shared baseline
ImGui lines items up by their top edge. ItemSize only shifts anything when
CurrLineTextBaseOffset is non-zero, and that stays zero unless
AlignTextToFramePadding ran -- so a meta timestamp beside a body-sized name would
sit flush at the top and float above the baseline.

The correction is the difference of the two ascents, scaled. The scaling looks
like it is applied twice and is not: Dalamud rasterises at SizePx * GlobalScale
and then divides the metrics back down, so ImFont.Ascent comes out logical.
Drawing multiplies it up again. The comment says so, because the first reviewer
to see this file read it the other way.

No call site yet -- the self-test in the next commit takes it, and the message
list takes it in block C.
2026-08-19 09:49:21 +02:00
JonKazama-Hellion 147034bda5 fix(layout): four axes that could always stale the cache, and the new roles
The height cache keys on a fingerprint of everything that can change a row's
height. Four things that can were never in it:

- ItalicFontV2.SizePt. ChunkRenderer pushes the italic face mid-row for emphasis,
  so there have been mixed sizes in a single line all along -- nobody called it
  that. Changing only the italic size moved every wrapped row and left the cache
  untouched.
- ItalicEnabled, which swaps between ItalicFont at its own size and AxisItalic at
  the base size.
- FontsEnabled and UseHellionFont, which swap the face outright. This pair is the
  quiet one: both size fields default to 12.75f, so the fingerprint did not move
  at all while the glyph widths underneath it did.

On top of those, the two new role sizes. They follow the base arithmetically, but
the resolved value is what belongs in the fingerprint -- a theme override moves
the base without moving any factor.

The three toggles go in the discrete half so they bypass the settle window, the
same way density already does. The sizes are sliders and wait it out.

Falsified rather than assumed: dropping FontsEnabled back out of Discrete turns
the new test red, so it is measuring the axis and not just passing.
2026-08-19 09:48:35 +02:00
JonKazama-Hellion 34e343d8f2 feat(fonts): a handle for the sender and one for the meta line
Two roles need a face of their own, and they need it for opposite reasons.

The sender is meant to carry weight. The mockup says 600, and there is no bold
face anywhere in the plugin -- the bundled file is Inter-Light and the game's
Axis is a single weight. So the weight comes from rasterising the same outline
denser, via RasterizerMultiply. That only works on the delegate path: with both
font toggles off the game font handle draws and has no such knob, and the sender
falls back to leaning on channel colour alone. Deliberate limitation, not an
oversight.

The meta face goes the other way: smaller, and on a glyph range of about eighty
entries instead of the full set. Timestamps and world names are Latin in every
client FFXIV ships, so ASCII plus the middle dot covers what this face will ever
be asked to draw. A full range would have rasterised the whole CJK block a second
time for nothing. Anything translated stays on the body face.

Two things in the rebuild path had to change with them. The handles now go up
inside a SuppressAutoRebuild block -- without it, one size change meant four
separate atlas rebuilds instead of one. And FontsReady checks both new handles
unconditionally, unlike ItalicFont which is allowed to be null: a handle that is
not ready makes SimplePushedFont push nothing at all, silently, and the first
frame after a rebuild would measure the wrong face and write those heights into
the row cache.

Both font self-tests were extended to match. A handle nobody asserts is a handle
that can go missing for a release without anyone noticing.
2026-08-19 09:46:34 +02:00
JonKazama-Hellion 6d2bb95528 feat(style): name the four type roles
Body, Sender, Header, Meta. Three of them share the base size, which looks like
an oversight and is not: the sender is set apart by weight and the header by
small caps with wide tracking. Neither of those is a size, and solving them with
size instead would turn the log into a ransom note. Only meta steps down, because
it is meant to be skipped over rather than read.

Factors sit in a static array rather than as consts. The master spec puts
typography under theme control, and ThemeTypography is already the declared
extension point for it -- a const would wall that off before anyone gets there.

No caller yet. FontManager takes the first one in the next commit; that is the
one place in this cycle where a piece lands before its call site, and it closes
inside the same block.
2026-08-19 09:44:31 +02:00
JonKazama-Hellion 58830aecec feat(style): the arithmetic behind a type scale
Quarter-point rounding, not whole points. The base size is 12.75pt, so rounding
a derived role to whole points would move it further than the step between two
adjacent base sizes -- the scale would quantise away the difference it exists to
express.

The floor is what keeps the meta role legible when someone runs a small base
size; below about seven points a timestamp stops being readable at any display
scale.
2026-08-19 09:44:05 +02:00
JonKazama-Hellion fc67c0852a fix(text): give the wrap calculation the scale imgui actually asks for
CalcWordWrapPositionA takes a scale, and imgui means size / FontSize by that --
the ratio between the size being rendered and the size the face was baked at
(imgui_draw.cpp, CalcTextSizeA). We were handing it ImGuiHelpers.GlobalScale.

That is the same number today, but by coincidence rather than by design. Dalamud
bakes every font at SizePx * GlobalScale and then divides the metrics back down,
so g.FontSize / font->FontSize lands on GlobalScale for every handle regardless
of its size. The coincidence holds only while one face draws a line.

The typography work starting with this cycle puts a second size into the same
line, and there the two numbers separate: the wrap would be computed for the
wrong size while CalcTextSizeA keeps measuring with the right one. Measured
height and drawn wrap would drift apart, and the virtualised clipper plans
against the measured value.

No behaviour change expected here -- the expression evaluates to what the old
constant already was.
2026-08-19 09:43:19 +02:00
JonKazama-Hellion b388dcb2de chore(release): close the v1.12.0 cycle
Version to 1.12.0 in the csproj, with the changelog and roadmap entries for the
local state. Not published: the public release stays at v1.5.6, repo.json keeps
its 1.5.6.0 manifest and all three download links are untouched.

The changelog leads with the storage change rather than burying it under fixes.
Until this release the unknown-channel failsafe was applied to known channels
too, so an installation that had unticked channels and left the failsafe on was
storing more than the grid said. From here the grid decides, which means those
installations store less than they did yesterday. Nothing already written is
touched, but a user who compares the database against last week should find the
reason in the notes and not have to guess.

Config version 25 is recorded there for the same reason. There is no migration
behind it -- the storage rule changed shape, and the stamp says so.

Known issues carry the honest tail: the database viewer and the emoji picker are
still English, 245 orphaned resource keys remain as the inventory of what the
v1.6.0 rebuild lost, and the licence text still contradicts itself between the
About tab and the translated resources.
2026-08-19 08:35:18 +02:00
JonKazama-Hellion e0c9efca05 i18n: the five spots the smoke test found, and a BOM in the export
Reported from a real pass through the window.

The theme categories were a static readonly array, so the five names
froze at whatever language the plugin started in and a runtime switch
relabelled the entire window except them. Same shape as the layout
labels earlier in this cycle; this one got missed because replacing the
literals with resource lookups looks finished until you actually switch.

The status bar built its counts from English literals -- tab, tabs, msg,
tell, tells -- and the privacy pill said "Privacy-First" in all 25
files. The thousands separator follows the user's culture now too, so
German reads 1,2k rather than 1.2k.

The live preview claims to show what the window will look like. It was
showing English channel names next to a translated placeholder, which is
worse than either. Channel labels come from ChatType.Name() now and the
status slots share the strings with the real status bar. The four mock
chat lines stay English on the earlier decision.

And the export wrote a byte order mark. Encoding.UTF8 emits one, and a
leading U+FEFF makes the JSON invalid for every strict parser --
confirmed against a real export from the game, where python's json.load
refused the file. CSV keeps its BOM, because without one Excel guesses
the codepage and mangles every non-ASCII name.

The self-test that was supposed to catch that read the file with
File.ReadAllText, which strips a BOM while detecting the encoding. It
reads bytes now.

The status bar tests asserted English literals and started failing on a
German machine -- they pin a fixed culture now instead of inheriting the
locale of whoever runs them.
2026-08-19 08:24:31 +02:00
JonKazama-Hellion fdb1a98519 fix(privacy): the cleanup could never be applied, and three more from the audit
The cleanup preview marked itself stale before it could be drawn. The
gate bumps a revision on release so a preview cannot survive a wipe; I
then made the preview take the gate, so its own release invalidated it
every single time and the apply button never appeared. The feature has
been shipping non-functional since it was written, with a self-test that
asserted the exact bump that killed it.

Read-only operations no longer move the revision, and preview and
maintenance have their own marks instead of borrowing Cleanup -- which
also stops the five-second metadata refresh from expiring previews, and
stops the UI announcing "another operation is running: cleanup" during a
VACUUM.

The JSON export produced invalid JSON. The chat relation kinds were
interpolated straight into the output, and interpolating an enum writes
its member name, so every message with a recognised relation came out as
"source_kind":LocalPlayer. That is the file a GDPR access request goes
out on. The self-test wrote a JSON file and never parsed it; it does now.

Retention with the limit at zero still deleted. The slider is labelled
"0 = never" and the sweep seeded 31 spec defaults unconditionally before
reading the user's overrides, so zero still lost free company, linkshell
and party history after ninety days -- and the short-circuit written for
exactly this case could never be reached, because the map was never
empty.

A wipe that worked reported that it had failed. VACUUM needs the
database to itself, the refilter walks a lazy reader on the primary
connection outside the lock, and the two collide -- after the DELETE has
committed. The delete paths no longer let that escape: the rows are
gone, an uncompacted file is a housekeeping problem, and telling
somebody their history is still there when it is not is a different kind
of problem.

Also:

- CSV cells starting with =, +, - or @ get a leading apostrophe. The
  content is text other people typed into a chat channel and the file
  exists to be opened in a spreadsheet.
- An export that matched nothing no longer replaces the previous one. It
  used to write its header, move it into place, and then report that
  nothing matched. Dalamud's save dialog offers no overwrite
  confirmation to fall back on, so this is the part that had to move.
- The retention sweep says so when it loses the race for the gate, and
  routes its notifications through the teardown check like everything
  else.
2026-08-19 07:17:33 +02:00
JonKazama-Hellion 9ea9e96145 fix: close what the style review found, starting with a gate the metadata skipped
The real defect first. RefreshDatabaseMetadata was the one worker of six
that never took the shared lock, and its flag was the one of six missing
from the tab's busy state. It calls MessageCount, which holds the read
lock, so a wipe could start while it was in there -- and the tab would
not have known to grey the button, because it could not see the worker.
Both halves fixed. The pattern is why: seven near-copies of one worker
skeleton, and each copy decided something slightly different.

The clear button failed silently when its thread could not start. The
most destructive control in the plugin, pressed, and nothing happens,
with no way to tell that from a wipe that worked -- while the three
harmless workers beside it do report. Maintenance was the mirror: its
comment promises refusals are said out loud, and then swallowed the
actual failure. Three start-failure paths also bypassed the notify
helper that carries the teardown check, three weeks after it was added
for exactly that.

The database numbers now wait for a real read, like the clear hint
already did. Zero bytes and zero messages read as an empty database, not
as a number nobody has fetched.

SelectionAfterDelete is gone, with its three tests. The accordion has no
selection, so its return value went into a discard -- a function
answering a question the interface does not ask, with green tests
guarding nothing. The project's own self-test README calls that the
anti-pattern of record.

Six new keys replaced by the translated orphans that already said the
same thing. A commit earlier in this cycle is literally called "stop
duplicating a key" and these went past it. The duplicate button also had
the label "Add", which is the one string out of ninety-four that was
never written.

Tests: CleanupDeleteTypes had none, and with the failsafe on -- how a
fresh config ships -- it is the path every cleanup takes. Four now,
including the one that matters: an empty list deletes nothing rather
than everything.

And a self-test for the gate wiring, which is what would have caught the
metadata worker. The unit tests prove the gate works; nothing proved the
workers use it.
2026-08-19 07:09:54 +02:00
JonKazama-Hellion e7b76fb21b chore(i18n): remove the keys whose features were removed on purpose
Fifty-four keys across 25 files, and a script that can find the rest.

The script reports rather than deletes, because a key with no caller is
a question and not a verdict: this cycle has twice found one that only
described a feature whose button was torn out, and deleting it would
have made the restoration cost 25 files of re-translation. The answer
lives in the git history of the deletion, not in a grep.

So only the ones where that history says the feature went on purpose:
the web interface, the settings card overview the sidebar replaced, the
save-and-discard model this window does not have, the LiteDB migration
dialog, and three one-time announcements for versions long past.

Plus nine this cycle superseded itself, including the three notes
telling the reader to press Save first.

251 keys still have no caller. That is not a to-do list -- it is the
inventory the search pass turned up: timestamp layouts, collapse
duplicate messages, the About tab's prose, the honorific glow, the
novice network button. Every one of them describes something that used
to work. They stay until each has been decided one way or the other,
which is the whole premise of this cycle.
2026-08-19 06:51:56 +02:00
JonKazama-Hellion bbfb9fc630 feat(tabs): the tab editor is back
There has been no way to create a tab, delete one, reorder them, or
choose what any of them collects since the settings window was rebuilt
in May. The five tabs a config happened to have were all a user could
ever have. Every label for this was sitting in the resources, translated
into 25 languages, with no caller.

An accordion, not the list-and-detail pane the plan sketched. The
settings column is narrow, every other tab in this window is a stack of
collapsible sections, and a split pane inside one of them would be the
only thing here that reads differently without buying anything.

Channels go through the matrix that already existed, unchanged: it knows
the groups, the sub-matrices and the ExtraChat channels, and it is
localised. What it does not know is copy-on-write -- it mutates the
dictionary it is handed -- so it never gets the tab's own. Edits land in
a working copy and are published as one reference swap when the user
leaves the tab, and only if something actually changed.

Saving is deferred behind a dirty flag with a short idle, not
IsItemDeactivatedAfterEdit. That idiom defers for sliders and text
fields, which stay active across frames; a checkbox activates and
deactivates inside one click, so it would fire exactly as often as the
return value and write the config file once per box.

Deleting closes the pop-out first, or the pool keeps a slot bound to a
tab that no longer exists. The last editable tab cannot be deleted at
all: the message list has no empty state. Temp tabs are not editable
here -- their name is a conversation partner and the auto-tell service
owns their lifetime -- so they are skipped entirely.

Also E2: Tab.AddMessage stamps LastActivity for every message now. The
condition that used to gate it filtered on InactivityHideChannels, a
setting belonging to hide-when-inactive, and that feature lost its
reader in cf4705e. Which tell tab the pool drops first -- the only thing
that reads the stamp -- was hanging on a setting for something that does
not happen. The three channel fields behind it are gone.
2026-08-19 06:50:23 +02:00
JonKazama-Hellion 3a1b863def i18n: adopt the client's word for tell, and translate the appearance tab
Two decisions, both yours.

Where Square Enix ships a client in a language, that client's word wins.
German says Flüstern, French message privé, Japanese テル, Korean
귓속말, Simplified Chinese 密语. Everywhere else there is no official
client and the loanword is what players actually say, so it stays.

That meant rewriting the whole corpus in those languages rather than
just the new keys, which is the reason the split existed in the first
place: forty German values and thirty-seven French ones carried the old
word. /tell is untouched, because that is a command and not a noun.

The mechanical pass left French with three agreement errors -- "des
message privé", "ce onglet", "messages de message privé" -- which is
what happens when you substring-replace a language with gender and
number. Fixed by hand.

Traditional Chinese keeps 悄悄話, which the file already used nineteen
times; it has no official client of its own.

The appearance tab is translated as well. Thirty keys: the colour
editor's groups and buttons, the theme categories, fork and import and
export, the font labels and the preview's input placeholder. Theme token
names stay English -- WindowBg and TextPrimary are JSON keys, not
prose -- and so do the brand strings.

465 keys, 25 files, no gaps, no orphans, no placeholder drift.

Still English and not in this commit: the database viewer behind
/hellionView, which is a documented user command rather than a
developer tool, and the symbol picker. Both are their own block.
2026-08-19 05:11:05 +02:00
JonKazama-Hellion 5991f49c59 i18n: hold the new strings against the glossary the plugin already had
A language review over all 24 files found that the translation batch had
been written as if the file were empty. It is not: eleven years of
FFXIV vocabulary and several hundred existing values already decided how
this plugin says things, and the new strings disagreed with them.

Three said something wrong rather than something unusual:

- Norwegian called opacity "tetthet", which is density -- and the same
  file uses that exact word for compact density, so one word stood for
  two different settings.
- Danish called the brand section "Brand", which in Danish is fire.
- Turkish called a manifest a notification, in a window that already has
  a notifications section.

Then the glossary itself. "tab" is zakładka in Polish, tab in Romanian
and Danish; "plugin" stays plugin in five languages that had translated
it; "pop-out" is a loanword in six. The Shift key keeps its name in
Swedish, Finnish and Italian, because that is what is printed on it.
Fixing pop-out also shortened the three longest row labels in the batch
past the point where SettingRow would have clipped them.

Czech was the clearest outright error: the existing file addresses the
reader informally throughout, and five new strings switched to the
formal form.

And a gap in my own previous fix: I had moved four of the five tell
strings to the established loanword and missed the fifth pair, so the
failed-tell warning still said "Flüstern" while its neighbours said
"Tell".

435 keys, 25 files, no gaps, no orphans, no placeholder drift, and every
one of the five tell keys now carries the same word in every language.

Whether German and French should say Flüstern and message privé rather
than tell is a real question -- both game clients do -- but that is a
glossary decision for the whole file, not something to introduce through
four new keys.
2026-08-19 00:28:24 +02:00
JonKazama-Hellion b3c0ec73ca i18n(settings): keep the game's own vocabulary, and stop duplicating a key
A review pass found the translation commit had introduced a split rather
than closed one.

Every existing string in this plugin leaves "tell" untranslated -- it is
what the game calls the thing, in twenty values across the German file
alone. My new keys translated it, so the channels tab showed "Auto-Tell-
Tabs" and, two rows below, "Flüstern automatisch öffnen". Thirteen of
fourteen sampled languages had the same break. The four affected keys
now use the established word, which also fixes a German line that had
tells "aufgehen" like a door.

Turkish said the opposite of what the control does: saydamlık is
transparency, the slider is opacity, and 1.0 means fully opaque. Every
other language uses the opacity word. Corrected, along with the inactive
one beside it.

Compact density had grown a second key for a field that already had one.
Appearance_UseCompactDensity_Name and _Description were sitting there
with no caller -- which is precisely what this cycle exists to fix -- so
the chat tab uses those and my duplicate is gone again. The German
wizard label for the same field said "Kompakter Density-Modus"; it says
the same thing as the other two now.

Also: Hungarian called a pop-out window "kiemelt" (highlighted), the
project's word is "kiugró"; German called it "ausgeklappt", which is
what a menu does; Italian and Polish had a clumsy inactive-opacity
label.

435 keys, all 25 files, no gaps, no orphans in either direction.

One claim in the previous commit was wrong and is worth stating plainly:
it said only log lines, thread names, developer tools and brand strings
remained English. The appearance tab is still English throughout -- the
theme picker, the colour editor, the font section, the live preview --
and so are parts of the database viewer, which is a documented user
command, not a developer tool. That is a separate block, not a footnote.
2026-08-19 00:24:28 +02:00
JonKazama-Hellion 83306f1f47 i18n(settings): translate the settings window
The settings window was rebuilt in v1.10.0 and v1.11.0 with its labels
written straight into the C#. Every section heading and most row labels
across five tabs read English in all 25 languages, which the memory
notes had accepted as a known backlog.

Forty-nine keys close it: seventeen section headings, the rows under
them, and the two layout choices. All 25 languages, 437 keys each, no
gaps and no placeholder drift in either direction.

The layout labels are a property rather than a static array now. A
static one would hold whichever language the plugin started in, and this
plugin switches language at runtime.

What stays English, deliberately: log lines and thread names, which no
user reads; the two developer tools behind Shift plus Ctrl+Shift, per
the decision that developer surfaces stay English; and the About tab's
brand lines, attributions and licence identifiers, which are names.
2026-08-19 00:08:19 +02:00
JonKazama-Hellion 6bb020547d feat(tabs): groundwork for the tab editor
The two halves that can be settled without a window, before the window
exists.

Concurrency first, because the editor is the first thing that ever
writes SelectedChannels after load. All three channel-filter fields are
read without a lock from the pending-message thread, the filter worker
and the draw thread, and mutating a live Dictionary while Matches walks
it is the standard way to get a wrong answer on somebody else's stack.
ReplaceChannelFilter builds the replacements and swaps the references,
so a reader sees the old set or the new one and never half of either.

It deliberately stops short of making the three writes one atomic step.
A reader can catch the new dictionary with the old ExtraChat flag for a
single message, which the editor's closing clear-and-refilter
reconsiders anyway. Doing better would mean one reference for all three,
and all three are serialized fields whose shape the config file already
has.

Then the index maths, which is where temp tabs make this more than list
arithmetic. They live in the same collection, they are not editable --
their name is a conversation partner and deleting one would be deleting
a conversation -- and a move has to step over them rather than swap with
them, or moving a tab down and back up would not return it to where it
started. Reversible in the editable order, which is the order the user
sees; the sidebar draws temp tabs under their own headers regardless of
where they sit in the list.

Twelve facts, and one of them started out asserting the wrong property:
that the whole list is restored by a move and its reverse. It is not,
and it does not need to be.

The last editable tab cannot be deleted. The message list has no empty
state, so a window with nothing to draw is not a state to offer.
2026-08-18 23:39:43 +02:00
JonKazama-Hellion 8e38e3e805 refactor(config): delete what was never read, restore what was only orphaned
Block D of v1.12.0. The line between the two is the whole job, and I got
it wrong once on the way: six cache fields on Tab looked dead because
nothing writes them, and nothing writes them because AutoTellTabTint and
TabTintCache went out with the chat window in cf4705e. Deleting the
fields would have cemented a loss instead of recording a decision.

So they are back, and the sidebar uses them again: an auto-tell tab is
tinted and glyphed from its partner, twelve colours against seven icons.
Four open tells are no longer four identical envelopes in one colour.

Their own header promised the same partner keeps its colour "across
sessions" while hashing with string.GetHashCode, which .NET salts per
process -- every game start reshuffled every tab. FNV-1a now, with a
lowbias32 finalizer that is not decoration: without it a probe over 144
similar keys reached six of the twelve colours, because the caller takes
the low bits with a modulo and FNV leaves those correlated. Three pinned
values guard it, which is also the only assertion that can catch a
regression to a salted hash.

The same question, asked of the three hide conditions this block had
quietly orphaned: HideDuringCutscenes, HideInBattle, HideWhenNotLoggedIn
all had readers in v1.5.6 and lost them in the same commit. Two of them
are states rather than conditions -- a cutscene the user dismissed stays
dismissed until it ends, and combat must not seize a chat that is
already hidden for another reason -- so they come back as a small state
machine with eight pinned transitions, and three toggles whose labels
were already translated in all 25 languages.

Actually deleted, with a reader search each time:

- Six per-tab hide fields. Their reader was the pop-out window and it
  stopped consulting them in cf4705e. Per-tab was the wrong unit anyway:
  "hide during cutscenes" is a statement about the screen.
- Tab.ChatCodes, whose migration the v16 schema gate had already made
  unreachable.
- InactivityHideTimeout and InactivityHideActiveDuringBattle, MaxLinesToRender
  which had stopped bounding anything, and the 155 lines of
  Configuration.UpdateFrom with no caller at all.

Config version 25, at all three places that carry it. No migration step:
the gate only refuses anything under 16 and Json.NET drops keys it does
not know, so the deleted fields simply stop being written.

One thing a review pass caught that matters more than any of the above:
the clone parity guard had gone hollow. It compares collections by
count, ChatCodes was the only collection the probe seeded, and removing
it left the guard comparing zero against zero. Verified by making
Tab.Clone discard both remaining collections and watching every
assertion stay green. The probe seeds them now, and the same sabotage
fails as it should.
2026-08-18 23:36:54 +02:00
JonKazama-Hellion 6829a80ff2 refactor(wizard): drop LoadPreviousSession, which never did anything
The wizard asked for it, coupled it to a neighbour, listed it in the
summary as applied, and wrote it to the config. No code in the plugin
has ever read it. A wizard that collects a decision and reports an
effect that does not happen is worse than one that never asked.

Its partner, FilterIncludePreviousSessions, has a real reader and stays.
The coupling was two-way -- switching this on forced the partner on,
switching the partner off forced this one off -- so removing it leaves a
single checkbox that means what it says, and the summary line now
reports the setting that actually took effect.

The self-test asserted that skipping step 3 does not overwrite either
field. That assertion is rewritten rather than repaired: it was pinning
the null-semantics of the wizard's pending state, which still matters,
just with one field instead of two.
2026-08-18 22:54:06 +02:00
JonKazama-Hellion e8d06e05fa fix(input): put the right-click menu back on the input field
Reported by a tester: the input field in v1.5.6 had right-click actions,
the current one has no right-click at all.

Correct, and the commit before this one put those actions in the wrong
place. The v1.5.6 menu was ImRaii.ContextPopupItem("ChatInputContext")
bound to the input field itself; I hung the two token entries off the
chat-message menu, where the rest of that window's items had landed.
They are on the input field now, which is also where they are useful:
you insert a token while composing, not while reading.

Two details from the original that the misplaced version had lost:

- The agent pointers are null-checked. Both can be null during a zone
  transition, which is exactly when somebody is most likely to be typing
  a flag into party chat.
- Inserting refocuses the field and puts the caret behind the token.
  Picking from a menu and then having to click back into the field is
  the kind of friction that makes a feature go unused.

Hiding the chat is not repeated in the menu. It was in the v1.5.6 one,
but it has its own button two widgets to the right now, and one way in
is enough.
2026-08-18 22:45:36 +02:00
JonKazama-Hellion 3cb20b6f65 feat(chat): insert map-flag and item-link tokens again, and finish the resx parity
The two menu items went out with the v1.5.6 chat window in cf4705e.
Their strings stayed, in the base file only, so the feature was gone and
its labels were untranslated in all 24 other languages.

They live in the chat context menu now, next to hide-chat, which is
where the rest of that window's items ended up. The game expands <flag>
and <item> at send time, so appending the literal token is the whole
implementation; each entry is disabled while its precondition is missing
so a token cannot be sent only to expand into nothing at the other end.

ChatLog_ScrollToBottom_Tooltip is the one that stings: it has had a
caller since v1.9.0 and existed in the base file alone, so every player
outside English read that tooltip in English.

All three are translated now, and with them the resource files are at
full parity for the first time -- 25 files, every key in every one.
2026-08-18 22:43:09 +02:00
JonKazama-Hellion 75c4acd19a feat(settings): make eleven settings reachable again
Every field here has a reader in the running plugin and had no control
in the window. The config file was the only way to any of them.

HideChat is the one that mattered. It defaults to on, it suppresses the
game's own chat window, and the only other path to it is a right-click
item that sets it to true and can never set it back. Anyone who used
that item once had to edit JSON to get their chat back. The new toggle
reads and writes the same field, so the two agree instead of fighting.

The rest, by tab:

- Window: the three remaining hide conditions, the preview minimum that
  belongs to a toggle already on screen, native item tooltips and their
  offset.
- Chat: an emotes section holding the BetterTTV switch, the cache state,
  and an editor for the blocked-code list that has been in the config
  since v1.0 with nowhere to edit it. Blocking one code is a finer
  instrument than switching emotes off wholesale. Plus auto-translate
  sorting.
- General: the failed-tell warning. The game reports a failed tell in
  the log only, where somebody who is typing does not see it.
- Data & Privacy: battle messages. It decides whether battle lines are
  persisted at all, which makes it a storage decision rather than a chat
  one, and it sits in front of the whitelist rather than inside it.

MaxParallelPopouts stays out. It is read once in the pool's constructor,
the windows are registered once, and registering at runtime is
explicitly forbidden -- a slider would do nothing until a reload, which
is exactly the kind of decoration this cycle exists to remove.

Four new strings in all 25 languages; the other fifteen labels were
already translated and waiting.
2026-08-18 22:40:54 +02:00
JonKazama-Hellion 1bb968211b feat(tabs): give pinning a way in, and a way back out
Pinning has been complete since v1.4.7: pools, a cap of five,
persistence, logout symmetry, the notification when the cap is hit. The
menu that called it went away, and nothing has called it since.

That is not only a missing feature, it is a dead end in saved data. A
tab pinned in v1.5.6 survives every save and load and permanently
occupies one of the five slots, and there has been no way anywhere to
release it. Unpin is the reason this task exists.

So the context menu grows a pin section for temp tabs, and the sidebar
grows the marker that says which rows are pinned -- a small thumbtack in
the icon's lower left, drawn from the row's own rectangle so it claims
no layout of its own. The unread badge owns the upper right, and badges
in this sidebar are where drawing into unreserved space caught this
project last.

At the cap the item is disabled rather than hidden: that is a state the
user can undo by unpinning something, and the tooltip switches to say
which. Five translated strings that had no caller now have one.

Promote-to-permanent stays out. It was removed on purpose after a tester
kept hitting it by accident. Reconnecting every method that lost its
caller, without asking why it lost it, would rebuild the problem this
cycle is supposed to be cleaning up.
2026-08-18 22:35:13 +02:00
JonKazama-Hellion 4f3bdc3c7b fix(privacy): translate the channel list and give it back its presets
Reported after a translation pass: the channel list in Data & Privacy
reads English in every language. It was not, as assumed, the client's
own naming -- the list called ToString() on the enum member, so it
showed "FreeCompanyLoginLogout" while Language.ChatType_* sat next to it
holding "Freie Gesellschaft (An-/Abmeldung)" in all 25 languages.

Fixing the label exposed how little else the list had. Six translated
strings written for exactly this control had no caller: the explanation
of what it does, the note that it governs the database and not the chat
window, and three presets -- data minimisation, select all, deselect
all. Eighty-nine checkboxes without a "recommended" button is not a
choice anyone makes.

So the list now carries the same eight groups the export uses, with the
individual channels one fold away. It is the one control that decides
what reaches the database; the two screens describing channels the same
way is worth more here than anywhere else.

The unknown-channel switch gets its description back, and the telemetry
section stops being the last English literal in the tab. It stays a
statement rather than a switch: a toggle would imply there is something
to turn off.
2026-08-18 22:30:12 +02:00
JonKazama-Hellion 9b634f54e9 fix(tabs): stop a tell from a popped-out partner hijacking the main window
Reported from the field: a new tell from someone whose tab is popped out
throws the main window onto the General tab, every time.

The router revealed the tab by activating it in the main window. That
window does not display a popped-out tab -- PickMainActiveTab re-anchors
on the next frame, and it anchors to the FIRST non-popped tab in list
order, not to the one the user was reading. So the reveal did nothing it
intended and threw away the active tab on the way.

Nothing needed revealing in the first place: the tab was already on
screen in its own window. That decision now lives in PlanTellReveal,
next to the pop-out helpers it belongs with, pure and pinned by nine
facts -- the mode, the switch and the popped-out state have eight
combinations between them and only two of them should touch anything.

ActivateTab refuses a popped-out tab outright as well. The router is the
caller that got it wrong, but the invariant belongs to the window: its
active tab is never one that something else is drawing.
2026-08-18 22:25:23 +02:00
JonKazama-Hellion 4f4f5fc86a docs: finish holding the documentation against the code
The first pass through A6 caught the dead file paths and the two GDPR
sections. A review pass walked every remaining claim and found eight
more that no longer describe the plugin.

The privacy notice carries a "last reviewed" stamp and commits, in its
own text, to keeping it accurate for the version it describes. It still
said v1.1.0 after the block that rewrote two of its sections.

Both documents announced two outbound network calls in their opening
paragraphs. The Lodestone font download went away in v1.0.4 and the
notice explains that further down, so the summary contradicted its own
body. There is one call, and it is BetterTTV.

The emote cache is `EmoteCacheV1/`, which the notice states correctly in
one place and wrongly in two others, two lines above a line this cycle
had already touched.

The list of buttons that open a browser named a Ko-fi page that appears
nowhere in the source, an issue tracker and a website that have no
button, and left out the Discord invite and the two Honorific links that
do. In a section that exists precisely to enumerate where a click sends
you, both halves matter.

The README described the pop-out input bar as an opt-in under a settings
section that does not exist. Neither does the switch: PopOutInputEnabled
has no reader, the input bar is unconditional, and the field belongs on
the deletion list. Said plainly instead.

Also: the export narrows by age, not by a date range, in both documents;
the tab is called About, not Info, and it has no per-translator list;
the sections are Colours and Integrations.

Plus a note the users affected by it deserve: the v24 migration switches
the privacy filter off where it was on with nothing selected, and until
now that was only in the log.
2026-08-18 22:14:45 +02:00
JonKazama-Hellion 06ef0bfb1c fix(input): let the arrow keys walk the sent-message history again
Reported against v1.11.0: type a command, send it, need it again, and
up-arrow does nothing. v1.5.6 recalled it.

Everything needed was already in the tree. InputHistoryService holds the
last thirty entries with move-to-newest dedup, CompactInputHistoryNavigator
owns the cursor maths, and both have their own test mirrors. Neither had
a single caller, and the input field never set CallbackHistory, so ImGui
had no reason to raise the event in the first place.

So: the flag goes on, the callback grows a history branch, and TrySend
pushes the trimmed line before it clears the buffer. Editing ends the
walk, otherwise down-arrow after changing a recalled line would jump to
the next entry and discard the edit.

The cursor is per input bar while the history is global. Which line each
window is looking at is not something the others should inherit.
2026-08-18 22:13:41 +02:00
JonKazama-Hellion 0279a1a9d6 fix(privacy): close the gaps three review passes found in block A
The worst of them made the block's own privacy promise backwards.

PrivacyPersistChannels was given a non-empty field initializer so a
fresh config would record conversations only. Dalamud deserialises with
Json.NET's defaults, which means ObjectCreationHandling.Auto: a
collection field that already holds items gets *populated*, not
replaced. Verified against Newtonsoft 13.0.3 -- saved [] loads as the
initializer, saved [Say] loads as initializer plus Say. So the change
would have unioned the privacy-first list into every existing config on
load and switched channels back on that the user had unticked, while
also making the v24 migration unreachable and its self-test vacuous. The
field is empty again and the seeding moved to CreateFresh, which only
runs when there is no config file at all.

Cleanup could delete a channel it had promised to keep. The allowlist
could only name channels that were already in the database when the
preview ran, so an unrecognised channel whose first message arrived
afterwards fell outside it. Where the failsafe is on, the deletion now
names what goes -- known channels that are not on the list -- instead of
what stays. The window closes completely, and a listed channel that
happens to be empty right now is safe for the same reason.

The cleanup preview was the one long operation that never took the
shared lock, while holding an open reader across a full-table scan.
That is precisely the case the lock was written for.

Clearing the history reported success when it failed. ClearMessages
purges the full-text index between the delete and the VACUUM; if that
step throws, the plaintext stays on disk and the user was told it was
gone. It has its own error string now, in all 25 languages.

Also:

- One busy state for the whole tab. Cleanup, clear, maintenance and
  export reach the same store, and per-section flags left two
  destructive buttons live at once. The lock turned that into a refusal
  rather than damage, but a refusal you have to trigger to discover is
  not an answer.
- The gate carries a revision, bumped by every mutating operation that
  finishes. A preview taken before a retention sweep no longer passes as
  current afterwards: comparing it against the settings alone cannot see
  that the rows it counted are gone.
- Database metadata moved to a worker. Checking "is anything busy" first
  is not enough, because an operation can take the lock in the gap
  before COUNT(*) runs, and then the game stands still for a whole file
  rewrite.
- The clear hint stays hidden until the count has actually been read.
  "0 messages are stored" in front of the clear button is a lie told at
  the worst possible moment.
- Refusal notices read the operation once. Guard and name were two reads
  of the same field, so a run finishing in between printed a sentence
  that stopped at the colon.
- The retention sweep cannot start twice. The gate only goes busy once
  the worker reaches TryBegin, and the due-check runs every tick.
- Teardown waits up to five seconds for the store to come free rather
  than disposing the connection under a running VACUUM.
- Maintenance has its own flag and says so when it is refused; reload
  gets the same guard as its neighbour; the breakdown tree keeps its
  open state across a language switch.
2026-08-18 22:13:33 +02:00
JonKazama-Hellion 1987d745d8 docs: hold the documentation against the code
Every claim in the user-facing docs, checked against what the plugin
actually does after A2 to A5.

The two GDPR sections in PRIVACY.md describe features that only exist
again as of this cycle, so they now name the screens they live on and
say what the cleanup does with a channel this build does not recognise.
The export sentence promised a date range; the form offers an age in
days.

PRIVACY.md also named "Show emotes" in Settings as the way to stop the
one outbound call the plugin makes. That switch has had no control since
May, and a documented opt-out that only exists in the JSON is not an
opt-out, so the toggle is back in the chat tab. Both its strings were
already translated.

Dead paths: the README source tree still listed a file deleted in May,
docs/IPC.md cited a window that no longer exists, the first-run wizard
pointed at the same removed file, and the theme authoring guide sent
readers to a Themes tab that is called Appearance.

The changelog lost two double blank lines that were failing markdownlint
and holding preflight red. Preflight is green again.

Historical documents keep their old paths on purpose: the changelog and
the AI disclosure describe what was true when they were written.
2026-08-18 21:51:15 +02:00
JonKazama-Hellion e24ea79302 feat(privacy): reconnect database maintenance and the manual retention run
Two sections that had backends and no buttons.

Database: path, size, WAL size, message count, and a clear button. The
numbers refresh at most every five seconds and not at all while a long
operation owns the store -- MessageCount takes the read lock, and asking
for it during a VACUUM means waiting for the whole file to be rewritten,
on the draw thread. The old version called ClearMessages straight from
the draw thread, VACUUM included; it runs on a worker now.

One line beyond the old layout sits above the clear button: how many
messages are stored, and that exporting keeps a copy. Whoever is about
to throw the history away should be told there is a way not to.

The legacy Chat 2 files only get a block when they are actually on disk,
and the advanced tools only appear when the section is expanded with
Shift held. The message injector is not back: it was deleted with the
tab and writing 10,000 fake messages into a user's real database is not
something to rebuild on the way past.

Retention: an "apply now" button, the running hint, and the last-run
line, which v1.11.0 shipped as an English literal while both strings sat
translated in all 25 languages. Plus reset-to-spec next to the existing
clear-overrides, since the two answer different questions and both were
already translated.

Retention_Apply_Tooltip stays unused and gets a replacement. It ends
with "Save your changes first", and the window it was written for had a
Save button.

Also here, found while wiring the manual trigger:

DbOperationGate.End now takes the operation it releases. It used to
reset blindly, on the reasoning that a worker must be able to release
from a finally without knowing whether it acquired. That is backwards: a
worker whose TryBegin was refused also runs its finally, and a blind
reset there hands away the lock of whichever operation actually holds
it. Worse than no gate, because the refused worker walks off believing
it did nothing while a VACUUM starts under somebody's open reader.
2026-08-18 21:47:50 +02:00
JonKazama-Hellion 1ab7ba8377 feat(privacy): reconnect the retroactive cleanup
The privacy filter only decides what gets written from now on. Whatever
was stored before the user narrowed their channels stays there until
something removes it, and that something has had no button since May.

Two rules shape the section, both because this deletes history and
cannot be undone:

- Without a preview the apply button does not exist. Not greyed out,
  absent. A disabled button is something a user waits for; a missing one
  is something they have to go and earn.
- A preview that no longer matches the settings counts as no preview.
  The old version only recoloured the number and left the button live,
  so a changed whitelist could be applied against counts computed for
  the previous one.

The mapping from the live rule to CleanupRetainOnly is the part worth
reading twice. CleanupRetainOnly takes one set and deletes everything
else, so it can only stand in for the live rule where that rule narrows
something: filter off means nothing is filtered, and an empty list means
a full wipe, which has its own button and its own confirmation. Both
cases now say so instead of offering a destructive action that does not
mean what it looks like.

Inside that, the allowlist is the whitelist itself plus any stored
channel this build does not recognise while the unknown-channel failsafe
is on. Deriving it from the counts instead would delete messages that
arrive on a whitelisted but currently empty channel between the preview
and the apply, and dropping the unrecognised ones would defeat the
failsafe, which exists to hold on to a new patch's channel until the
user has decided about it.

The preview runs on a worker over its own connection. It is a GROUP BY
across every stored row, the old version ran it inline on the draw
thread, and holding the read lock for it would stall UpsertMessage on
the framework thread for the length of the scan.

Cleanup_Help_SavedNote stays unused: it tells the reader to press Save
first, and the window it was written for had a Save button.
2026-08-18 21:38:34 +02:00
JonKazama-Hellion 94af81a961 fix(privacy): let the channel grid actually decide what is stored
IsAllowedForStorage applied the unknown-channel failsafe to known
channels too. Untick Say in the grid, leave "Save unknown channel types"
on, and Say kept being written -- and that failsafe is on by default.

So a config that never met the wizard ran with the filter enabled, an
empty list and the failsafe on, which stored everything while the
filter's own description promised "only messages from allowed channels
are written to the database". The grid was inert for exactly the users
who had not been walked through the wizard.

The rule now reads: on the list, or unknown and the failsafe allows it.
A known channel the user did not pick stays out.

That correction alone would turn "stores everything" into "stores
nothing" for those same configs, so two things move with it:

- Config v24 switches the filter off where it was on with nothing
  picked. Same behaviour as before, stated where the user can see it,
  and one line in the log saying so. A config that does have picks keeps
  them and starts honouring them, which is the point of the change.
- A fresh config seeds the list from PrivacyFirstWhitelist instead of
  starting empty. Privacy by Default was already the documented intent;
  it just relied on the hole to stay usable.

The rule lives in its own type now. Configuration implements a Dalamud
interface, and the build suite cannot load Dalamud.dll -- the runtime
resolves the declaring type before reaching the method body, so even a
static call on it fails. Fifteen cases pin the truth table and the
migration condition; the self-test checks the running config is not in
the state the migration exists to undo.
2026-08-18 21:32:02 +02:00
JonKazama-Hellion d59ee62223 feat(privacy): reconnect the message export
The exporter has worked since v1.4.8. The form that drives it went out
with the old settings window in May, which left PRIVACY.md promising an
access request the plugin had no way to answer.

New section in the data and privacy tab: time range, sender substring,
channel groups, format, and a save dialog. Form state lives in the tab,
not the config -- a filter describes one action, and a stale "last 7
days, sender Mira" reappearing weeks later is a worse start than an
empty form.

StreamForExport now takes a caller-owned connection. The reader stays
open for as long as the file is written, seconds to minutes on a large
history, and chat keeps arriving throughout -- so the primary connection
would be read here and written by UpsertMessage at once, and
SqliteConnection is not thread-safe. Holding the read lock instead would
trade that for freezing the game.

ChannelGroups lifts the eight groups out of the deleted tab and finishes
them: 37 of 89 channels belonged to no group and were therefore
unreachable in the UI. Game Master channels follow ChatTypeExt.Parent(),
so GmTell sits with the other tells rather than under system traffic --
an access request that quietly drops part of what it promises is the
dangerous kind of gap.

Also here:

- OpenSecondaryConnection disposes on a failing pragma. Open can succeed
  and journal_mode=WAL still time out, and with Pooling=false the
  connection then survives until a finalizer reaches it. Affects the
  full-text rebuild worker too.
- StreamForExport builds its logger before the reader, so a throwing
  CreateLogger cannot leave a reader nobody owns.
- The export thread takes the gate itself instead of the caller taking
  it first. Acquiring before Start would strand the gate for the session
  if thread creation failed, and the gate also holds back the sweep.
- Notifications are skipped once teardown has started. The thread has no
  cancellation path and finishing the file is right, but reporting it to
  a plugin that is gone is not.
- Transient widget rows that return their value instead of saving it.
  Writing the config file on every keystroke of a sender filter would be
  both pointless and slow.
- Five translated keys for "another database operation is running", in
  all 25 languages. Two of the four operation names have no trigger yet;
  they arrive with the cleanup and maintenance sections.
2026-08-18 21:25:10 +02:00
JonKazama-Hellion 90bf986f76 refactor(export): read text from chunks, write the file atomically
Two changes to MessageExporter before it gets a caller.

It read SenderSource and ContentSource, the raw SeStrings. TextValue on one
holding an auto-translate phrase reaches SeStringEvaluator, which asserts it is
on the main thread and throws unconditionally when a macro resolves a global
number. An export belongs on a worker, so that would abort it partway and leave
half a file.

The plan called for resolving text in batches on the framework thread. Not
needed: Message.Sender and Message.Content are already-resolved chunk lists --
ChunkUtil turns auto-translate into text at ingest, and the full-text index reads
them exactly this way. Same strings, no evaluator, no thread affinity, and no
batching machinery.

Second, the file handling. The format was validated after the StreamWriter was
opened, so an unknown format left a zero-byte file where a previous export had
been. It is checked first now, and the write goes to a .part file that is moved
into place at the end. A crash halfway used to leave a file that opens cleanly
and is quietly incomplete -- which on the path a GDPR access request goes out on
is worse than an obvious failure.

Almost none of this is reachable from the build suite: ExportToFile takes
IEnumerable<Message>, Message needs SeString, and xUnit cannot load Dalamud.dll
-- even an empty list fails, because the runtime resolves the parameter type
before the body runs. So the format mapping is pinned there and the rest by a new
self-test, which builds probe messages with deliberately empty SeStrings: if the
exporter ever reads them again, the text comes out blank and it fails.
2026-08-18 20:47:45 +02:00
JonKazama-Hellion 22de2de234 docs(db): correct why VACUUM fails against an open reader
The comment said PerformMaintenance inherits a five-second timeout before
throwing. It does inherit that timeout, but it is not what happens here: a VACUUM
on a connection with a live reader fails instantly with 'cannot VACUUM - SQL
statements in progress'.

That is SQLITE_ERROR, not SQLITE_BUSY. Busy handling only covers contention
between different connections, so no timeout applies and no retry helps. The
practical difference matters for the error message the UI will show: the DELETE
has already committed when it fires, so the rows are gone and only the compaction
is missing -- 'deleted but not compacted', not 'failed'.

And PerformMaintenance batches VACUUM, REINDEX and ANALYZE in one statement, so
a failing VACUUM takes the other two with it.
2026-08-18 20:37:40 +02:00
JonKazama-Hellion 6a3bbe2357 fix(ci): let the version check tell a release from a local build
The check demanded csproj, repo.json and every DownloadLink carry the same
version. That is right for a published release and impossible for anything else,
so it went red the moment v1.11.0 closed -- and pre-push blocks on it.

The two states were conflated. repo.json is the distribution manifest, so its
version has to describe what the links actually serve. Claiming 1.11.0 while
every link serves v1.5.6 makes Dalamud offer an update, install the old build,
and offer the same update again on the next launch. Satisfying the old rule
meant building exactly that.

Now: the manifest must always agree with itself and with its links, and the
build must never be older than what is published. --release additionally demands
the three match, which is the mode for cutting a tag and still catches the v1.2.2
burn it was written for.

The link check moved from the csproj version to the manifest version, which is
the pairing that protects users. In the old form a manifest could name a version
none of its links served and pass, as long as the csproj agreed -- the exact
mismatch it now rejects.
2026-08-18 20:28:28 +02:00
JonKazama-Hellion d0eb2934ed feat(db): one gate for every long-running database operation
Generalises the retention-sweep lock, which already solved this for a single
case: it stopped a manual sweep from racing the automatic one, and nothing else.

Export, cleanup and clear need the same protection against each other, and for a
sharper reason. An export leaves a reader open on the primary connection
deliberately outside _readLock, because the enumerator is consumed lazily by its
caller. A VACUUM starting while that reader lives meets an active reader on a
connection Microsoft documents as not thread-safe, and PerformMaintenance sets no
command timeout, so it inherits five seconds before throwing -- after the DELETE
has already committed.

TryBegin refuses rather than queues. Every one of these is user-initiated, and a
wipe that fires minutes after the click is worse than one that declines. End is
idempotent and does not check which operation ends, so a worker that throws
before acquiring can still release from its finally block.

Current is volatile because the draw thread reads it every frame to decide which
buttons are disabled. Blocking on the lock to find that out would freeze the game
for the length of a VACUUM, which is the exact failure this is meant to prevent.

Pure state machine, so the transitions are pinned without a database or an ImGui
frame -- including that exactly one of 64 competing callers wins.
2026-08-18 20:19:27 +02:00
JonKazama-Hellion 125a57167e fix(privacy): purge the full-text index when messages are deleted
messages_fts stores sender and content as plain text, and no delete path touched
it. ClearMessages, CleanupRetainOnly and the retention sweep all removed rows
from `messages` alone, so the readable text of every "deleted" message stayed on
disk.

It was self-sealing. InitFtsReadyCache treats a non-empty index as ready, so
after a wipe the index stayed full, the flag stayed true, and the rebuild that
would have cleared it never ran again.

This is not hypothetical: the retention sweep runs unattended every 24 hours,
so any user with retention on has been accumulating orphaned plain text since
the index shipped. And the plugin says otherwise in two places -- the clear
button promises "Removes all message history. Cannot be restored", and
PRIVACY.md documents targeted deletion as a feature.

Wiping the index rather than deleting matched rows, because message_guid is a
GUID string while messages.Id is a BLOB and the two cannot be joined in SQL. The
index is derived data; it rebuilds from the surviving rows on the next start,
which is both cheaper and provably complete.

CleanupRetainOnly also skips VACUUM when nothing matched, the way
DeleteByRetentionPolicy already did. Rewriting the whole file for zero deleted
rows costs seconds on a large database and gains nothing.

Six tests drive the real store against a real database, since the defect was in
what the SQL did not touch rather than in any computed value.
2026-08-18 20:03:03 +02:00