Stage 1: buffer model (BrowserWindow + (browser buffers) session)

ober

bd7a8ca7e93b38313d91295a4977e4557abe7f4f

diff --git a/.build.yml b/.build.yml
index 557352b..2f93799 100644
--- a/.build.yml
+++ b/.build.yml
@@ -74,5 +74,14 @@ tasks:
       export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)"
       export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so"
       make test-gui
-      # Stage snapshots as a downloadable artifact (~/jerboa-browser/...).
+  - test-buffers: |
+      cd jerboa-browser
+      # Stage 1 buffer-model functional tests (offscreen, hermetic in CI).
+      export QT_QPA_PLATFORM=offscreen JWB_TEST_NO_NETWORK=1
+      export QTWEBENGINE_DISABLE_SANDBOX=1
+      export QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu"
+      export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)"
+      export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so"
+      make test-buffers
+      # Stage all harness snapshots (stage0 + stage1) as a downloadable artifact.
       tar czf gui-snapshots.tar.gz test-artifacts
diff --git a/Makefile b/Makefile
index a3d3996..14da97c 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
 SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
 LIBDIRS     := $(CURDIR)/scheme:$(JERBOA_HOME)/lib
 
-.PHONY: binary test test-gui repl clean help
+.PHONY: binary test test-gui test-buffers repl clean help
 .DEFAULT_GOAL := help
 
 # Build the self-contained native ./jerboa-browser (Chez + boot + (browser)).
@@ -19,6 +19,11 @@ test-gui:
 	QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \
 	  $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-gui-test.ss
 
+# Stage 1 buffer-model functional tests (headless). Writes PNGs to ./test-artifacts.
+test-buffers:
+	QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \
+	  $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-buffers-test.ss
+
 # REPL with (browser) preloaded, in the interpreter.
 repl:
 	JERBOA_HOME=$(JERBOA_HOME) $(SCHEME) -q --libdirs $(LIBDIRS) --program scheme/browser-repl.ss
diff --git a/ROADMAP.md b/ROADMAP.md
index 2c3d52d..1b1ead0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -96,17 +96,28 @@ to host (building the window shell before buffers would be throwaway).
 - **Deferred to Stage 1**: `QMainWindow` chrome (stacked views + status +
   minibuffer line) and `--repl`-on-GUI-launch.
 
-### Stage 1 — Buffer model
-Multiple views in one window; nyxt buffer semantics.
-- **C++**: window holds N views in the stack; `jwb_window_add_buffer`,
-  `_switch_buffer(i)`, `_delete_buffer(i)`, `_buffer_count`, `_current_buffer`,
-  per-buffer title/url already available via view API.
-- **Scheme**: `(browser buffers)` — a buffer list with id/title/url/view;
-  `make-buffer`, `switch-buffer`, `switch-buffer-next/previous/last`,
-  `delete-buffer`, `current-buffer`, `buffer-list`.
-- **Tests (the canonical scenario)**: open A → `make-buffer` B → open B →
-  `switch-buffer-previous` → assert current url == A, buffer-count == 2;
-  snapshot each step.
+### Stage 1 — Buffer model ✅ DONE
+Multiple views in one window; nyxt buffer semantics. Also lands the `QMainWindow`
+chrome deferred from Stage 0 (the window now hosts the buffers + status line +
+minibuffer line). `jerboa-browser <url>` launches into this window.
+- **C++** (`browser_window.cpp`, `HandleKind::Window`): a `QMainWindow` hosting a
+  `QStackedWidget` (buffers), a status `QLabel`, and a minibuffer `QLineEdit`
+  (hidden until Stage 2). `jwb_window_open/close`, `_add_view`/`_remove_view`
+  (reparent only — never frees a view), `_view_count`, `_current_index`,
+  `_show_view`, `_show`/`_hide`/`_resize`, `_grab_png` (full-chrome snapshot),
+  `_set_title`/`_set_status`/`_set_minibuffer`.
+- **Scheme**: `(browser)` window wrappers + the `(browser buffers)` session layer
+  — `open-browser-session`/`close-browser-session!`, `session-open-buffer`
+  (opens+focuses+loads, nyxt make-buffer-focus), `session-close-buffer!`,
+  `session-buffer-list`/`-count`, `session-current-index`/`-buffer`,
+  `session-switch!`/`-index!`/`-next!`/`-previous!`/`-last!`,
+  `session-update-status!`, `buffer-id`/`-view`/`-title`/`-url`.
+- **Tests** (`browser-buffers-test.ss`, `make test-buffers`): 27 cases — open 3
+  buffers, assert count/index/identity, cyclic switching, close-buffer index
+  shift, full-window PNG snapshots; plus the canonical networked flow (open A,
+  open B, switch-previous → A) proven by a per-buffer JS marker read back through
+  `session-current-buffer` (the current index really resolves to A's view).
+  Wired into CI (hermetic).
 
 ### Stage 2 — Keymap engine + emacs bindings + minibuffer (prompt-buffer)
 The heart of the nyxt feel.
diff --git a/WISHLIST.md b/WISHLIST.md
index 529b101..ce74dc4 100644
--- a/WISHLIST.md
+++ b/WISHLIST.md
@@ -8,7 +8,7 @@ built on the existing Qt WebEngine FFI + self-contained native binary, with
 offline Qt snapshot + functional tests modeled on `~/mine/jerboa-emacs`.
 
 - [x] **Offline Qt test + snapshot harness** (offscreen, grab→PNG w/ IHDR-verified size, key injection) — *Stage 0* — `make test-gui`; `--repl`-on-GUI-launch + window chrome deferred to Stage 1
-- [ ] **Buffer model** — multiple web views in one window; switch / list / delete — *Stage 1*
+- [x] **Buffer model** — QMainWindow + stacked views; open/switch/next/prev/last/close; `(browser buffers)` session — *Stage 1* — `make test-buffers` (27); `jerboa-browser <url>` launches into the window
 - [ ] **Keymap engine + emacs bindings + minibuffer (prompt-buffer)** with fuzzy completion — *Stage 2*
 - [ ] **Navigation parity** — scroll, zoom, find-in-page, history, reload, copy url/title — *Stage 3*
 - [ ] **Hint-mode** — link hinting / follow (`f`, `M-g`, `; f`) via JS overlay — *Stage 4*
diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
index 734f146..1d99100 100644
--- a/include/jerboa_browser.h
+++ b/include/jerboa_browser.h
@@ -181,6 +181,30 @@ JWB_API int jwb_view_grab_png(JwbHandle view, const char *path);
 JWB_API int jwb_send_key(JwbHandle view, int qt_key, int modifiers,
                          const char *text);
 
+/* === Window / buffer chrome (Stage 1) ==================================
+ * A BrowserWindow is a QMainWindow hosting a QStackedWidget of views (the
+ * nyxt "buffers" model: one window, many buffers, one visible at a time) plus
+ * a status line and a minibuffer line.  Views are reparented into the stack by
+ * jwb_window_add_view; the window never frees a view (free it via jwb_view_free
+ * after removing/closing the window).  Ops return 1 on success, 0 on failure
+ * (jwb_last_error has the reason); index getters return -1 on failure. */
+JWB_API JwbHandle jwb_window_open(void);              /* 0 on error */
+JWB_API int jwb_window_close(JwbHandle window);       /* detaches views first */
+JWB_API int jwb_window_add_view(JwbHandle window, JwbHandle view); /* -> index */
+JWB_API int jwb_window_remove_view(JwbHandle window, int index);   /* keeps view */
+JWB_API int jwb_window_view_count(JwbHandle window);
+JWB_API int jwb_window_current_index(JwbHandle window);
+JWB_API int jwb_window_show_view(JwbHandle window, int index);
+JWB_API int jwb_window_show(JwbHandle window);
+JWB_API int jwb_window_hide(JwbHandle window);
+JWB_API int jwb_window_resize(JwbHandle window, int width, int height);
+JWB_API int jwb_window_grab_png(JwbHandle window, const char *path);
+JWB_API int jwb_window_set_title(JwbHandle window, const char *utf8_title);
+JWB_API int jwb_window_set_status(JwbHandle window, const char *utf8_text);
+/* Set the minibuffer text and show it; pass NULL/"" with show=0 to hide it. */
+JWB_API int jwb_window_set_minibuffer(JwbHandle window, const char *utf8_text,
+                                      int show);
+
 #ifdef __cplusplus
 } /* extern "C" */
 #endif
