diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md
index b555e18..d19dc81 100644
--- a/docs/tables_frontend_overhaul_implementation_plan.md
+++ b/docs/tables_frontend_overhaul_implementation_plan.md
@@ -64,6 +64,7 @@ It is intentionally implementation-focused:
| 2026-03-21 | P3.1 | Completed | Split `Tables.razor` into focused table components for the selector header, context bar, canvas, and legend while leaving loading, deep-link synchronization, and dialog state in the page host. |
| 2026-03-21 | P3.2 | Completed | Replaced the floating table picker with a permanent left-rail layout, converted the old selector component into a real page header, and kept the current selection flow intact inside the new reference frame. |
| 2026-03-21 | P3.3 | Completed | Added rail search, family filters, pinned and recent sections, curated status chips, and keyboard up/down plus Enter handling on top of the new permanent table index rail. |
+| 2026-03-21 | P3.4 | Completed | Added a sticky context bar with reference-mode tabs, variant and severity selectors, roll-jump state, and active filter chips, then wired those controls into page state and canvas filtering. |
### Lessons Learned
@@ -97,6 +98,7 @@ It is intentionally implementation-focused:
- The `Tables` rewrite is safer when orchestration and rendering are separated early. Keeping loading, persistence, and dialog state in the page host while extracting render-only components makes later layout and interaction changes much lower risk.
- The `Tables` navigation model needs its own persistent geometry before advanced behaviors land. Converting the selector to a real rail first keeps later search and keyboard work from being tangled up with another structural rewrite.
- Rail keyboard behavior is easiest to maintain when it works from one deduplicated option order, even if the UI shows multiple sections. Keeping one internal option list avoids separate arrow-key state per section.
+- The context bar controls should own one shared view-state model before the canvas gets more visual treatment. Wiring the filters into the host page now avoids a second refactor when row, column, and cell emphasis land.
## Target Outcomes
@@ -460,7 +462,7 @@ Build the shared interaction infrastructure needed by multiple destinations befo
| `P3.1` | Completed | `Tables.razor` now acts as the stateful host while selector/header/canvas/legend rendering lives in dedicated `Components/Tables` components. |
| `P3.2` | Completed | The old dropdown picker is gone; `/tables` now uses a permanent left rail and a real page header while keeping the current selection flow intact. |
| `P3.3` | Completed | The rail now supports search-as-you-type, family filters, pinned and recent sections, curated status chips, and a deduplicated arrow/Enter keyboard path. |
-| `P3.4` | Pending | Introduce the sticky context bar with roll jump and mode/filter controls. |
+| `P3.4` | Completed | The table surface now has a sticky context bar with mode tabs, variant/severity focus, roll-jump state, and active filter chips wired into host-page view state. |
| `P3.5` | Pending | Rework the canvas for sticky headers, sticky roll bands, stronger reading emphasis, and density control. |
| `P3.6` | Pending | Remove visible resting-state action stacks from non-selected cells. |
| `P3.7` | Pending | Add the desktop selection-driven inspector. |
diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor
index b1be804..b8a5f57 100644
--- a/src/RolemasterDb.App/Components/Pages/Tables.razor
+++ b/src/RolemasterDb.App/Components/Pages/Tables.razor
@@ -57,10 +57,21 @@
+ CurrentMode="referenceMode"
+ SelectedGroupKey="selectedGroupKey"
+ SelectedColumnKey="selectedColumnKey"
+ RollJumpValue="rollJumpValue"
+ OnTogglePin="TogglePinnedTableAsync"
+ OnModeChanged="UpdateReferenceModeAsync"
+ OnGroupChanged="UpdateSelectedGroupAsync"
+ OnColumnChanged="UpdateSelectedColumnAsync"
+ OnRollJumpChanged="UpdateRollJumpAsync" />
@@ -135,6 +146,10 @@
private string? curationQuickParseError;
private int? curatingResultId;
private CriticalCellEditorModel? curationModel;
+ private string referenceMode = TablesReferenceMode.Reference;
+ private string selectedGroupKey = string.Empty;
+ private string selectedColumnKey = string.Empty;
+ private string rollJumpValue = string.Empty;
private bool hasResolvedStoredTableSelection;
private CriticalTableReference? SelectedTableReference =>
referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase));
@@ -170,14 +185,17 @@
if (tableDetail is null)
{
detailError = "The selected table could not be loaded.";
+ NormalizeViewStateForCurrentDetail();
return;
}
await RecordRecentTableVisitAsync();
+ NormalizeViewStateForCurrentDetail();
}
catch (Exception exception)
{
detailError = exception.Message;
+ NormalizeViewStateForCurrentDetail();
}
finally
{
@@ -621,4 +639,75 @@
new(
TableSlug: selectedTableSlug,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
+
+ private Task UpdateReferenceModeAsync(string mode)
+ {
+ referenceMode = NormalizeMode(mode);
+ return Task.CompletedTask;
+ }
+
+ private Task UpdateSelectedGroupAsync(string groupKey)
+ {
+ selectedGroupKey = NormalizeOptionalFilter(groupKey);
+ return Task.CompletedTask;
+ }
+
+ private Task UpdateSelectedColumnAsync(string columnKey)
+ {
+ selectedColumnKey = NormalizeOptionalFilter(columnKey);
+ return Task.CompletedTask;
+ }
+
+ private Task UpdateRollJumpAsync(string rollValue)
+ {
+ rollJumpValue = NormalizeRollInput(rollValue);
+ return Task.CompletedTask;
+ }
+
+ private void NormalizeViewStateForCurrentDetail()
+ {
+ referenceMode = NormalizeMode(referenceMode);
+
+ if (tableDetail is null)
+ {
+ selectedGroupKey = string.Empty;
+ selectedColumnKey = string.Empty;
+ rollJumpValue = string.Empty;
+ return;
+ }
+
+ if (tableDetail.Groups.All(group => !string.Equals(group.Key, selectedGroupKey, StringComparison.OrdinalIgnoreCase)))
+ {
+ selectedGroupKey = string.Empty;
+ }
+
+ if (tableDetail.Columns.All(column => !string.Equals(column.Key, selectedColumnKey, StringComparison.OrdinalIgnoreCase)))
+ {
+ selectedColumnKey = string.Empty;
+ }
+
+ rollJumpValue = NormalizeRollInput(rollJumpValue);
+ }
+
+ private static string NormalizeMode(string? mode) =>
+ mode switch
+ {
+ TablesReferenceMode.NeedsCuration => TablesReferenceMode.NeedsCuration,
+ TablesReferenceMode.Curated => TablesReferenceMode.Curated,
+ _ => TablesReferenceMode.Reference
+ };
+
+ private static string NormalizeOptionalFilter(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
+
+ private static string NormalizeRollInput(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ var digitsOnly = new string(value.Where(char.IsDigit).ToArray());
+ return digitsOnly.Length == 0 ? string.Empty : digitsOnly;
+ }
}
diff --git a/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor b/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor
index 0587521..4dc0240 100644
--- a/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor
+++ b/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor
@@ -3,11 +3,11 @@
@if (Detail.Groups.Count > 0)
{
- @foreach (var group in Detail.Groups)
+ @foreach (var group in visibleGroups)
{