diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.html b/apps/web/src/app/shared/reputation-display/reputation-display.component.html
new file mode 100644
index 0000000..0034d28
--- /dev/null
+++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.html
@@ -0,0 +1,25 @@
+
+
+
+ @if (entry().nextThreshold !== null) {
+
+
+
+
+ {{ entry().reputation }} / {{ entry().nextThreshold }}
+
+ } @else {
+
{{ entry().reputation }} — Höchster Rang erreicht
+ }
+
diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.scss b/apps/web/src/app/shared/reputation-display/reputation-display.component.scss
new file mode 100644
index 0000000..4a6499f
--- /dev/null
+++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.scss
@@ -0,0 +1,52 @@
+.reputation-display {
+ display: grid;
+ gap: var(--ar-space-2);
+ padding: var(--ar-space-3);
+ border: 1px solid var(--ar-border);
+ border-radius: var(--ar-radius-sm);
+ background: var(--ar-panel);
+}
+
+.reputation-display__header {
+ display: flex;
+ justify-content: space-between;
+ font-family: Georgia, 'Times New Roman', serif;
+}
+
+.reputation-display__rank {
+ color: var(--ar-gold);
+}
+
+.reputation-display__progress {
+ display: flex;
+ align-items: center;
+ gap: var(--ar-space-2);
+}
+
+.reputation-display__track {
+ position: relative;
+ flex: 1;
+ block-size: 0.5rem;
+ border-radius: var(--ar-radius-sm);
+ background: var(--ar-border);
+ overflow: hidden;
+}
+
+.reputation-display__fill {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ background: var(--ar-gold);
+}
+
+.reputation-display__value {
+ font-variant-numeric: tabular-nums;
+ color: var(--ar-text-muted);
+ font-size: var(--ar-font-sm);
+}
+
+.reputation-display__max {
+ margin: 0;
+ color: var(--ar-gold);
+ font-size: var(--ar-font-sm);
+}
diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts b/apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts
new file mode 100644
index 0000000..da84c60
--- /dev/null
+++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts
@@ -0,0 +1,95 @@
+import { TestBed } from '@angular/core/testing';
+import type { ReputationEntry } from '../../core/api/game-api.models';
+import { ReputationDisplayComponent } from './reputation-display.component';
+
+async function setup(entry: ReputationEntry) {
+ await TestBed.configureTestingModule({ imports: [ReputationDisplayComponent] }).compileComponents();
+ const fixture = TestBed.createComponent(ReputationDisplayComponent);
+ fixture.componentRef.setInput('entry', entry);
+ fixture.detectChanges();
+ return fixture;
+}
+
+describe('ReputationDisplayComponent', () => {
+ it('renders the faction name and rank', async () => {
+ const fixture = await setup({
+ factionKey: 'border-guard',
+ factionName: 'Grenzwacht',
+ reputation: 320,
+ rank: 'KNOWN',
+ rankLabel: 'Bekannt',
+ nextThreshold: 500,
+ });
+ const element = fixture.nativeElement as HTMLElement;
+
+ expect(element.textContent).toContain('Grenzwacht');
+ expect(element.textContent).toContain('Bekannt');
+ });
+
+ it('shows current reputation against the next threshold', async () => {
+ const fixture = await setup({
+ factionKey: 'border-guard',
+ factionName: 'Grenzwacht',
+ reputation: 320,
+ rank: 'KNOWN',
+ rankLabel: 'Bekannt',
+ nextThreshold: 500,
+ });
+ const element = fixture.nativeElement as HTMLElement;
+
+ expect(element.textContent).toContain('320');
+ expect(element.textContent).toContain('500');
+ });
+
+ it('renders a progress fraction between 0 and 100', async () => {
+ const fixture = await setup({
+ factionKey: 'border-guard',
+ factionName: 'Grenzwacht',
+ reputation: 320,
+ rank: 'KNOWN',
+ rankLabel: 'Bekannt',
+ nextThreshold: 500,
+ });
+ const element = fixture.nativeElement as HTMLElement;
+
+ const fill = element.querySelector('[data-reputation-progress-fill]');
+ const width = parseFloat(fill?.style.inlineSize ?? '0');
+ expect(width).toBeGreaterThan(0);
+ expect(width).toBeLessThanOrEqual(100);
+ });
+
+ it('handles the top rank with no next threshold without crashing or showing a bogus progress bar', async () => {
+ const fixture = await setup({
+ factionKey: 'border-guard',
+ factionName: 'Grenzwacht',
+ reputation: 1500,
+ rank: 'ESTEEMED',
+ rankLabel: 'Geachtet',
+ nextThreshold: null,
+ });
+ const element = fixture.nativeElement as HTMLElement;
+
+ expect(element.textContent).toContain('Geachtet');
+ expect(element.querySelector('[data-reputation-max]')).toBeTruthy();
+ });
+
+ it('renders a sensible zero-reputation row for a faction never interacted with', async () => {
+ const fixture = await setup({
+ factionKey: 'silent-order',
+ factionName: 'Der Stille Orden',
+ reputation: 0,
+ rank: 'STRANGER',
+ rankLabel: 'Fremder',
+ nextThreshold: 100,
+ });
+ const element = fixture.nativeElement as HTMLElement;
+
+ expect(element.textContent).toContain('Der Stille Orden');
+ expect(element.textContent).toContain('Fremder');
+ expect(element.textContent).toContain('0');
+
+ const fill = element.querySelector('[data-reputation-progress-fill]');
+ const width = parseFloat(fill?.style.inlineSize ?? '-1');
+ expect(width).toBe(0);
+ });
+});
diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.ts b/apps/web/src/app/shared/reputation-display/reputation-display.component.ts
new file mode 100644
index 0000000..209eb99
--- /dev/null
+++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.ts
@@ -0,0 +1,28 @@
+import { Component, computed, input } from '@angular/core';
+import type { ReputationEntry } from '../../core/api/game-api.models';
+
+/**
+ * Reusable Regional Reputation presentation (spec §27). No dedicated screen
+ * hosts this yet -- it is built and tested standalone so a Character
+ * screen, merchant, or NPC dialog can embed it later.
+ */
+@Component({
+ selector: 'app-reputation-display',
+ templateUrl: './reputation-display.component.html',
+ styleUrl: './reputation-display.component.scss',
+})
+export class ReputationDisplayComponent {
+ readonly entry = input.required();
+
+ // Progress toward the next threshold, measured from 0 rather than from the
+ // current rank's own floor: the API DTO exposes `reputation` and
+ // `nextThreshold` only. A more precise bar would need a `currentThreshold`
+ // field that nothing else needs yet, so this stays coarse on purpose.
+ protected readonly progressPercent = computed(() => {
+ const entry = this.entry();
+ if (entry.nextThreshold === null) {
+ return 100;
+ }
+ return Math.min(100, Math.max(0, (entry.reputation / entry.nextThreshold) * 100));
+ });
+}