diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/DisplayText.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/DisplayText.cs
new file mode 100644
index 00000000..cff056da
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/DisplayText.cs
@@ -0,0 +1,45 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
+
+///
+/// Rendering helpers for operator-facing text that comes out of the database.
+///
+///
+///
+/// Why this exists (#504). Several pages abbreviated a hash / thumbprint with the range
+/// operator — @s.SourceHash[..12]… — which throws
+/// the moment the value is shorter than the slice.
+/// In Blazor that escapes BuildRenderTree unhandled and takes the whole page to an
+/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
+/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
+/// The docker-dev seed proved it: its placeholder SourceHash values (h1,
+/// h-abs-hr200) made /scripts a hard 500 on every stock bring-up.
+///
+///
+/// Use for every such prefix. It never throws, and it appends the
+/// ellipsis only when it actually truncated — a 2-character hash renders as h1, not
+/// h1…, so the display never implies more text than exists.
+///
+///
+public static class DisplayText
+{
+ ///
Placeholder rendered for a null / blank value. Matches the em-dash convention the
+ /// Admin UI already uses for "nothing to show" cells.
+ public const string Empty = "—";
+
+ ///
+ /// The first characters of , followed by an
+ /// ellipsis when (and only when) characters were dropped.
+ ///
+ ///
The value to abbreviate. May be null, blank, or shorter than
+ ///
— none of which throw.
+ ///
Maximum number of characters to keep. Values below 1 are treated as 1,
+ /// so a caller cannot turn a display bug into a crash.
+ ///
for a null/blank value; the value verbatim when it already fits;
+ /// otherwise the truncated prefix plus an ellipsis.
+ public static string Abbreviate(string? value, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return Empty;
+ if (maxLength < 1) maxLength = 1;
+ return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DisplayTextTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DisplayTextTests.cs
new file mode 100644
index 00000000..462507fa
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DisplayTextTests.cs
@@ -0,0 +1,95 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
+
+///
+/// #504 — , the null/short-safe replacement for the
+/// @value[..N] range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
+///
+///
+/// The bug being pinned: a range operator over an unvalidated DB string throws
+/// out of BuildRenderTree, which is unhandled
+/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
+/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
+/// that matter; the happy path is almost incidental.
+///
+[Trait("Category", "Unit")]
+public sealed class DisplayTextTests
+{
+ ///
A value longer than the budget is truncated and marked with an ellipsis — the normal
+ /// case for a 64-char SHA-256.
+ [Fact]
+ public void A_long_value_is_truncated_and_marked()
+ {
+ DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
+ }
+
+ ///
The regression: a value SHORTER than the budget must render, not throw. This is exactly
+ /// what a hand-inserted / API-authored SourceHash like h1 hits.
+ [Theory]
+ [InlineData("h1", 12)]
+ [InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
+ [InlineData("abc", 16)]
+ [InlineData("x", 64)]
+ public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
+ {
+ DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
+ }
+
+ ///
No ellipsis when nothing was dropped — the display must never imply text that is not
+ /// there. (The old markup appended "…" unconditionally, outside the slice.)
+ [Fact]
+ public void A_short_value_gets_no_ellipsis()
+ {
+ DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
+ }
+
+ ///
A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
+ /// marked.
+ [Fact]
+ public void A_value_exactly_at_the_budget_is_untouched()
+ {
+ DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
+ }
+
+ ///
Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
+ /// producing a stray ellipsis.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void A_null_or_blank_value_renders_the_placeholder(string? value)
+ {
+ DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
+ }
+
+ ///
A nonsensical budget is clamped rather than allowed to throw — a display bug must not
+ /// become a page crash, which is the whole point of this helper.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(int.MinValue)]
+ public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
+ {
+ DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
+ }
+
+ ///
Whatever the inputs, it never throws — the property that keeps a bad row from killing a
+ /// page. Includes the shapes that broke the old code.
+ [Fact]
+ public void It_never_throws_for_any_combination()
+ {
+ string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
+ int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
+
+ foreach (var value in values)
+ {
+ foreach (var budget in budgets)
+ {
+ Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
+ }
+ }
+ }
+}