Data display
Left-truncating paths
Shortens a long path by dropping whole segments from the left, because the tail is what tells two paths apart — the opposite of what CSS ellipsis throws away.
- truncation
- paths
- text-overflow
- breadcrumbs
- file-systems
<div class="pht-panel">
<div class="pht-controls">
<label class="pht-label">
Budget
<input type="range" id="pht-budget" min="14" max="60" value="28" />
<output id="pht-budget-out" class="pht-mono">28</output>
</label>
<label class="pht-label pht-label-grow">
Path
<input type="text" id="pht-input" class="pht-input" value="/var/lib/orchestration/storage/cluster/primary/active" />
</label>
</div>
<div class="pht-rows">
<div class="pht-row">
<span class="pht-tag">Head-truncated</span>
<div class="pht-box" id="pht-left"></div>
</div>
<div class="pht-row">
<span class="pht-tag">CSS ellipsis</span>
<div class="pht-box pht-ellip" id="pht-right"></div>
</div>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.pht-panel { display: flex; flex-direction: column; gap: 1rem; max-width: 640px; }
.pht-controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
.pht-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9375rem; color: var(--dim); }
.pht-label-grow { flex: 1 1 260px; min-width: 0; }
.pht-mono { font-family: var(--mono); font-size: 0.9375rem; color: var(--ink); }
.pht-input {
flex: 1;
min-width: 0;
font: inherit;
font-family: var(--mono);
font-size: 1rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
}
.pht-rows { display: flex; flex-direction: column; gap: 0.6rem; }
.pht-row { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 0.75rem; align-items: center; }
.pht-tag {
font-family: var(--mono);
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--dim);
}
.pht-box {
font-family: var(--mono);
font-size: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.7rem;
overflow: hidden;
white-space: nowrap;
}
.pht-ellip { text-overflow: ellipsis; }(function () {
// Drops whole segments from the LEFT until the remainder fits the budget.
// The tail is kept intact because it is almost always the part that tells
// two paths apart -- everything under one shared root looks identical from
// the left, and a "2026" folder is not unique to any one branch of a tree.
function abbreviatePathFromHead(path, maxLength) {
if (path.length <= maxLength) return path;
var segments = path.split('/').filter(Boolean);
if (segments.length === 0) return path;
// The last segment survives no matter what -- even alone, over budget.
var accumulated = segments[segments.length - 1];
for (var index = segments.length - 2; index >= 0; index -= 1) {
var candidate = segments[index] + '/' + accumulated;
if (('\u2026/' + candidate).length > maxLength) break;
accumulated = candidate;
}
var abbreviated = '\u2026/' + accumulated;
// If dropping segments would not actually shorten the string, return the
// original unchanged rather than a "shortened" result that is longer.
return abbreviated.length < path.length ? abbreviated : path;
}
var pathInput = document.getElementById('pht-input');
var budgetInput = document.getElementById('pht-budget');
var budgetOut = document.getElementById('pht-budget-out');
var leftBox = document.getElementById('pht-left');
var rightBox = document.getElementById('pht-right');
function render() {
var budget = Number(budgetInput.value);
budgetOut.textContent = String(budget);
var value = pathInput.value;
// The result is display-only, not a valid path -- pair it with a title
// carrying the real value so a pointer user can still recover it.
leftBox.textContent = abbreviatePathFromHead(value, budget);
leftBox.title = value;
rightBox.textContent = value;
rightBox.style.maxWidth = budget + 'ch';
rightBox.title = value;
}
pathInput.addEventListener('input', render);
budgetInput.addEventListener('input', render);
render();
})();Paste this into an agent to rebuild the pattern from scratch.
Build a function that abbreviates a long slash-delimited path to a character budget by dropping whole segments from the LEFT, and pair it with a live two-row comparison against ordinary CSS `text-overflow: ellipsis`, driven by an editable path input and a budget slider so a reader can see the difference for themselves.
Reach for this anywhere a path, a breadcrumb trail, or any hierarchical identifier has to fit a fixed width: file browsers, import statements in an error trace, nested category labels, deep object paths in a debugger. It is specifically for hierarchical strings where a shared root is common and the distinguishing information sits at the tail. Do not reach for it on a flat string with no hierarchy -- a sentence, a title, a single filename with no directory -- where there is no head/tail asymmetry to exploit and ordinary right-truncating ellipsis is simply correct.
The reasoning for the direction reversal: three unrelated folders can all be named `2026`, and everything stored under one person's home directory shares that home directory as its head. Right-truncation -- the CSS default -- clips from the end, which is exactly backwards: it keeps the part every path in the tree has in common and throws away the part that would let a reader tell two paths apart. Truncating from the head keeps the tail, which is where the identifying information almost always lives.
Three edge rules make the algorithm robust rather than merely usually-correct. Segments are kept whole -- never truncated mid-word -- because half a folder name reads as more broken than a missing one. The last segment survives even if it alone exceeds the budget, since dropping it entirely would remove the one piece of information the whole function exists to protect. And if trimming segments would not actually produce something shorter than the original, return the original unchanged rather than a "shortened" result that is the same length or longer.
The algorithm itself is short: split on `/`, start the accumulator at the last segment, and walk backward from there, prepending one whole segment at a time and checking the ellipsis-prefixed length against the budget on each step, stopping the instant it would overflow.
accumulated = segments[last] for index from segments.length - 2 down to 0: if ("…/" + segments[index] + "/" + accumulated).length > budget: break accumulated = segments[index] + "/" + accumulated
The result is for display only -- it is not a valid path -- so always pair the rendered string with a `title` attribute carrying the real, untruncated value.
Two companion utilities are worth shipping in the same module, though they don't need a live demo of their own. A containment check for "is path A inside path B" has to compare whole segments after normalizing both paths, not call `startsWith` on the raw strings -- the naive version wrongly reports `/library-old` as being inside `/library`, because the string `/library` is a textual prefix of `/library-old` even though the two are siblings. And when a listing needs to sort these paths or their trailing segments, use `new Intl.Collator(undefined, { numeric: true })` rather than the default string comparator, so a segment named "Trip 2" sorts before "Trip 10" instead of after it.