diff --git a/TODO.md b/TODO.md
index 51610c1..93d9860 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,6 +1,21 @@
# TODO
+## In Progress
+- [ ] Extract `SearchController` — most complex public page (§2 step 4)
+- [ ] Extract `SystemController` — biggest single-file win, 500→8 lines (§2 step 3, §5)
+- [ ] Extract `ThesisEditController` — merges edit.php + actions/edit.php, deduplicate jury fieldset (§2 step 5)
+- [ ] Extract remaining controllers one by one (§2 step 6)
+- [ ] Consolidate action handlers into controller methods (§4)
+- [ ] Introduce pagination partial `templates/partials/pagination.php` (§6)
+- [ ] Introduce admin form partials: select-field, checkbox-list, jury-fieldset (§6)
+- [ ] Unify flash message keys project-wide to `_flash_error` / `_flash_success` (§7)
+- [ ] Move OG tag construction into controller logic (§8)
+- [ ] Extract inline CSS/JS from `system.php` into separate assets (§5)
+
## Completed
+- [x] Create `src/App.php` — boot, adminGuard, verifyCsrf, rotateCsrf, redirect, flash, consumeFlash, render
+- [x] Auto-load `App.php` from `config/bootstrap.php`
+- [x] Create `templates/partials/flash-messages.php` — unified flash partial with legacy key drain
- [x] Merge public and admin head/nav templates into unified `templates/head.php` and `templates/header.php`
- `templates/head.php` — outputs `…
`, reads `$bodyClass`, `$isAdmin`; handles admin title suffix, admin.css prepend, and OG tag suppression internally
- `templates/header.php` — outputs `` with public nav + search bar or admin nav depending on `$isAdmin`
diff --git a/config/bootstrap.php b/config/bootstrap.php
index 886ce5c..09444bd 100644
--- a/config/bootstrap.php
+++ b/config/bootstrap.php
@@ -29,6 +29,9 @@ if (file_exists(APP_ROOT . '/config/admin_credentials.php')) {
require_once APP_ROOT . '/config/admin_credentials.php';
}
+// Central application helper (boot, auth guard, CSRF, flash, render)
+require_once APP_ROOT . '/src/App.php';
+
// Maintenance mode gate — block public pages; allow /admin/ through.
// The flag file lives in storage/ (outside webroot) to avoid web exposure.
define('MAINTENANCE_FLAG', APP_ROOT . '/storage/maintenance.flag');
diff --git a/src/App.php b/src/App.php
new file mode 100644
index 0000000..d72c94d
--- /dev/null
+++ b/src/App.php
@@ -0,0 +1,168 @@
+ $error, 'success' => $success];
+ }
+
+ // ── Redirect ──────────────────────────────────────────────────────────────
+
+ /**
+ * Flash a message and redirect. Terminates the script.
+ */
+ public static function redirect(string $url, ?string $success = null, ?string $error = null): never
+ {
+ if ($success !== null) {
+ self::flash('success', $success);
+ }
+ if ($error !== null) {
+ self::flash('error', $error);
+ }
+ header('Location: ' . $url);
+ exit;
+ }
+
+ // ── Template rendering ────────────────────────────────────────────────────
+
+ /**
+ * Render a full page: head → header → content template → footer.
+ *
+ * Expects $vars to contain the same keys the templates already rely on
+ * ($pageTitle, $bodyClass, $isAdmin, $extraCss, $ogTags, etc.).
+ * The footer variant (public vs admin) is chosen automatically based
+ * on $isAdmin.
+ *
+ * @param string $template Path relative to APP_ROOT/templates/
+ * @param array $vars Variables to expose inside the templates
+ */
+ public static function render(string $template, array $vars = []): void
+ {
+ // Make all vars available in the template scope.
+ extract($vars);
+
+ include APP_ROOT . '/templates/head.php';
+ include APP_ROOT . '/templates/header.php';
+ include APP_ROOT . '/templates/' . $template;
+
+ if (!empty($isAdmin)) {
+ include APP_ROOT . '/templates/admin/footer.php';
+ } else {
+ include APP_ROOT . '/templates/footer.php';
+ }
+ }
+}
diff --git a/src/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/src/cache/rate_limit/ad921d60486366258809553a3db49a4a.json
index c31bce6..6f54142 100644
--- a/src/cache/rate_limit/ad921d60486366258809553a3db49a4a.json
+++ b/src/cache/rate_limit/ad921d60486366258809553a3db49a4a.json
@@ -1 +1 @@
-[1774721459]
\ No newline at end of file
+[1775039085]
\ No newline at end of file
diff --git a/templates/partials/flash-messages.php b/templates/partials/flash-messages.php
new file mode 100644
index 0000000..6966fd0
--- /dev/null
+++ b/templates/partials/flash-messages.php
@@ -0,0 +1,18 @@
+
+
+⚠ = htmlspecialchars($_flash['error']) ?>
+
+
+✓ = htmlspecialchars($_flash['success']) ?>
+