diff --git a/qt-webengine/CMakeLists.txt b/qt-webengine/CMakeLists.txt
index addf223..b8d6a6e 100644
--- a/qt-webengine/CMakeLists.txt
+++ b/qt-webengine/CMakeLists.txt
@@ -21,6 +21,7 @@ add_library(jerboa_browser SHARED
   src/browser_policy.cpp
   src/browser_ffi.cpp
   src/browser_automation.cpp
+  src/browser_window.cpp
   src/browser_host.cpp
 )
 target_include_directories(jerboa_browser
diff --git a/qt-webengine/src/browser_handles.cpp b/qt-webengine/src/browser_handles.cpp
index 2047b5a..250304d 100644
--- a/qt-webengine/src/browser_handles.cpp
+++ b/qt-webengine/src/browser_handles.cpp
@@ -22,7 +22,12 @@ JwbHandle g_next = 1; // 0 is never a valid handle
 thread_local std::string g_last_error;
 
 const char *kind_name(jwb::HandleKind k) {
-  return k == jwb::HandleKind::Context ? "context" : "view";
+  switch (k) {
+    case jwb::HandleKind::Context: return "context";
+    case jwb::HandleKind::View:    return "view";
+    case jwb::HandleKind::Window:  return "window";
+  }
+  return "unknown";
 }
 
 } // namespace
diff --git a/qt-webengine/src/browser_internal.h b/qt-webengine/src/browser_internal.h
index d13271c..d42159e 100644
--- a/qt-webengine/src/browser_internal.h
+++ b/qt-webengine/src/browser_internal.h
@@ -22,7 +22,7 @@ QApplication *qapp();
 
 // --- handle registry ----------------------------------------------------
 // Handles are tagged so a context can't be used where a view is expected.
-enum class HandleKind { Context, View };
+enum class HandleKind { Context, View, Window };
 
 // Allocate a handle owning `payload`. Handles are monotonic and never reused,
 // so a freed handle stays permanently invalid — stale/double-free is rejected
