Two-column layout with sticky character summary

The Build Ledger now uses a two-column grid: the builder UI stays on the
left, and a new sticky CharacterSummary panel on the right aggregates the
whole character at a glance — house, level, total XP, skill/intel point
pools (with POI bonus broken out), per-spec levels with cumulative XP and
perks unlocked, current faction tier and progress to next, skill
allocations grouped by class, and the six most recent perk unlocks across
character + specs.

The previous separate "Skill Summary" panel is subsumed by the sidebar.
Wrap width grows from 1240px to 1560px. Below 1180px the layout collapses
to one column and the summary becomes non-sticky.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-05-23 07:58:27 -04:00
parent 6c7b4b8133
commit 65d06f1754
3 changed files with 517 additions and 91 deletions

View file

@ -3,6 +3,7 @@ import { computed, onMounted, ref, shallowRef, watch } from 'vue';
import XpProgressCard from './components/XpProgressCard.vue'; import XpProgressCard from './components/XpProgressCard.vue';
import FactionTrack from './components/FactionTrack.vue'; import FactionTrack from './components/FactionTrack.vue';
import SkillTree from './components/SkillTree.vue'; import SkillTree from './components/SkillTree.vue';
import CharacterSummary from './components/CharacterSummary.vue';
import { import {
applyBuild, applyBuild,
build, build,
@ -130,33 +131,20 @@ const totalSpentAcrossClasses = computed(() =>
Object.values(build.skills).reduce((a, b) => a + (b || 0), 0), Object.values(build.skills).reduce((a, b) => a + (b || 0), 0),
); );
// Per-class summary: { classId: { spent, allocations: [{node, points}] } } // Per-class spent counts used by the skill-tree tab chips.
const summaryByClass = computed(() => { const spentByClass = computed<Record<ClassId, number>>(() => {
const out: Record< const out: Record<ClassId, number> = {
ClassId, benegesserit: 0,
{ spent: number; allocations: { tag: string; name: string; kind: string; points: number; max: number }[] } mentat: 0,
> = { planetologist: 0,
benegesserit: { spent: 0, allocations: [] }, swordmaster: 0,
mentat: { spent: 0, allocations: [] }, trooper: 0,
planetologist: { spent: 0, allocations: [] },
swordmaster: { spent: 0, allocations: [] },
trooper: { spent: 0, allocations: [] },
}; };
for (const c of CLASSES) { for (const c of CLASSES) {
const tree = skillTrees.value[c.id]; const tree = skillTrees.value[c.id];
if (!tree) continue; if (!tree) continue;
for (const node of tree.nodes) { for (const node of tree.nodes) {
const pts = build.skills[node.tag] || 0; out[c.id] += build.skills[node.tag] || 0;
if (pts > 0) {
out[c.id].spent += pts;
out[c.id].allocations.push({
tag: node.tag,
name: node.name,
kind: node.kind,
points: pts,
max: node.maxPoints,
});
}
} }
} }
return out; return out;
@ -265,6 +253,9 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
</p> </p>
</header> </header>
<div class="layout">
<div class="builder">
<!-- HOUSE --> <!-- HOUSE -->
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
@ -422,7 +413,7 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
> >
{{ c.name }} {{ c.name }}
<span <span
v-if="summaryByClass[c.id].spent > 0" v-if="spentByClass[c.id] > 0"
style=" style="
display: block; display: block;
margin-top: 4px; margin-top: 4px;
@ -430,9 +421,7 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
font-size: 10px; font-size: 10px;
" "
> >
{{ summaryByClass[c.id].spent }} pt{{ {{ spentByClass[c.id] }} pt{{ spentByClass[c.id] === 1 ? '' : 's' }}
summaryByClass[c.id].spent === 1 ? '' : 's'
}}
</span> </span>
</div> </div>
</div> </div>
@ -446,70 +435,22 @@ const specMeta: Record<SpecId, { name: string; sym: string }> = {
/> />
</section> </section>
<!-- SKILL SUMMARY -->
<section class="panel">
<div class="panel-head">
<h2>Skill Summary</h2>
<div class="sub">All allocations across classes</div>
</div> </div>
<div class="cards">
<div v-for="c in CLASSES" :key="c.id" class="card"> <CharacterSummary
<div class="sym">{{ c.name.toUpperCase().slice(0, 3) }}</div> :build="build"
<h3> :char-xp="charXp"
{{ c.name }} :spec-tables="specTables"
<span :faction-table="currentFaction || null"
style=" :skill-trees="skillTrees"
font-family: 'JetBrains Mono', monospace; :classes="CLASSES"
font-size: 12px; :specs="SPECS"
color: var(--ink-muted); :spec-meta="specMeta"
letter-spacing: 0.12em; :total-skill-points="totalSkillPointsAtLevel"
margin-left: 8px; :total-intel-level="totalIntelAtLevel"
" :total-spent-across-classes="totalSpentAcrossClasses"
>{{ summaryByClass[c.id].spent }} pts</span />
>
</h3>
<div
v-if="summaryByClass[c.id].allocations.length === 0"
style="color: var(--ink-muted); font-size: 13px; padding: 8px 0"
>
no points allocated
</div> </div>
<ul
v-else
style="
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 4px;
"
>
<li
v-for="a in summaryByClass[c.id].allocations"
:key="a.tag"
style="
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 4px 8px;
background: var(--bg);
border-radius: 3px;
border: 1px solid var(--line-soft);
"
>
<span>
<span style="color: var(--ink-muted); margin-right: 6px">{{
a.kind.slice(0, 3).toUpperCase()
}}</span>
{{ a.name }}
</span>
<span style="color: var(--sand)">{{ a.points }}/{{ a.max }}</span>
</li>
</ul>
</div>
</div>
</section>
<footer class="footer-bar"> <footer class="footer-bar">
<div>Unofficial fan-made tool · not affiliated with the publisher</div> <div>Unofficial fan-made tool · not affiliated with the publisher</div>

View file

@ -0,0 +1,465 @@
<script setup lang="ts">
import { computed } from 'vue';
import type {
BuildState,
ClassId,
FactionTable,
Perk,
SkillTree,
SpecId,
XpTable,
} from '../types';
const props = defineProps<{
build: BuildState;
charXp: XpTable | null;
specTables: Record<SpecId, XpTable | null>;
factionTable: FactionTable | null;
skillTrees: Record<ClassId, SkillTree | null>;
classes: { id: ClassId; name: string }[];
specs: SpecId[];
specMeta: Record<SpecId, { name: string; sym: string }>;
totalSkillPoints: number;
totalIntelLevel: number;
totalSpentAcrossClasses: number;
}>();
function fmt(n: number) {
return Number(n || 0).toLocaleString('en-US');
}
const houseName = computed(() =>
props.build.house === 'atreides' ? 'House Atreides' : 'House Harkonnen',
);
const currentLevelXp = computed(() => {
if (!props.charXp) return 0;
const idx = props.build.character.level - 1;
if (idx < 0) return 0;
return props.charXp.rows[idx]?.totalXp || 0;
});
const totalIntel = computed(
() => props.totalIntelLevel + (props.build.character.bonusIntel || 0),
);
const currentTier = computed(() => {
if (!props.factionTable) return null;
return props.factionTable.tiers[props.build.faction.tier] || null;
});
const nextTier = computed(() => {
if (!props.factionTable) return null;
return props.factionTable.tiers[props.build.faction.tier + 1] || null;
});
interface SpecSummary {
id: SpecId;
name: string;
sym: string;
level: number;
totalXp: number;
nextLevelXp: number;
perksUnlocked: number;
perksTotal: number;
}
const specSummaries = computed<SpecSummary[]>(() =>
props.specs.map((id) => {
const t = props.specTables[id];
const cur = props.build.specs[id];
const totalXp = t && cur.level > 0 ? t.rows[cur.level - 1]?.totalXp || 0 : 0;
const nextLevelXp = t ? t.rows[cur.level]?.xpRequired || 0 : 0;
let perksU = 0;
let perksT = 0;
if (t) {
for (const r of t.rows) {
if (r.perks) {
perksT += r.perks.length;
if (r.level <= cur.level) perksU += r.perks.length;
}
}
}
return {
id,
name: props.specMeta[id].name,
sym: props.specMeta[id].sym,
level: cur.level,
totalXp,
nextLevelXp,
perksUnlocked: perksU,
perksTotal: perksT,
};
}),
);
interface ClassSkillSummary {
id: ClassId;
name: string;
spent: number;
allocations: { tag: string; name: string; kind: string; points: number; max: number }[];
}
const skillsByClass = computed<ClassSkillSummary[]>(() => {
const out: ClassSkillSummary[] = [];
for (const c of props.classes) {
const tree = props.skillTrees[c.id];
const allocations: ClassSkillSummary['allocations'] = [];
let spent = 0;
if (tree) {
for (const node of tree.nodes) {
const pts = props.build.skills[node.tag] || 0;
if (pts > 0) {
spent += pts;
allocations.push({
tag: node.tag,
name: node.name,
kind: node.kind,
points: pts,
max: node.maxPoints,
});
}
}
}
if (spent > 0 || tree)
out.push({ id: c.id, name: c.name, spent, allocations });
}
return out;
});
interface RecentPerk extends Perk {
source: string;
level: number;
}
const recentPerks = computed<RecentPerk[]>(() => {
// Show last few perks unlocked across character + all specs
const all: RecentPerk[] = [];
if (props.charXp) {
for (const r of props.charXp.rows) {
if (r.level <= props.build.character.level && r.perks) {
for (const p of r.perks) {
all.push({ ...p, source: 'Character', level: r.level });
}
}
}
}
for (const id of props.specs) {
const t = props.specTables[id];
if (!t) continue;
const lvl = props.build.specs[id].level;
for (const r of t.rows) {
if (r.level <= lvl && r.perks) {
for (const p of r.perks) {
all.push({ ...p, source: props.specMeta[id].name, level: r.level });
}
}
}
}
// newest unlocks first by level
all.sort((a, b) => b.level - a.level || a.source.localeCompare(b.source));
return all.slice(0, 6);
});
</script>
<template>
<aside class="summary">
<div class="summary-head">
<div class="sym">CHARACTER SUMMARY</div>
<h2>{{ houseName }}</h2>
</div>
<!-- Identity -->
<div class="block">
<div class="kv">
<span class="k">Level</span>
<span class="v big">{{ build.character.level }}</span>
</div>
<div class="kv">
<span class="k">Total XP</span>
<span class="v">{{ fmt(currentLevelXp + build.character.xpInto) }}</span>
</div>
<div class="kv">
<span class="k">Currently Viewing</span>
<span class="v">
{{ classes.find((c) => c.id === build.classId)?.name || '—' }}
</span>
</div>
</div>
<!-- Points pool -->
<div class="block">
<div class="block-head">Points</div>
<div class="kv">
<span class="k">Skill Points</span>
<span class="v">
<span
:class="{
over: totalSpentAcrossClasses > totalSkillPoints,
good: totalSpentAcrossClasses > 0 && totalSpentAcrossClasses <= totalSkillPoints,
}"
>
{{ totalSpentAcrossClasses }}
</span>
/ {{ totalSkillPoints }} spent
</span>
</div>
<div class="kv">
<span class="k">Intel Points</span>
<span class="v">
{{ fmt(totalIntel) }}
<span v-if="build.character.bonusIntel" class="muted">
({{ fmt(totalIntelLevel) }} + {{ fmt(build.character.bonusIntel) }} POI)
</span>
</span>
</div>
</div>
<!-- Specializations -->
<div class="block">
<div class="block-head">Specializations</div>
<ul class="spec-list">
<li v-for="s in specSummaries" :key="s.id">
<span class="spec-sym">{{ s.sym }}</span>
<span class="spec-name">{{ s.name }}</span>
<span class="spec-lvl">L{{ s.level }}</span>
<span class="spec-xp">{{ fmt(s.totalXp) }} XP</span>
<span
v-if="s.perksTotal"
class="spec-perks"
:title="`${s.perksUnlocked} of ${s.perksTotal} perks unlocked`"
>
{{ s.perksUnlocked }}/{{ s.perksTotal }}
</span>
</li>
</ul>
</div>
<!-- Faction -->
<div class="block">
<div class="block-head">Faction Standing</div>
<div class="kv">
<span class="k">{{ houseName }}</span>
<span class="v">
<strong>{{ currentTier?.name || '—' }}</strong>
</span>
</div>
<div class="kv" v-if="nextTier">
<span class="k">Next</span>
<span class="v">
{{ fmt(build.faction.standingInto) }} /
{{ fmt(nextTier.standingRequired) }} {{ nextTier.name }}
</span>
</div>
</div>
<!-- Skills by class -->
<div class="block">
<div class="block-head">Skill Allocations</div>
<div v-if="totalSpentAcrossClasses === 0" class="empty">
No skill points allocated.
</div>
<div v-else class="class-blocks">
<div
v-for="c in skillsByClass.filter((s) => s.allocations.length > 0)"
:key="c.id"
class="class-block"
>
<div class="class-row">
<span class="cls-name">{{ c.name }}</span>
<span class="cls-spent">{{ c.spent }} pts</span>
</div>
<ul class="skill-list">
<li v-for="a in c.allocations" :key="a.tag">
<span class="kind">{{ a.kind.slice(0, 3).toUpperCase() }}</span>
<span class="skill-name">{{ a.name }}</span>
<span class="skill-pts">{{ a.points }}/{{ a.max }}</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Recent unlocks -->
<div class="block" v-if="recentPerks.length">
<div class="block-head">Latest Unlocks</div>
<ul class="recent">
<li v-for="p in recentPerks" :key="`${p.source}-${p.level}-${p.name}`">
<span class="recent-src">{{ p.source }} L{{ p.level }}</span>
<span class="recent-name">{{ p.name }}</span>
<span v-if="p.effect" class="recent-eff">{{ p.effect }}</span>
</li>
</ul>
</div>
</aside>
</template>
<style scoped>
.summary {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 20px 22px;
position: sticky;
top: 76px;
max-height: calc(100vh - 100px);
overflow-y: auto;
box-shadow: var(--shadow);
}
.summary::-webkit-scrollbar { width: 8px; }
.summary::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
.summary-head {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px dashed var(--line-soft);
}
.summary-head .sym {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.22em;
color: var(--ink-muted);
text-transform: uppercase;
margin-bottom: 4px;
}
.summary-head h2 {
font-family: 'Cormorant Garamond', serif;
font-size: 24px;
margin: 0;
font-weight: 500;
color: var(--sand);
}
.block { margin-bottom: 18px; }
.block:last-child { margin-bottom: 0; }
.block-head {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--ink-muted);
text-transform: uppercase;
margin-bottom: 8px;
}
.kv {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 4px 0;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
border-bottom: 1px dotted rgba(255, 255, 255, 0.04);
}
.kv:last-child { border-bottom: none; }
.kv .k { color: var(--ink-muted); }
.kv .v { color: var(--ink); }
.kv .v.big {
font-family: 'Cormorant Garamond', serif;
font-size: 22px;
color: var(--sand);
}
.kv .v .muted { color: var(--ink-muted); margin-left: 6px; font-size: 11px; }
.kv .v .good { color: var(--sand-2); }
.kv .v .over { color: var(--ember); }
.spec-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.spec-list li {
display: grid;
grid-template-columns: 30px 1fr 36px auto 50px;
gap: 8px;
align-items: baseline;
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
padding: 4px 6px;
background: var(--bg);
border-radius: 3px;
border: 1px solid var(--line-soft);
}
.spec-sym { color: var(--ink-muted); letter-spacing: 0.16em; font-size: 10px; }
.spec-name { color: var(--ink-dim); }
.spec-lvl { color: var(--sand); text-align: right; }
.spec-xp { color: var(--ink-muted); text-align: right; font-size: 10.5px; }
.spec-perks {
color: var(--sand-2);
text-align: right;
font-size: 10px;
letter-spacing: 0.04em;
}
.empty {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--ink-muted);
padding: 8px 0;
}
.class-blocks {
display: grid;
gap: 10px;
}
.class-block {
background: var(--bg);
border: 1px solid var(--line-soft);
border-radius: 3px;
padding: 8px 10px;
}
.class-row {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px dashed var(--line-soft);
}
.cls-name { color: var(--ink); letter-spacing: 0.1em; }
.cls-spent { color: var(--sand); }
.skill-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 3px;
}
.skill-list li {
display: grid;
grid-template-columns: 32px 1fr auto;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 2px 4px;
}
.kind { color: var(--ink-muted); font-size: 9.5px; letter-spacing: 0.1em; align-self: center; }
.skill-name { color: var(--ink-dim); }
.skill-pts { color: var(--sand); }
.recent {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.recent li {
display: grid;
grid-template-columns: 90px 1fr;
gap: 6px;
align-items: baseline;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 4px 6px;
background: var(--bg);
border-left: 2px solid var(--sand-2);
border-radius: 2px;
}
.recent-src { color: var(--ink-muted); font-size: 10px; letter-spacing: 0.1em; }
.recent-name { color: var(--ink); }
.recent-eff {
grid-column: 2;
color: var(--sand-2);
font-size: 10.5px;
margin-top: 2px;
}
</style>

View file

@ -92,10 +92,30 @@ body {
a { color: var(--sand); } a { color: var(--sand); }
.wrap { .wrap {
max-width: 1240px; max-width: 1560px;
margin: 0 auto; margin: 0 auto;
padding: 48px 28px 96px; padding: 48px 28px 96px;
} }
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 400px;
gap: 28px;
align-items: start;
}
.builder {
min-width: 0; /* prevents grid from blowing out on long content */
}
@media (max-width: 1180px) {
.layout {
grid-template-columns: 1fr;
}
.summary {
position: static !important;
max-height: none !important;
}
}
header.hero { header.hero {
margin-bottom: 32px; margin-bottom: 32px;
} }
@ -430,7 +450,7 @@ button:disabled {
margin: -48px 0 32px; margin: -48px 0 32px;
} }
.share-inner { .share-inner {
max-width: 1240px; max-width: 1560px;
margin: 0 auto; margin: 0 auto;
padding: 0 28px; padding: 0 28px;
display: flex; display: flex;