diff --git a/src/aare/gui/new_gui/ROADMAP.md b/src/aare/gui/new_gui/ROADMAP.md
index aaada39f..a95c4e6a 100644
--- a/src/aare/gui/new_gui/ROADMAP.md
+++ b/src/aare/gui/new_gui/ROADMAP.md
@@ -74,6 +74,14 @@ Start→`automated_scan`).
### M. Analysis panels (nice-to-have) — MED
- Target-stability chart, prediction-metrics, smargon-trace (CSV/charts)
+### M2. Sample run-history (mockup, pending backend) — MED
+- `widgets/sample_history.py` (RunLEDStrip + SampleHistoryPanel) renders per-run
+ outcome as the 6-stage LED strip (✓ ok / ✕ error / ! warn / hollow skip), with
+ a compact per-row variant (grey=unknown · green/orange/red). Design approved;
+ plan: per-row compact LEDs + **inline expand** under the clicked sample.
+ BLOCKED on a backend run-history endpoint (server only exposes counts today),
+ so it's kept as a mockup — `set_history(name, summary, runs)` is ready to wire.
+
### N. UX / framework — MED
- Keyboard shortcuts (~18), layout persistence (QSettings), restore-default-layout
- Tutorials/guided help (F1), splash screen
diff --git a/src/aare/gui/new_gui/widgets/sample_history.py b/src/aare/gui/new_gui/widgets/sample_history.py
new file mode 100644
index 00000000..cd22cc8c
--- /dev/null
+++ b/src/aare/gui/new_gui/widgets/sample_history.py
@@ -0,0 +1,187 @@
+"""Sample history — per-run outcome shown as a stage-LED strip.
+
+Clicking a sample in the mount panel reveals its previous runs. Each run is a row
+of stage LEDs (Mount · Center · Raster · XRF · Collect · Process) coloured by
+outcome (green ✓ ok, red ✕ error, peach ! warning, hollow = not part of the run),
+mirroring the workflow LED style. Errors are spelled out under the strip.
+
+``set_history`` takes plain dicts so it can be fed from a backend history endpoint
+later; for now the Manual view can derive a summary from SampleShortInfo counts.
+"""
+
+from __future__ import annotations
+
+from PySide6.QtCore import QPointF, QRectF, Qt
+from PySide6.QtGui import QColor, QFont, QPainter, QPen
+from PySide6.QtWidgets import (
+ QLabel,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
+
+from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette
+
+STAGE_NAMES = ("Mount", "Center", "Raster", "XRF", "Collect", "Process")
+# status -> (key for colour, glyph)
+_GLYPH = {"ok": "✓", "error": "✕", "warn": "!", "skip": ""}
+
+
+class RunLEDStrip(QWidget):
+ """A single run rendered as a row of stage LEDs."""
+
+ def __init__(self, stages: list, palette: Palette, compact: bool = False,
+ parent=None):
+ super().__init__(parent)
+ self._p = palette
+ self._stages = stages # [(name, status)]
+ self._compact = compact # row summary: small dots, no labels
+ if compact:
+ self.setFixedSize(118, 16)
+ else:
+ self.setFixedHeight(52)
+ self.setMinimumWidth(330)
+
+ def _color(self, status: str) -> QColor:
+ p = self._p
+ return QColor({"ok": p.success, "error": p.danger,
+ "warn": p.breakpoint, "unknown": p.pending_node}
+ .get(status, p.pending_track))
+
+ def paintEvent(self, event): # noqa: N802
+ p = self._p
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ n = len(self._stages)
+ step_w = self.width() / n
+ if self._compact:
+ self._paint_compact(painter, n, step_w)
+ return
+ r = 9
+ cy = 26
+ f = QFont()
+ f.setPixelSize(9)
+ for i, (name, status) in enumerate(self._stages):
+ cx = step_w * i + step_w / 2
+ # connector to the next LED
+ if i < n - 1:
+ nxt = self._stages[i + 1][1]
+ done = status in ("ok", "warn") and nxt != "skip"
+ painter.setPen(QPen(QColor(p.success if done else p.pending_track), 2))
+ painter.drawLine(QPointF(cx + r + 2, cy),
+ QPointF(step_w * (i + 1) + step_w / 2 - r - 2, cy))
+ col = self._color(status)
+ painter.setPen(Qt.NoPen)
+ if status == "skip":
+ painter.setBrush(Qt.NoBrush)
+ painter.setPen(QPen(QColor(p.pending_node), 2))
+ painter.drawEllipse(QPointF(cx, cy), r, r)
+ else:
+ painter.setBrush(col)
+ painter.drawEllipse(QPointF(cx, cy), r, r)
+ painter.setPen(QColor(p.accent_text))
+ painter.setFont(QFont(FONT_MONO_FALLBACK, 8, QFont.Bold))
+ painter.drawText(QRectF(cx - r, cy - r, 2 * r, 2 * r),
+ Qt.AlignCenter, _GLYPH.get(status, ""))
+ # label
+ painter.setPen(QColor(p.text_faint if status == "skip" else p.text_muted))
+ painter.setFont(f)
+ painter.drawText(QRectF(cx - step_w / 2, cy + r + 1, step_w, 13),
+ Qt.AlignHCenter | Qt.AlignTop, name)
+
+ def _paint_compact(self, painter: QPainter, n: int, step_w: float) -> None:
+ cy, r = self.height() / 2, 5
+ for i, (_name, status) in enumerate(self._stages):
+ cx = step_w * i + step_w / 2
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(self._color(status))
+ painter.drawEllipse(QPointF(cx, cy), r, r)
+
+
+class _RunCard(QWidget):
+ def __init__(self, run: dict, palette: Palette, parent=None):
+ super().__init__(parent)
+ p = palette
+ self.setAttribute(Qt.WA_StyledBackground, True)
+ err = bool(run.get("error"))
+ self.setStyleSheet(
+ f"_RunCard {{ background:{p.surface};"
+ f" border:1px solid {p.danger if err else p.border_panel};"
+ f" border-radius:10px; }}")
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(12, 9, 12, 9)
+ lay.setSpacing(4)
+ head = QLabel(
+ f"{run.get('label','Run')} · {run.get('type','')}"
+ f" {run.get('when','')}"
+ f"{(' · ' + run['res']) if run.get('res') else ''}")
+ head.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:11.5px; color:{p.text_primary};")
+ lay.addWidget(head)
+ lay.addWidget(RunLEDStrip(run["stages"], palette))
+ if err:
+ e = QLabel(f"⛔ {run['error']}")
+ e.setWordWrap(True)
+ e.setStyleSheet(f"font-size:11px; color:{p.danger};")
+ lay.addWidget(e)
+
+
+class SampleHistoryPanel(QWidget):
+ """Header + scrollable list of run cards for one sample."""
+
+ def __init__(self, palette: Palette, parent=None):
+ super().__init__(parent)
+ self.setObjectName("SidePanel")
+ self._p = palette
+ self.setMinimumWidth(360)
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+
+ head = QWidget()
+ head.setStyleSheet(f"border-bottom:1px solid {palette.border_panel};")
+ hl = QVBoxLayout(head)
+ hl.setContentsMargins(16, 14, 16, 12)
+ hl.setSpacing(2)
+ self._title = QLabel("Sample history")
+ self._title.setStyleSheet("font-size:15px; font-weight:700;")
+ hl.addWidget(self._title)
+ self._summary = QLabel("")
+ self._summary.setStyleSheet(
+ f"font-family:{FONT_MONO_FALLBACK}; font-size:11px; color:{palette.text_muted};")
+ hl.addWidget(self._summary)
+ outer.addWidget(head)
+
+ self._scroll = QScrollArea()
+ self._scroll.setWidgetResizable(True)
+ self._scroll.setFrameShape(QScrollArea.NoFrame)
+ self._list = QWidget()
+ self._list_lay = QVBoxLayout(self._list)
+ self._list_lay.setContentsMargins(12, 12, 12, 12)
+ self._list_lay.setSpacing(8)
+ self._list_lay.addStretch(1)
+ self._scroll.setWidget(self._list)
+ outer.addWidget(self._scroll, 1)
+
+ self._empty = QLabel("Select a sample to see its run history.")
+ self._empty.setAlignment(Qt.AlignCenter)
+ self._empty.setStyleSheet(f"color:{palette.text_faint}; font-size:12px;")
+ outer.addWidget(self._empty)
+ self._scroll.setVisible(False)
+
+ def set_history(self, name: str, summary: str, runs: list) -> None:
+ self._title.setText(name or "Sample history")
+ self._summary.setText(summary or "")
+ while self._list_lay.count() > 1:
+ w = self._list_lay.takeAt(0).widget()
+ if w:
+ w.setParent(None)
+ w.deleteLater()
+ for run in runs:
+ self._list_lay.insertWidget(self._list_lay.count() - 1,
+ _RunCard(run, self._p))
+ has = bool(runs)
+ self._scroll.setVisible(has)
+ self._empty.setVisible(not has)
+ if not has and name:
+ self._empty.setText(f"No runs recorded for {name} yet.")