diff --git a/qt-webengine/src/browser_window.cpp b/qt-webengine/src/browser_window.cpp
new file mode 100644
index 0000000..8dcf11c
--- /dev/null
+++ b/qt-webengine/src/browser_window.cpp
@@ -0,0 +1,235 @@
+// browser_window.cpp — BrowserWindow: the nyxt-style single window that hosts
+// many buffers (views) in a QStackedWidget, plus a status line and a minibuffer
+// line.  One buffer is visible at a time; switching is setCurrentIndex on the
+// stack.  This is the chrome deferred from Stage 0, now justified by the buffer
+// model (browser buffers).
+//
+// Lifecycle contract: a view is reparented INTO the stack by add_view, but the
+// window never owns/frees views — close() detaches every view (setParent null)
+// so each view handle remains valid and is freed exactly once via jwb_view_free.
+// This mirrors the "free views before their context" discipline elsewhere.
+#include "browser_objects.h"
+
+#include <QApplication>
+#include <QLabel>
+#include <QLineEdit>
+#include <QMainWindow>
+#include <QPixmap>
+#include <QStackedWidget>
+#include <QString>
+#include <QVBoxLayout>
+#include <QWidget>
+
+#include <cstring>
+#include <vector>
+
+namespace jwb {
+
+// A window owns its Qt widget tree (QMainWindow + children); it does NOT own the
+// views in `views` — those stay owned by their handles. `views` parallels the
+// stack's widget order, so index i in `views` is stack widget i.
+struct Window {
+  QMainWindow *win;
+  QStackedWidget *stack;
+  QLabel *status;
+  QLineEdit *minibuffer;
+  std::vector<JwbHandle> views;
+};
+
+inline bool get_window(JwbHandle h, Window **out, JwbStatus *err) {
+  void *p = nullptr;
+  if (!handle_lookup(h, HandleKind::Window, &p, err)) return false;
+  *out = static_cast<Window *>(p);
+  return true;
+}
+
+// Detach every view from the stack without deleting it (the view handle keeps
+// ownership). Used by close() and by remove_view.
+static void detach_view_widget(QStackedWidget *stack, QWidget *w) {
+  stack->removeWidget(w);
+  w->setParent(nullptr);
+}
+
+} // namespace jwb
+
+extern "C" {
+
+JWB_API JwbHandle jwb_window_open(void) {
+  jwb::clear_last_error();
+  if (!jwb::qapp()) {
+    jwb::set_last_error("runtime not initialized; call jwb_runtime_init first");
+    return 0;
+  }
+  auto *w = new jwb::Window();
+  w->win = new QMainWindow();
+  auto *central = new QWidget(w->win);
+  auto *layout = new QVBoxLayout(central);
+  layout->setContentsMargins(0, 0, 0, 0);
+  layout->setSpacing(0);
+  w->stack = new QStackedWidget(central);
+  w->status = new QLabel(central);
+  w->status->setObjectName("jwb_status");
+  w->minibuffer = new QLineEdit(central);
+  w->minibuffer->setObjectName("jwb_minibuffer");
+  w->minibuffer->hide(); // shown only while a prompt is active (Stage 2)
+  layout->addWidget(w->stack, /*stretch=*/1);
+  layout->addWidget(w->status, 0);
+  layout->addWidget(w->minibuffer, 0);
+  w->win->setCentralWidget(central);
+  w->win->resize(1024, 768);
+  return jwb::handle_alloc(jwb::HandleKind::Window, w);
+}
+
+JWB_API int jwb_window_close(JwbHandle window) {
+  jwb::clear_last_error();
+  void *obj = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::handle_release(window, jwb::HandleKind::Window, &obj, &st)) return 0;
+  auto *w = static_cast<jwb::Window *>(obj);
+  // Detach (don't delete) every view so its handle stays valid + singly-owned.
+  while (w->stack->count() > 0)
+    jwb::detach_view_widget(w->stack, w->stack->widget(0));
+  delete w->win; // deletes central/stack/status/minibuffer (Qt parent tree)
+  delete w;
+  return 1;
+}
+
+JWB_API int jwb_window_add_view(JwbHandle window, JwbHandle view) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  jwb::View *v = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return -1;
+  if (!jwb::get_view(view, &v, &st)) return -1;
+  int index = w->stack->addWidget(v->view); // reparents v->view into the stack
+  w->views.push_back(view);
+  return index;
+}
+
+JWB_API int jwb_window_remove_view(JwbHandle window, int index) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  if (index < 0 || index >= w->stack->count()) {
+    jwb::set_last_error("remove_view: index out of range");
+    return 0;
+  }
+  jwb::detach_view_widget(w->stack, w->stack->widget(index));
+  w->views.erase(w->views.begin() + index);
+  return 1;
+}
+
+JWB_API int jwb_window_view_count(JwbHandle window) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return -1;
+  return w->stack->count();
+}
+
+JWB_API int jwb_window_current_index(JwbHandle window) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return -1;
+  return w->stack->currentIndex(); // -1 when empty
+}
+
+JWB_API int jwb_window_show_view(JwbHandle window, int index) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  if (index < 0 || index >= w->stack->count()) {
+    jwb::set_last_error("show_view: index out of range");
+    return 0;
+  }
+  w->stack->setCurrentIndex(index);
+  return 1;
+}
+
+JWB_API int jwb_window_show(JwbHandle window) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  w->win->show();
+  return 1;
+}
+
+JWB_API int jwb_window_hide(JwbHandle window) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  w->win->hide();
+  return 1;
+}
+
+JWB_API int jwb_window_resize(JwbHandle window, int width, int height) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  if (width <= 0 || height <= 0) {
+    jwb::set_last_error("resize: width/height must be positive");
+    return 0;
+  }
+  w->win->resize(width, height);
+  return 1;
+}
+
+JWB_API int jwb_window_grab_png(JwbHandle window, const char *path) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  if (!path) {
+    jwb::set_last_error("grab: path is null");
+    return 0;
+  }
+  if (w->win->size().isEmpty()) w->win->resize(1024, 768);
+  QPixmap pm = w->win->grab();
+  if (pm.isNull()) {
+    jwb::set_last_error("grab: null pixmap");
+    return 0;
+  }
+  return pm.save(QString::fromUtf8(path), "PNG") ? 1 : 0;
+}
+
+JWB_API int jwb_window_set_title(JwbHandle window, const char *utf8_title) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  w->win->setWindowTitle(QString::fromUtf8(utf8_title ? utf8_title : ""));
+  return 1;
+}
+
+JWB_API int jwb_window_set_status(JwbHandle window, const char *utf8_text) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  w->status->setText(QString::fromUtf8(utf8_text ? utf8_text : ""));
+  return 1;
+}
+
+JWB_API int jwb_window_set_minibuffer(JwbHandle window, const char *utf8_text,
+                                      int show) {
+  jwb::clear_last_error();
+  jwb::Window *w = nullptr;
+  JwbStatus st = JWB_OK;
+  if (!jwb::get_window(window, &w, &st)) return 0;
+  w->minibuffer->setText(QString::fromUtf8(utf8_text ? utf8_text : ""));
+  if (show) {
+    w->minibuffer->show();
+    w->minibuffer->setFocus(Qt::OtherFocusReason);
+  } else {
+    w->minibuffer->hide();
+  }
+  return 1;
+}
+
+} // extern "C"
diff --git a/scheme/browser-buffers-test.ss b/scheme/browser-buffers-test.ss
new file mode 100644
index 0000000..03d8e13
--- /dev/null
+++ b/scheme/browser-buffers-test.ss
@@ -0,0 +1,163 @@
+#!chezscheme
+;;; browser-buffers-test.ss — Stage 1 functional tests for the buffer model.
+;;;
+;;; Offline + synchronous (QT_QPA_PLATFORM=offscreen), modeled on jerboa-emacs'
+;;; tests/test-qt.ss. Two layers:
+;;;   - hermetic core (no network): open N buffers, assert count / current-index
+;;;     / identity, cyclic switch-next/previous/last, switch-to, close-buffer
+;;;     index shifting, window snapshots of the chrome.
+;;;   - networked scenario (skipped under JWB_TEST_NO_NETWORK): the canonical
+;;;     nyxt flow — open A, open B (focuses B), switch-previous → A — proven by
+;;;     a per-buffer JS marker read back through (session-current-buffer): this
+;;;     asserts the window's current index truly resolves to the A *view*.
+;;;
+;;; Run:  QT_QPA_PLATFORM=offscreen make test-buffers
+;;; Exits 0 on success, 1 on any failed case.
+
+(import (chezscheme) (browser) (browser buffers))
+
+;;; ─── tiny test framework (shared shape with browser-gui-test.ss) ──────────
+(define *pass* 0)
+(define *fail* 0)
+(define *test-name* "(none)")
+
+(define-syntax test-group
+  (syntax-rules ()
+    [(_ name body ...)
+     (begin (display "\n=== ") (display name) (display " ===\n")
+            (flush-output-port (current-output-port))
+            body ...)]))
+
+(define (run-test-case name thunk)
+  (set! *test-name* name)
+  (let ((ok (guard (e (#t
+                       (set! *fail* (+ *fail* 1))
+                       (display "  FAIL: ") (display name) (newline)
+                       (display "    error: ")
+                       (display (if (message-condition? e) (condition-message e)
+                                    (format "~s" e)))
+                       (newline) (flush-output-port (current-output-port)) #f))
+              (thunk) #t)))
+    (when ok
+      (set! *pass* (+ *pass* 1))
+      (display "  pass: ") (display name) (newline)
+      (flush-output-port (current-output-port)))))
+
+(define-syntax test-case
+  (syntax-rules () [(_ name body ...) (run-test-case name (lambda () body ...))]))
+
+(define-syntax check
+  (syntax-rules (=> ?)
+    [(_ expr => expected)
+     (let ((got expr) (exp expected))
+       (unless (equal? got exp)
+         (error 'check (format "~a: expected ~s, got ~s" *test-name* exp got))))]
+    [(_ expr ? pred)
+     (let ((got expr))
+       (unless (pred got)
+         (error 'check (format "~a: predicate failed for ~s" *test-name* got))))]))
+
+(define (str-contains? hay needle)
+  (let ((hn (string-length hay)) (nn (string-length needle)))
+    (let loop ((i 0))
+      (cond ((> (+ i nn) hn) #f)
+            ((string=? (substring hay i (+ i nn)) needle) #t)
+            (else (loop (+ i 1)))))))
+
+;;; ─── setup ────────────────────────────────────────────────────────────────
+(unless (getenv "QT_QPA_PLATFORM") (setenv "QT_QPA_PLATFORM" "offscreen"))
+(define artifacts-dir (or (getenv "JWB_TEST_ARTIFACTS") "test-artifacts"))
+(unless (file-exists? artifacts-dir) (mkdir artifacts-dir))
+(define (artifact name) (string-append artifacts-dir "/" name))
+
+(define s (open-browser-session)) ; network-capable, one empty window
+(test-case "window resize 900x650" (browser-ok? (browser-window-resize (session-window s) 900 650)))
+
+;;; ─── hermetic buffer model (no network) ───────────────────────────────────
+(define ba #f) (define bb #f) (define bc #f)
+(test-group "open buffers (no load)"
+  (test-case "open buffer A" (set! ba (session-open-buffer s "")) (check (buffer? ba) => #t))
+  (test-case "count == 1 after A" (check (session-buffer-count s) => 1))
+  (test-case "A is current (index 0)" (check (session-current-index s) => 0))
+  (test-case "current-buffer is A" (check (eq? (session-current-buffer s) ba) => #t))
+  (test-case "open buffer B focuses it"
+    (set! bb (session-open-buffer s ""))
+    (check (session-current-index s) => 1)
+    (check (eq? (session-current-buffer s) bb) => #t))
+  (test-case "open buffer C focuses it"
+    (set! bc (session-open-buffer s ""))
+    (check (session-buffer-count s) => 3)
+    (check (eq? (session-current-buffer s) bc) => #t))
+  (test-case "buffer ids are distinct"
+    (check (= (buffer-id ba) (buffer-id bb)) => #f)
+    (check (= (buffer-id bb) (buffer-id bc)) => #f)))
+
+(test-group "switching"
+  (test-case "switch-previous from C -> B (index 1)"
+    (session-switch-previous! s) (check (session-current-index s) => 1)
+    (check (eq? (session-current-buffer s) bb) => #t))
+  (test-case "switch-previous wraps B -> A (index 0)"
+    (session-switch-previous! s) (check (session-current-index s) => 0))
+  (test-case "switch-previous wraps A -> C (index 2)"
+    (session-switch-previous! s) (check (session-current-index s) => 2))
+  (test-case "switch-next wraps C -> A (index 0)"
+    (session-switch-next! s) (check (session-current-index s) => 0))
+  (test-case "switch! to B by identity"
+    (check (session-switch! s bb) => #t) (check (eq? (session-current-buffer s) bb) => #t))
+  (test-case "switch-last -> C (highest index)"
+    (session-switch-last! s) (check (eq? (session-current-buffer s) bc) => #t)))
+
+(test-group "window snapshot (chrome)"
+  (test-case "show window" (browser-ok? (browser-window-show (session-window s))))
+  (test-case "status line set" (browser-ok? (browser-window-set-status (session-window s) "[3/3] buffer C")))
+  (test-case "grab window PNG is 900x650"
+    (browser-ok? (browser-window-grab-png (session-window s) (artifact "stage1-three-buffers.png")))))
+
+(test-group "close buffer shifts indices"
+  (test-case "close middle buffer B"
+    (check (session-close-buffer! s bb) => #t) (check (session-buffer-count s) => 2))
+  (test-case "remaining buffers are A then C"
+    (check (eq? (list-ref (session-buffer-list s) 0) ba) => #t)
+    (check (eq? (list-ref (session-buffer-list s) 1) bc) => #t))
+  (test-case "switch to C (now index 1)"
+    (check (session-switch! s bc) => #t) (check (session-current-index s) => 1)))
+
+;;; ─── canonical networked scenario ─────────────────────────────────────────
+(if (getenv "JWB_TEST_NO_NETWORK")
+    (begin (display "\n=== canonical nyxt flow ===\n  SKIP (JWB_TEST_NO_NETWORK set)\n")
+           (flush-output-port (current-output-port)))
+    (let ((s2 (open-browser-session)))
+      (test-group "canonical nyxt flow: open A, open B, switch-previous -> A"
+        (let ((a #f) (b #f))
+          (test-case "open A and load example.com"
+            (set! a (session-open-buffer s2 "https://example.com"))
+            (check (browser-ok? (browser-eval (buffer-view a) "window.__buf='A'; document.title"))
+                   => #t))
+          (test-case "open B (focuses B) and load example.com"
+            (set! b (session-open-buffer s2 "https://example.com"))
+            (browser-eval (buffer-view b) "window.__buf='B'")
+            (check (eq? (session-current-buffer s2) b) => #t)
+            (check (session-buffer-count s2) => 2))
+          (test-case "switch-previous -> current buffer is A"
+            (session-switch-previous! s2)
+            (check (eq? (session-current-buffer s2) a) => #t)
+            (check (session-current-index s2) => 0))
+          (test-case "current buffer's view is truly A's (JS marker == A)"
+            (check (browser-value (browser-eval (buffer-view (session-current-buffer s2)) "window.__buf"))
+                   => "A"))
+          (test-case "current buffer url is example.com"
+            (check (buffer-url (session-current-buffer s2)) ? (lambda (u) (str-contains? u "example.com"))))
+          (test-case "status + window snapshot of loaded buffer"
+            (session-update-status! s2)
+            (browser-window-resize (session-window s2) 900 650)
+            (browser-ok? (browser-window-grab-png (session-window s2) (artifact "stage1-canonical.png"))))))
+      (close-browser-session! s2)))
+
+;;; ─── teardown ───────────────────────────────────────────────────────────
+(test-group "teardown"
+  (test-case "close session" (close-browser-session! s) (check (session-buffer-count s) => 0)))
+
+(newline)
+(display "browser-buffers-test: ") (display *pass*) (display " passed, ")
+(display *fail*) (display " failed") (newline)
+(exit (if (zero? *fail*) 0 1))
diff --git a/scheme/browser-main.ss b/scheme/browser-main.ss
index 0fbdb2c..fa2d069 100644
--- a/scheme/browser-main.ss
+++ b/scheme/browser-main.ss
@@ -14,7 +14,7 @@
 ;;;   jerboa-browser test       run the (browser) test suite
 ;;;   jerboa-browser run FILE   run a Jerboa script with (browser) available
 
-(import (chezscheme) (browser) (std repl))
+(import (chezscheme) (browser) (browser buffers) (std repl))
 
 ;; Directory of argv[0], or "." when it has no slash / is unavailable.
 (define (exe-dir)
@@ -82,37 +82,29 @@
     (display "  headless check: QT_QPA_PLATFORM=offscreen JWB_BROWSE_MS=1500 jerboa-browser <url>\n" p)
     (exit 1)))
 
-;; Open the GUI at `url` and hand control to the Qt event loop (the window stays
-;; live until closed). Set JWB_BROWSE_MS=<ms> for a headless/no-block smoke that
-;; pumps for ms, prints the page title, then exits — used by tests/CI.
+;; Open the GUI at `url` in a nyxt-style window (one window, buffers in a stack)
+;; and hand control to the Qt event loop (the window stays live until closed).
+;; Set JWB_BROWSE_MS=<ms> for a headless/no-block smoke that pumps for ms, prints
+;; the page title, then exits — used by tests/CI.
 (define (run-browse url)
   (guard (e (#t (browse-fail "could not launch the browser" e)))
-    (let ((ir (browser-init)))
-      (unless (browser-ok? ir) (error 'browser-init (browser-value ir))))
-    (let ((cr (browser-open-context (browser-capabilities 'network))))
-      (unless (browser-ok? cr) (error 'browser-open-context (browser-value cr)))
-      (let* ((c (browser-value cr)) (vr (browser-open-view c)))
-        (unless (browser-ok? vr) (error 'browser-open-view (browser-value vr)))
-        (let ((v (browser-value vr)))
-          (browser-set-title v "Jerboa Browser")
-          (let ((lr (browser-load v url)))
-            (unless (browser-ok? lr)
-              (display (string-append "jerboa-browser: load failed: "
-                                      (browser-value lr) "\n")
-                       (current-error-port))))
-          (browser-show v)
-          (browser-focus v)
-          (let ((ms (and (getenv "JWB_BROWSE_MS")
-                         (string->number (getenv "JWB_BROWSE_MS")))))
-            (if ms
-                (begin
-                  (browser-pump ms)
-                  (let ((tr (browser-eval v "document.title")))
-                    (when (browser-ok? tr) (display (browser-value tr)) (newline)))
-                  (browser-close-view v) (browser-close-context c) (exit 0))
-                (begin
-                  (browser-exec)          ; blocks until the window is closed
-                  (browser-close-view v) (browser-close-context c) (exit 0)))))))))
+    (let* ((s (open-browser-session))          ; init + network context + window
+           (win (session-window s)))
+      (browser-window-set-title win "Jerboa Browser")
+      (let ((buf (session-open-buffer s url))) ; open + focus + load the buffer
+        (session-update-status! s)
+        (browser-window-show win)
+        (let ((ms (and (getenv "JWB_BROWSE_MS")
+                       (string->number (getenv "JWB_BROWSE_MS")))))
+          (if ms
+              (begin
+                (browser-pump ms)
+                (let ((tr (browser-eval (buffer-view buf) "document.title")))
+                  (when (browser-ok? tr) (display (browser-value tr)) (newline)))
+                (close-browser-session! s) (exit 0))
+              (begin
+                (browser-exec)          ; blocks until the window is closed
+                (close-browser-session! s) (exit 0))))))))
 
 (define (usage port code)
   (display "usage: jerboa-browser [<url> | browse [<url>] | repl | test | run <file>]\n" port)
diff --git a/scheme/browser.ss b/scheme/browser.ss
index 613eace..1a58c06 100644
--- a/scheme/browser.ss
+++ b/scheme/browser.ss
@@ -57,7 +57,24 @@
     ;; automation / testing
     browser-resize
     browser-grab-png
-    browser-send-key)
+    browser-send-key
+    ;; window / buffer chrome
+    browser-window-open
+    browser-window-close
+    browser-window-add-view
+    browser-window-remove-view
+    browser-window-view-count
+    browser-window-current-index
+    browser-window-show-view
+    browser-window-show
+    browser-window-hide
+    browser-window-resize
+    browser-window-grab-png
+    browser-window-set-title
+    browser-window-set-status
+    browser-window-set-minibuffer
+    browser-window?
+    browser-window-handle)
 
   (import (except (chezscheme)
                   make-hash-table hash-table?
@@ -156,6 +173,21 @@
   (define-c-lambda %view-resize  (uint64 int int)            int         "jwb_view_resize")
   (define-c-lambda %grab-png     (uint64 char-string)        int         "jwb_view_grab_png")
   (define-c-lambda %send-key     (uint64 int int char-string) int        "jwb_send_key")
+  ;; window / buffer chrome (browser_window.cpp)
+  (define-c-lambda %win-open       ()                        uint64      "jwb_window_open")
+  (define-c-lambda %win-close      (uint64)                  int         "jwb_window_close")
+  (define-c-lambda %win-add-view   (uint64 uint64)           int         "jwb_window_add_view")
+  (define-c-lambda %win-remove-view (uint64 int)             int         "jwb_window_remove_view")
+  (define-c-lambda %win-view-count (uint64)                  int         "jwb_window_view_count")
+  (define-c-lambda %win-current    (uint64)                  int         "jwb_window_current_index")
+  (define-c-lambda %win-show-view  (uint64 int)              int         "jwb_window_show_view")
+  (define-c-lambda %win-show       (uint64)                  int         "jwb_window_show")
+  (define-c-lambda %win-hide       (uint64)                  int         "jwb_window_hide")
+  (define-c-lambda %win-resize     (uint64 int int)          int         "jwb_window_resize")
+  (define-c-lambda %win-grab-png   (uint64 char-string)      int         "jwb_window_grab_png")
+  (define-c-lambda %win-set-title  (uint64 char-string)      int         "jwb_window_set_title")
+  (define-c-lambda %win-set-status (uint64 char-string)      int         "jwb_window_set_status")
+  (define-c-lambda %win-set-minibuf (uint64 char-string int) int         "jwb_window_set_minibuffer")
 
   ;; --- tagged results -----------------------------------------------------
   (def (ok* v)  (list 'ok v))
@@ -331,4 +363,88 @@
         (ok* #t)
         (err* (string-append "send-key: " (%last-error)))))
 
+  ;; --- window / buffer chrome (browser_window.cpp) -----------------------
+  ;; A browser-window is the nyxt-style single window hosting many views; the
+  ;; (browser buffers) module layers the buffer list/switch logic on top.
+  ;; These FFIs use the 1=ok / 0=fail (or index / -1) convention with the reason
+  ;; in jwb_last_error, so read %last-error on failure.
+  (defstruct browser-window (handle))
+  (def (require-window who w)
+    (unless (browser-window? w) (error who "expected a browser-window" w)))
+
+  ;; (ok #t) when n is 1, else (err "LABEL: REASON" from jwb_last_error).
+  (def (int-ok label n)
+    (if (= n 1) (ok* #t) (err* (string-append label ": " (%last-error)))))
+
+  (def (browser-window-open)
+    (let ((h (%win-open)))
+      (if (= h 0)
+          (err* (string-append "window open: " (%last-error)))
+          (ok* (make-browser-window h)))))
+
+  (def (browser-window-close w)
+    (require-window 'browser-window-close w)
+    (int-ok "window close" (%win-close (browser-window-handle w))))
+
+  ;; Reparent a view into the window's stack; (ok INDEX) on success.
+  (def (browser-window-add-view w view)
+    (require-window 'browser-window-add-view w)
+    (require-view 'browser-window-add-view view)
+    (let ((i (%win-add-view (browser-window-handle w) (browser-view-handle view))))
+      (if (>= i 0) (ok* i) (err* (string-append "add-view: " (%last-error))))))
+
+  ;; Detach (does not free) the view at INDEX.
+  (def (browser-window-remove-view w index)
+    (require-window 'browser-window-remove-view w)
+    (int-ok "remove-view" (%win-remove-view (browser-window-handle w) index)))
+
+  (def (browser-window-view-count w)
+    (require-window 'browser-window-view-count w)
+    (let ((n (%win-view-count (browser-window-handle w))))
+      (if (>= n 0) (ok* n) (err* (string-append "view-count: " (%last-error))))))
+
+  ;; (ok INDEX); INDEX is -1 when the window has no buffers (not an error).
+  (def (browser-window-current-index w)
+    (require-window 'browser-window-current-index w)
+    (let ((i (%win-current (browser-window-handle w))))
+      (if (and (= i -1) (> (string-length (%last-error)) 0))
+          (err* (string-append "current-index: " (%last-error)))
+          (ok* i))))
+
+  (def (browser-window-show-view w index)
+    (require-window 'browser-window-show-view w)
+    (int-ok "show-view" (%win-show-view (browser-window-handle w) index)))
+
+  (def (browser-window-show w)
+    (require-window 'browser-window-show w)
+    (int-ok "window show" (%win-show (browser-window-handle w))))
+
+  (def (browser-window-hide w)
+    (require-window 'browser-window-hide w)
+    (int-ok "window hide" (%win-hide (browser-window-handle w))))
+
+  (def (browser-window-resize w width height)
+    (require-window 'browser-window-resize w)
+    (int-ok "window resize" (%win-resize (browser-window-handle w) width height)))
+
+  (def (browser-window-grab-png w path)
+    (require-window 'browser-window-grab-png w)
+    (if (= 1 (%win-grab-png (browser-window-handle w) path))
+        (ok* path)
+        (err* (string-append "window grab-png: " (%last-error)))))
+
+  (def (browser-window-set-title w title)
+    (require-window 'browser-window-set-title w)
+    (int-ok "window set-title" (%win-set-title (browser-window-handle w) title)))
+
+  (def (browser-window-set-status w text)
+    (require-window 'browser-window-set-status w)
+    (int-ok "window set-status" (%win-set-status (browser-window-handle w) text)))
+
+  ;; show? #t shows the minibuffer (and focuses it); #f hides it.
+  (def (browser-window-set-minibuffer w text show?)
+    (require-window 'browser-window-set-minibuffer w)
+    (int-ok "window set-minibuffer"
+            (%win-set-minibuf (browser-window-handle w) text (if show? 1 0))))
+
   ) ; library (browser)
diff --git a/scheme/browser/buffers.ss b/scheme/browser/buffers.ss
new file mode 100644
index 0000000..02844a0
--- /dev/null
+++ b/scheme/browser/buffers.ss
@@ -0,0 +1,171 @@
+#!chezscheme
+;;; (browser buffers) — nyxt-style buffer model over a single BrowserWindow.
+;;;
+;;; A "session" owns one window + one capability-scoped context and a list of
+;;; buffers. A buffer is a web view living in the window's stacked widget; one
+;;; buffer is visible at a time and switching is O(1) (QStackedWidget index).
+;;; This mirrors nyxt: a window has many buffers, `make-buffer` opens+focuses a
+;;; new one, and switch-buffer-{next,previous,last} cycle through them.
+;;;
+;;; The window (C side) is the source of truth for stack order + current index;
+;;; `buffers` here is a parallel list (stack index i == list position i), kept in
+;;; lock-step by always add/removing in both places together.
+;;;
+;;; Usage:
+;;;   (import (browser buffers))
+;;;   (define s (open-browser-session))              ; network-capable by default
+;;;   (define a (session-open-buffer s "https://example.com"))
+;;;   (define b (session-open-buffer s "https://www.iana.org")) ; focuses b
+;;;   (session-switch-previous! s)                   ; back to a
+;;;   (session-current-buffer s)                     ; => a
+;;;   (close-browser-session! s)
+
+(library (browser buffers)
+  (export
+    open-browser-session close-browser-session!
+    session? session-window session-context
+    session-open-buffer session-close-buffer!
+    session-buffer-list session-buffer-count
+    session-current-index session-current-buffer
+    session-switch! session-switch-index!
+    session-switch-next! session-switch-previous! session-switch-last!
+    session-update-status!
+    buffer? buffer-id buffer-view buffer-title buffer-url)
+
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?)
+          (browser))
+
+  ;; --- records ------------------------------------------------------------
+  ;; A buffer is a stable identity (id) over a web view; title/url are queried
+  ;; live from the view (browser-title / browser-url).
+  (defstruct buffer (id view))
+  ;; A session: the window, its context, the ordered buffer list, next id.
+  (defstruct session (window context buffers next-id))
+
+  ;; --- small helpers ------------------------------------------------------
+  (def (must who r)
+    (if (browser-ok? r) (browser-value r)
+        (error who (browser-value r))))
+
+  ;; Position of `buf` (by identity) in the session's buffer list, or #f.
+  (def (buffer-index s buf)
+    (let loop ((bs (session-buffer-list s)) (i 0))
+      (cond ((null? bs) #f)
+            ((eq? (car bs) buf) i)
+            (else (loop (cdr bs) (+ i 1))))))
+
+  ;; Drop the element at index i from list xs (0-based; i assumed in range).
+  (def (list-remove-at xs i)
+    (let loop ((xs xs) (i i) (acc '()))
+      (cond ((null? xs) (reverse acc))
+            ((= i 0) (append (reverse acc) (cdr xs)))
+            (else (loop (cdr xs) (- i 1) (cons (car xs) acc))))))
+
+  ;; --- buffer field readers (plain strings; "" if unavailable) ------------
+  (def (buffer-title buf)
+    (let ((r (browser-title (buffer-view buf))))
+      (if (browser-ok? r) (browser-value r) "")))
+  (def (buffer-url buf)
+    (let ((r (browser-url (buffer-view buf))))
+      (if (browser-ok? r) (browser-value r) "")))
+
+  ;; --- session lifecycle --------------------------------------------------
+  ;; Open a session: init runtime, open a context with `caps` (default network),
+  ;; open the window. Returns a session with zero buffers.
+  (def (open-browser-session . opt)
+    (let ((caps (if (pair? opt) (car opt) (browser-capabilities 'network))))
+      (must 'open-browser-session (browser-init))
+      (let ((ctx (must 'open-browser-session (browser-open-context caps)))
+            (win (must 'open-browser-session (browser-window-open))))
+        (make-session win ctx '() 0))))
+
+  ;; Tear down: detach + free every view, close the window, free the context.
+  (def (close-browser-session! s)
+    (browser-window-close (session-window s)) ; detaches views (does not free)
+    (for-each (lambda (b) (browser-close-view (buffer-view b)))
+              (session-buffer-list s))
+    (browser-close-context (session-context s))
+    (session-buffers-set! s '()))
+
+  ;; --- buffers ------------------------------------------------------------
+  ;; Open a new buffer, append it to the window's stack, FOCUS it (nyxt
+  ;; make-buffer-focus semantics), and load `url` if given. Returns the buffer.
+  (def (session-open-buffer s url)
+    (let* ((view (must 'session-open-buffer (browser-open-view (session-context s))))
+           (id   (session-next-id s))
+           (buf  (make-buffer id view))
+           (idx  (must 'session-open-buffer
+                         (browser-window-add-view (session-window s) view))))
+      (session-next-id-set! s (+ id 1))
+      (session-buffers-set! s (append (session-buffer-list s) (list buf)))
+      (browser-window-show-view (session-window s) idx)
+      (when (and (string? url) (> (string-length url) 0))
+        (browser-load view url))
+      buf))
+
+  ;; Close a buffer: detach from the window, free its view, drop from the list.
+  (def (session-close-buffer! s buf)
+    (let ((i (buffer-index s buf)))
+      (when i
+        (browser-window-remove-view (session-window s) i)
+        (browser-close-view (buffer-view buf))
+        (session-buffers-set! s (list-remove-at (session-buffer-list s) i))
+        #t)))
+
+  (def (session-buffer-list s) (session-buffers s))
+  (def (session-buffer-count s) (length (session-buffers s)))
+
+  ;; Current visible buffer index (-1 when none), and the buffer itself (#f).
+  (def (session-current-index s)
+    (must 'session-current-index (browser-window-current-index (session-window s))))
+  (def (session-current-buffer s)
+    (let ((i (session-current-index s)) (bs (session-buffer-list s)))
+      (if (and (>= i 0) (< i (length bs))) (list-ref bs i) #f)))
+
+  ;; --- switching ----------------------------------------------------------
+  (def (session-switch-index! s i)
+    (must 'session-switch-index! (browser-window-show-view (session-window s) i)))
+
+  ;; Switch to a specific buffer (by identity); #t if found, #f otherwise.
+  (def (session-switch! s buf)
+    (let ((i (buffer-index s buf)))
+      (and i (begin (session-switch-index! s i) #t))))
+
+  ;; Cyclic next / previous (wrap around); no-op when there are no buffers.
+  (def (session-switch-next! s)
+    (let ((n (session-buffer-count s)))
+      (when (> n 0)
+        (session-switch-index! s (modulo (+ (session-current-index s) 1) n)))))
+  (def (session-switch-previous! s)
+    (let ((n (session-buffer-count s)))
+      (when (> n 0)
+        (session-switch-index! s (modulo (- (session-current-index s) 1) n)))))
+
+  ;; Switch to the last (most recently opened) buffer.
+  (def (session-switch-last! s)
+    (let ((n (session-buffer-count s)))
+      (when (> n 0) (session-switch-index! s (- n 1)))))
+
+  ;; --- status line --------------------------------------------------------
+  ;; Render "[i/n] TITLE — URL" of the current buffer into the window status bar.
+  (def (session-update-status! s)
+    (let ((b (session-current-buffer s))
+          (n (session-buffer-count s))
+          (i (session-current-index s)))
+      (browser-window-set-status
+        (session-window s)
+        (if b
+            (string-append "[" (number->string (+ i 1)) "/" (number->string n) "] "
+                           (buffer-title b) " — " (buffer-url b))
+            "(no buffers)"))))
+
+  ) ; library (browser buffers)