v0.0.28 - Added zoom shortcuts (Ctrl+/-/0), fixed shortcut capture for Ctrl keys on settings page, and added software-rendering detection in browser.sh to fix high CPU on content-heavy pages

This commit is contained in:
Laan Tungir
2026-07-16 10:22:07 -04:00
parent 5150b7282a
commit 0a1b117746
14 changed files with 916 additions and 105 deletions
+5 -1
View File
@@ -5,7 +5,11 @@
# libsecp256k1-dev, libssl-dev, libcurl4-openssl-dev
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
# Debug build: -g for debug symbols (stack traces in gdb/core dumps),
# -O2 for optimization (runs at full speed). No ASAN (too slow for
# daily use). To temporarily enable ASAN for crash investigation:
# make clean && make CFLAGS="-std=c99 -Wall -Wextra -g -O0 -fsanitize=address" LDFLAGS="-fsanitize=address"
CFLAGS ?= -std=c99 -Wall -Wextra -g -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0 libqrencode sqlite3)
CFLAGS += -I./nostr_core_lib
CFLAGS += -DNOSTR_ENABLE_NSIGNER_CLIENT
+1 -1
View File
@@ -1 +1 @@
0.0.27
0.0.28
+39
View File
@@ -125,6 +125,45 @@ cmd_start() {
return 0
fi
# ── Rendering environment detection ──────────────────────────────
# WebKitGTK's compositor re-composites every frame. On systems with
# GPU acceleration this is cheap (hardware-accelerated). But in
# software-rendering environments (Qubes OS, headless VMs, no GPU,
# llvmpipe/softpipe Mesa), the compositor pegs the CPU at ~100% on
# content-heavy pages. In those cases we disable compositing mode and
# the DMA-BUF renderer so WebKit uses the lighter non-composited path.
#
# On a normal desktop Linux with a real GPU, we leave these unset so
# WebKit can use hardware-accelerated compositing.
local software_rendering=0
# Check 1: LIBGL_ALWAYS_SOFTWARE is set (Qubes OS sets this unconditionally)
if [[ "${LIBGL_ALWAYS_SOFTWARE:-}" == "1" ]]; then
software_rendering=1
fi
# Check 2: No DRM render device → no GPU available
if [[ ! -e /dev/dri/renderD128 ]]; then
software_rendering=1
fi
# Check 3: EGL/GL renderer is a software rasterizer
if command -v eglinfo >/dev/null 2>&1; then
if eglinfo 2>/dev/null | grep -qiE 'llvmpipe|softpipe|swrast'; then
software_rendering=1
fi
elif command -v glxinfo >/dev/null 2>&1; then
if glxinfo 2>/dev/null | grep -qiE 'llvmpipe|softpipe|swrast'; then
software_rendering=1
fi
fi
if [[ "$software_rendering" -eq 1 ]]; then
echo "[browser] Software rendering detected — disabling WebKit compositor."
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
fi
# Build first
echo "[browser] Building..."
if ! make -s 2>/dev/null; then
+303
View File
@@ -0,0 +1,303 @@
# Global Agent Sidebar — Per-Window, Shared Chat Session
## Problem
The agent chat sidebar is currently **per-tab**. Each [`tab_info_t`](src/tab_manager.h:23)
carries its own `sidebar_webview`, `sidebar_container`, `paned`, and
`sidebar_visible` flag. [`tab_manager_toggle_sidebar()`](src/tab_manager.c:2635)
only affects the active tab. When you switch tabs, the sidebar disappears
because the new tab has `sidebar_visible = FALSE`.
Meanwhile, the chat session is already **global**
[`agent_chat_store.c`](src/agent_chat_store.c:36) has a single
`g_current_session_id` shared across all tabs and windows. This creates a
mismatch: global chat state, per-tab sidebar UI.
## Solution
Move the sidebar from per-tab to **per-window**. Each window gets a
window-level `GtkPaned` (sidebar | notebook). The sidebar persists across
tab switches within a window. Each window's sidebar visibility is
independent. All sidebars show the same global chat conversation.
```
Window 1 (main) Window 2 (auxiliary)
┌──────────┬──────────────────┐ ┌──────────┬──────────────────┐
│ Sidebar │ GtkNotebook │ │ Sidebar │ GtkNotebook │
│ (webview)│ Tab 1 | Tab 2 │ │ (webview)│ Tab 3 │
│ chat │ main webview │ │ chat │ main webview │
│ [input] │ │ │ [input] │ │
└──────────┴──────────────────┘ └──────────┴──────────────────┘
↘ ↙
Same global chat session (g_current_session_id)
```
### Layout change
**Current** (sidebar inside each tab's page):
```
window
└── vbox
└── notebook
└── tab->page (GtkBox: toolbar + paned)
├── toolbar (hamburger + url + bookmark)
└── paned (GtkPaned horizontal)
├── sidebar_container [hidden]
└── webview
```
**New** (sidebar at window level, outside notebook):
```
window
└── vbox
└── window_paned (GtkPaned horizontal) ← NEW
├── sidebar_container [hidden] ← moved here
└── notebook
└── tab->page (GtkBox: toolbar + webview)
├── toolbar
└── webview ← direct child now
```
## Design
### New struct: `window_state_t`
Sidebar state moves out of `tab_info_t` into a per-window struct:
```c
typedef struct {
GtkWindow *window;
GtkWidget *notebook;
GtkWidget *paned; /* window-level: sidebar | notebook */
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
gboolean sidebar_visible;
} window_state_t;
```
The tab manager maintains:
- `static window_state_t g_main_window` — the main window
- `static GArray *g_aux_windows` — dynamic array of auxiliary windows
- `static window_state_t *g_active_ws` — pointer to the focused window's state
### What gets removed from `tab_info_t`
```c
/* REMOVE these from tab_info_t: */
WebKitWebView *sidebar_webview;
GtkWidget *sidebar_container;
GtkWidget *paned;
gboolean sidebar_visible;
```
Each tab's `page` goes back to a simple `GtkBox` (toolbar + webview) with no
paned.
### Resolution helpers
```c
/* Return the window_state_t for the currently focused window. */
static window_state_t *get_active_window_state(void);
/* Return the window_state_t whose notebook matches the given widget. */
static window_state_t *window_state_for_notebook(GtkWidget *notebook);
```
These replace the pattern of "get active tab, read tab->paned / tab->sidebar_*".
## Implementation Steps
### Step 1 — Add `window_state_t` and tracking arrays to `tab_manager.c`
Add the struct, `g_main_window`, `g_aux_windows` (GArray), and `g_active_ws`.
Add helper functions `get_active_window_state()` and
`window_state_for_notebook()`.
No public API change yet — this is internal scaffolding.
### Step 2 — Restructure `tab_manager_init()` for the main window
Currently ([line 2271](src/tab_manager.c:2271)):
- Creates `g_notebook`, adds it to `parent` (the vbox)
New:
- Create `g_notebook` (same as now)
- Create `g_main_window.paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL)`
- Create `g_main_window.sidebar_container = gtk_box_new(VERTICAL, 0)`
- Pack sidebar_container as child 1 (FALSE, FALSE), hide it
- Pack notebook as child 2 (TRUE, TRUE)
- Set paned position to 0 (sidebar hidden)
- Add `g_main_window.paned` to `parent` (the vbox) instead of the notebook
- Set `g_main_window.window = window`, `g_main_window.notebook = g_notebook`
- Set `g_active_ws = &g_main_window`
- Keep `g_active_window` / `g_active_notebook` globals for backward compat
(they're used by `get_effective_notebook()` and `tab_manager_get_active()`)
### Step 3 — Simplify `tab_create()` (remove per-tab paned)
Currently ([line 2005](src/tab_manager.c:2005)):
- Creates `tab->paned`, `tab->sidebar_container`, packs webview into paned,
packs paned into `tab->page`
New:
- Pack `tab->webview` directly into `tab->page` (after the toolbar)
- Remove all paned/sidebar_container/sidebar_webview/sidebar_visible setup
- Remove the `gtk_widget_hide(tab->sidebar_container)` calls in
`tab_manager_new_tab()` ([line 2403](src/tab_manager.c:2403)) and
`tab_manager_new_window()` ([line 848](src/tab_manager.c:848))
### Step 4 — Restructure `tab_manager_new_window()` for auxiliary windows
Currently ([line 783](src/tab_manager.c:783)):
- Creates window, creates notebook, adds notebook to window, creates tab
New:
- Create window
- Create notebook
- Create a `window_state_t` for this auxiliary window
- Create `ws->paned`, `ws->sidebar_container`, pack sidebar + notebook into
paned, add paned to window (instead of adding notebook directly)
- Set `ws->window`, `ws->notebook`, `ws->sidebar_visible = FALSE`
- Append `ws` to `g_aux_windows`
- Create the tab (same as now, via `tab_create()` + `tab_array_add()`)
- Wire `focus-in-event` to update `g_active_ws` (in addition to
`g_active_window` / `g_active_notebook`)
- Wire `destroy` to remove from `g_aux_windows` and free the
`window_state_t` (but NOT the sidebar webview if we want to keep it
alive — actually, destroy the whole window_state_t since the window is
going away)
### Step 5 — Update `on_window_focus_in()` to set `g_active_ws`
Currently ([line 760](src/tab_manager.c:760)):
- Sets `g_active_window` and `g_active_notebook`
New:
- Also set `g_active_ws = window_state_for_notebook(notebook)`
### Step 6 — Update `on_aux_window_destroy()` to clean up `g_aux_windows`
Currently ([line 773](src/tab_manager.c:773)):
- Reverts `g_active_window` / `g_active_notebook` to main window
New:
- Find the `window_state_t` in `g_aux_windows` matching the destroyed window
- Remove it from the array and free it
- Revert `g_active_ws` to `&g_main_window`
### Step 7 — Rewrite `sidebar_create_webview()` to be window-scoped
Currently ([line 2601](src/tab_manager.c:2601)):
- Takes a `tab_info_t *tab`, creates webview, packs into `tab->sidebar_container`
New:
- Takes a `window_state_t *ws`, creates webview, packs into
`ws->sidebar_container`
- Same webview settings, nostr_inject_setup, load `AGENT_CHAT_URL_STR`
### Step 8 — Rewrite `tab_manager_toggle_sidebar()` to be window-scoped
Currently ([line 2635](src/tab_manager.c:2635)):
- Gets active tab, toggles `tab->paned` / `tab->sidebar_container` /
`tab->sidebar_visible`
New:
- `window_state_t *ws = get_active_window_state()`
- If `ws->sidebar_visible`: set paned position to 0, hide sidebar_container,
set `sidebar_visible = FALSE`
- Else: lazily create sidebar webview if NULL, set paned position to
`SIDEBAR_DEFAULT_WIDTH`, show sidebar_container, set `sidebar_visible = TRUE`
### Step 9 — Update `tab_manager_sidebar_visible()`
Currently ([line 2666](src/tab_manager.c:2666)):
- Returns `tab->sidebar_visible` for the active tab
New:
- `window_state_t *ws = get_active_window_state()`
- Return `ws ? ws->sidebar_visible : FALSE`
### Step 10 — Update `tab_manager.h`
Remove from `tab_info_t`:
```c
WebKitWebView *sidebar_webview;
GtkWidget *sidebar_container;
GtkWidget *paned;
gboolean sidebar_visible;
```
The public API (`tab_manager_toggle_sidebar`, `tab_manager_sidebar_visible`,
`tab_manager_get_main_webview`) stays the same — signatures unchanged.
### Step 11 — Verify `tab_manager_get_main_webview()` still works
Currently ([line 2659](src/tab_manager.c:2659)):
- Gets active tab, returns `tab->webview`
This still works — the sidebar is now outside the notebook, so
`tab_manager_get_active()` (which resolves by notebook page widget) will
never return the sidebar. No change needed, but verify after the refactor.
### Step 12 — Build and test
```bash
make clean && make
./browser.sh start --login-method generate
```
Test checklist:
- [ ] Ctrl+Shift+A toggles sidebar in main window
- [ ] Sidebar persists when switching tabs (the core fix)
- [ ] Open a `target="_blank"` link → new window, sidebar hidden
- [ ] Focus new window, Ctrl+Shift+A → sidebar appears in that window
- [ ] Both windows' sidebars show the same chat conversation
- [ ] `;` shortcut in URL bar opens sidebar if hidden, sends message
- [ ] Agent tools (snapshot, click, eval) target the active tab's webview,
not the sidebar
- [ ] Close auxiliary window → no crash, main window still works
- [ ] Close main window → app exits cleanly
## Files to modify
| File | Change |
|------|--------|
| [`src/tab_manager.h`](src/tab_manager.h) | Remove 4 sidebar fields from `tab_info_t` |
| [`src/tab_manager.c`](src/tab_manager.c) | Add `window_state_t` + tracking; restructure `tab_manager_init`, `tab_create`, `tab_manager_new_window`, `on_window_focus_in`, `on_aux_window_destroy`; rewrite `sidebar_create_webview`, `tab_manager_toggle_sidebar`, `tab_manager_sidebar_visible` |
## Files NOT modified
| File | Why |
|------|-----|
| [`src/agent_tools.c`](src/agent_tools.c) | Already uses `tab_manager_get_main_webview()` — unchanged |
| [`src/agent_chat.c`](src/agent_chat.c) | Already uses `tab_manager_sidebar_visible()` / `tab_manager_toggle_sidebar()` — unchanged |
| [`src/main.c`](src/main.c) | Shortcut handler already calls `tab_manager_toggle_sidebar()` — unchanged. The vbox→paned→notebook nesting is handled inside `tab_manager_init()` |
| [`src/shortcuts.c`](src/shortcuts.c) | Shortcut binding unchanged |
| [`www/agents/chat.js`](www/agents/chat.js) | Chat page JS unchanged — it polls `sovereign://agents/messages` regardless of which window's sidebar it's in |
| [`src/agent_chat_store.c`](src/agent_chat_store.c) | Global session unchanged |
## Risks and mitigations
1. **`gtk_widget_show_all()` revealing the sidebar** — The current code has
explicit `gtk_widget_hide(tab->sidebar_container)` calls after
`show_all` in `tab_manager_new_tab` and `tab_manager_new_window`. With
the sidebar at window level, `show_all` on the window will reveal it.
Mitigation: call `gtk_widget_hide(ws->sidebar_container)` after
`gtk_widget_show_all(window)` in both `tab_manager_init` and
`tab_manager_new_window`.
2. **Paned position reset on window resize** — GtkPaned may reset position
on certain resize events. Mitigation: use `gtk_paned_set_position()` on
show, and test resizing behavior.
3. **Auxiliary window sidebar webview lifecycle** — When an aux window is
destroyed, its sidebar webview must be destroyed too (it's a child of
the window). This happens automatically via GTK container destruction,
but verify no dangling pointers in `g_aux_windows`.
4. **`g_active_ws` stale pointer** — If an aux window is destroyed while
it's the active window, `on_aux_window_destroy` must reset
`g_active_ws = &g_main_window`. Same pattern as the existing
`g_active_window` / `g_active_notebook` reset.
+71 -12
View File
@@ -443,7 +443,7 @@ static cJSON *tool_open(cJSON *params) {
/* Always operate on the MAIN webview (the web page), never the
* sidebar chat webview. tab_manager_get_main_webview() returns
* tab->webview directly, never tab->sidebar_webview. */
* the active tab's webview directly, never the sidebar. */
WebKitWebView *wv = tab_manager_get_main_webview();
if (!wv) {
g_free(normalized);
@@ -1736,13 +1736,62 @@ static cJSON *tool_drag(cJSON *params) {
return make_success(NULL);
}
static cJSON *tool_close_all(cJSON *params) {
(void)params;
tab_manager_close_all();
return make_success(NULL);
/* ── Tab tools (main-thread dispatch) ────────────────────────────── *
* All tab management functions (new, switch, close, close_all) create
* or destroy GTK widgets, which MUST happen on the main thread. The
* agent loop runs on a background thread, so we dispatch via
* g_idle_add(). */
typedef struct {
char *url;
int index;
} tab_idle_t;
static gboolean tab_new_idle(gpointer user_data) {
tab_idle_t *ctx = (tab_idle_t *)user_data;
if (ctx) {
ctx->index = tab_manager_new_tab(ctx->url);
g_free(ctx->url);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
/* ── Tab tools ────────────────────────────────────────────────────── */
static gboolean tab_switch_idle(gpointer user_data) {
tab_idle_t *ctx = (tab_idle_t *)user_data;
if (ctx) {
tab_manager_switch_to(ctx->index);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
static gboolean tab_close_idle(gpointer user_data) {
tab_idle_t *ctx = (tab_idle_t *)user_data;
if (ctx) {
tab_manager_close_tab(ctx->index);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
static gboolean close_all_idle(gpointer user_data) {
(void)user_data;
tab_manager_close_all();
return G_SOURCE_REMOVE;
}
static gboolean close_active_idle(gpointer user_data) {
(void)user_data;
tab_manager_close_active();
return G_SOURCE_REMOVE;
}
static cJSON *tool_close_all(cJSON *params) {
(void)params;
g_idle_add(close_all_idle, NULL);
return make_success(NULL);
}
static cJSON *tool_tab_list(cJSON *params) {
(void)params;
@@ -1765,10 +1814,16 @@ static cJSON *tool_tab_list(cJSON *params) {
static cJSON *tool_tab_new(cJSON *params) {
const char *url = get_string_param(params, "url");
int index = tab_manager_new_tab(url);
if (index < 0) return make_error("TAB_FAILED", "Failed to create tab (max tabs reached?)");
/* Dispatch to main thread — tab_manager_new_tab() creates GTK
* widgets which must happen on the main thread. */
tab_idle_t *ctx = g_new(tab_idle_t, 1);
ctx->url = url ? g_strdup(url) : NULL;
ctx->index = -1;
g_idle_add(tab_new_idle, ctx);
/* We can't know the exact index since the creation is async, but
* the tab will be created. Return a placeholder. */
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "index", index);
cJSON_AddNumberToObject(data, "index", tab_manager_count());
return make_success(data);
}
@@ -1776,7 +1831,9 @@ static cJSON *tool_tab_switch(cJSON *params) {
int index = get_int_param(params, "index", -1);
if (index < 0) return make_error("MISSING_PARAM", "Provide 'index'");
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
tab_manager_switch_to(index);
tab_idle_t *ctx = g_new(tab_idle_t, 1);
ctx->index = index;
g_idle_add(tab_switch_idle, ctx);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "index", index);
return make_success(data);
@@ -1790,13 +1847,15 @@ static cJSON *tool_tab_close(cJSON *params) {
if (index < 0) return make_error("NO_TAB", "No active tab");
}
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
tab_manager_close_tab(index);
tab_idle_t *ctx = g_new(tab_idle_t, 1);
ctx->index = index;
g_idle_add(tab_close_idle, ctx);
return make_success(NULL);
}
static cJSON *tool_close(cJSON *params) {
(void)params;
tab_manager_close_active();
g_idle_add(close_active_idle, NULL);
return make_success(NULL);
}
+26
View File
@@ -287,6 +287,16 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
(void)widget;
(void)data;
/* When the active tab is showing a sovereign:// internal page (e.g.
* the settings page's keyboard-shortcut capture), don't intercept
* any key events — let them pass through to the web page's JS so it
* can capture arbitrary key combos (including Ctrl+key). */
tab_info_t *active = tab_manager_get_active();
if (active && active->current_url[0] != '\0' &&
strncmp(active->current_url, "sovereign://", 12) == 0) {
return FALSE;
}
int action = shortcuts_lookup(event);
if (action < 0) return FALSE;
@@ -386,6 +396,18 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
tab_manager_toggle_sidebar();
return TRUE;
case SHORTCUT_ZOOM_IN:
tab_manager_zoom_in();
return TRUE;
case SHORTCUT_ZOOM_OUT:
tab_manager_zoom_out();
return TRUE;
case SHORTCUT_ZOOM_RESET:
tab_manager_zoom_reset();
return TRUE;
default:
return FALSE;
}
@@ -867,6 +889,10 @@ int main(int argc, char **argv) {
cli_args_free(&cli);
gtk_widget_show_all(window);
/* Re-hide the sidebar container that show_all revealed. The sidebar
* is per-window (packed in the window-level GtkPaned) and should
* only appear when the user toggles it. */
tab_manager_hide_sidebar_after_show_all();
gtk_main();
return EXIT_SUCCESS;
+12
View File
@@ -91,6 +91,18 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"toggle_sidebar", "Toggle agent sidebar",
"Show or hide the agent chat sidebar", "<Control><Shift>a"
},
[SHORTCUT_ZOOM_IN] = {
"zoom_in", "Zoom in",
"Increase the zoom level of the active tab", "<Control>plus"
},
[SHORTCUT_ZOOM_OUT] = {
"zoom_out", "Zoom out",
"Decrease the zoom level of the active tab", "<Control>minus"
},
[SHORTCUT_ZOOM_RESET] = {
"zoom_reset", "Reset zoom",
"Reset the zoom level of the active tab to 100%", "<Control>0"
},
};
/* ── In-memory parsed bindings ──────────────────────────────────────── */
+3
View File
@@ -44,6 +44,9 @@ typedef enum {
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
SHORTCUT_TOGGLE_SIDEBAR,
SHORTCUT_ZOOM_IN,
SHORTCUT_ZOOM_OUT,
SHORTCUT_ZOOM_RESET,
SHORTCUT_COUNT
} shortcut_action_t;
+216 -73
View File
@@ -57,6 +57,22 @@ extern void on_menu_agent(GtkMenuItem *item, gpointer data);
extern gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data);
/* ── Per-window sidebar state ─────────────────────────────────────── *
* The agent chat sidebar is per-window (not per-tab). Each window has
* a window-level GtkPaned (sidebar | notebook). The sidebar webview is
* lazily created on first toggle. All windows' sidebars show the same
* global chat session (agent_chat_store's g_current_session_id).
* Visibility is independent per-window. */
typedef struct {
GtkWindow *window;
GtkWidget *notebook;
GtkWidget *paned; /* window-level: sidebar | notebook */
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
gboolean sidebar_visible;
} window_state_t;
/* ── Static state ─────────────────────────────────────────────────── */
static GtkWidget *g_notebook = NULL;
@@ -69,6 +85,22 @@ static GtkWindow *g_window = NULL;
static GtkWindow *g_active_window = NULL;
static GtkWidget *g_active_notebook = NULL;
/* ── Per-window sidebar state ─────────────────────────────────────── *
* The agent chat sidebar is now per-window (not per-tab). Each window
* has a window-level GtkPaned (sidebar | notebook). The sidebar
* webview is lazily created on first toggle. All windows' sidebars
* show the same global chat session (agent_chat_store's
* g_current_session_id). Visibility is independent per-window.
*
* The window_state_t struct and helper forward declarations are above
* (in the forward declarations section) because on_window_focus_in /
* on_aux_window_destroy — which are defined before the sidebar section —
* need them. */
static window_state_t g_main_window = {0};
static GArray *g_aux_windows = NULL; /* GArray of window_state_t */
static window_state_t *g_active_ws = NULL; /* points to g_main_window or an aux entry */
/* Target notebook for the next tab_create() call.
*
* When non-NULL, tab_create()/tab_manager_new_tab() add the new tab to
@@ -95,6 +127,13 @@ static int g_tab_cap = 0;
/* ── Forward declarations ─────────────────────────────────────────── */
/* Per-window sidebar helpers (defined in the sidebar section at the
* bottom of this file, but needed by on_window_focus_in /
* on_aux_window_destroy which are defined earlier). The window_state_t
* typedef is above, before the static state section. */
static window_state_t *get_active_window_state(void);
static window_state_t *window_state_for_notebook(GtkWidget *notebook);
static tab_info_t *tab_create(const char *url);
static GtkWidget *build_tab_label(tab_info_t *tab);
static GtkWidget *tab_find_notebook(GtkWidget *page);
@@ -762,6 +801,8 @@ static gboolean on_window_focus_in(GtkWidget *widget,
g_active_window = GTK_WINDOW(widget);
g_active_notebook = notebook;
g_active_ws = window_state_for_notebook(notebook);
if (g_active_ws == NULL) g_active_ws = &g_main_window;
g_print("[windows] Active window changed to %p (notebook %p)\n",
(void *)g_active_window, (void *)g_active_notebook);
return FALSE;
@@ -769,14 +810,27 @@ static gboolean on_window_focus_in(GtkWidget *widget,
/* destroy handler for auxiliary windows: if this was the active window,
* fall back to the main window/notebook so MCP keeps working. Does NOT
* quit the app — only the main window's destroy handler does that. */
* quit the app — only the main window's destroy handler does that.
* Also removes the window's window_state_t from g_aux_windows. */
static void on_aux_window_destroy(GtkWidget *widget, gpointer user_data) {
(void)user_data;
if (g_active_window == GTK_WINDOW(widget)) {
g_active_window = g_window;
g_active_notebook = g_notebook;
g_active_ws = &g_main_window;
g_print("[windows] Active window closed, reverting to main window\n");
}
/* Remove the destroyed window's entry from g_aux_windows. */
if (g_aux_windows != NULL) {
for (guint i = 0; i < g_aux_windows->len; i++) {
window_state_t *ws = &g_array_index(g_aux_windows,
window_state_t, i);
if (ws->window == GTK_WINDOW(widget)) {
g_array_remove_index_fast(g_aux_windows, i);
break;
}
}
}
g_print("[windows] Auxiliary window destroyed: %p\n", (void *)widget);
}
@@ -797,7 +851,22 @@ static GtkWidget *tab_manager_new_window(const char *url,
gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook), FALSE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(notebook), FALSE);
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(notebook), TRUE);
gtk_container_add(GTK_CONTAINER(window), notebook);
/* Build a window-level GtkPaned: left = sidebar container, right =
* notebook. The sidebar is per-window (not per-tab), so it persists
* across tab switches within this window. Hidden by default. */
GtkWidget *paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(paned, TRUE);
gtk_widget_set_hexpand(paned, TRUE);
GtkWidget *sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(paned), sidebar_container, FALSE, FALSE);
gtk_widget_hide(sidebar_container);
gtk_paned_pack2(GTK_PANED(paned), notebook, TRUE, TRUE);
gtk_paned_set_position(GTK_PANED(paned), 0);
gtk_container_add(GTK_CONTAINER(window), paned);
/* Build a full tab via tab_create(). Set g_target_notebook so the
* tab is added to this window's notebook (not the main notebook),
@@ -843,11 +912,6 @@ static GtkWidget *tab_manager_new_window(const char *url,
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* Re-hide the sidebar container after show_all (see
* tab_manager_new_tab for the rationale — Issue 2). */
if (tab->sidebar_container) {
gtk_widget_hide(tab->sidebar_container);
}
gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), page_num);
/* Window lifecycle: focus-in updates the active window/notebook
@@ -859,11 +923,30 @@ static GtkWidget *tab_manager_new_window(const char *url,
/* Show everything and present the window. */
gtk_widget_show_all(window);
/* Re-hide the sidebar container after show_all — it should only
* appear when the user toggles it. (Same rationale as the main
* window in tab_manager_init.) */
gtk_widget_hide(sidebar_container);
gtk_window_present(GTK_WINDOW(window));
/* Register this window's window_state_t in g_aux_windows. */
if (g_aux_windows == NULL) {
g_aux_windows = g_array_new(FALSE, FALSE, sizeof(window_state_t));
}
window_state_t ws = {0};
ws.window = GTK_WINDOW(window);
ws.notebook = notebook;
ws.paned = paned;
ws.sidebar_container = sidebar_container;
ws.sidebar_webview = NULL;
ws.sidebar_visible = FALSE;
g_array_append_val(g_aux_windows, ws);
/* This new window is now the active window. */
g_active_window = GTK_WINDOW(window);
g_active_notebook = notebook;
g_active_ws = &g_array_index(g_aux_windows, window_state_t,
g_aux_windows->len - 1);
g_print("[windows] Created new window %p (tab %d) for %s\n",
(void *)window, index, url ? url : "(none)");
@@ -1302,6 +1385,36 @@ void tab_manager_toggle_inspector(void) {
}
}
/* ── Zoom control ──────────────────────────────────────────────────── */
#define ZOOM_MIN 0.25
#define ZOOM_MAX 5.0
#define ZOOM_STEP 1.1
void tab_manager_zoom_in(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
gdouble z = webkit_web_view_get_zoom_level(tab->webview);
z *= ZOOM_STEP;
if (z > ZOOM_MAX) z = ZOOM_MAX;
webkit_web_view_set_zoom_level(tab->webview, z);
}
void tab_manager_zoom_out(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
gdouble z = webkit_web_view_get_zoom_level(tab->webview);
z /= ZOOM_STEP;
if (z < ZOOM_MIN) z = ZOOM_MIN;
webkit_web_view_set_zoom_level(tab->webview, z);
}
void tab_manager_zoom_reset(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
webkit_web_view_set_zoom_level(tab->webview, 1.0);
}
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
@@ -2002,32 +2115,10 @@ static tab_info_t *tab_create(const char *url) {
gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE);
/* Build a horizontal GtkPaned: left = sidebar container, right =
* main webview. Both children are always packed (no add/remove
* which caused a crash). When hidden, the position is set to 0
* and the sidebar widget is hidden — the divider disappears at
* position 0. When shown, the position is set to 280 and the
* sidebar is shown — the divider appears and is resizable. */
tab->paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(tab->paned, TRUE);
gtk_widget_set_hexpand(tab->paned, TRUE);
/* Sidebar container — packed as first (left) pane. Hidden by
* default. No size request (the paned position controls width). */
tab->sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(tab->paned), tab->sidebar_container,
FALSE, FALSE);
gtk_widget_hide(tab->sidebar_container);
tab->sidebar_visible = FALSE;
tab->sidebar_webview = NULL;
/* Main webview — packed as second (right) pane. */
gtk_paned_pack2(GTK_PANED(tab->paned), GTK_WIDGET(tab->webview),
TRUE, TRUE);
/* Position 0 = sidebar gets no space (hidden by default). */
gtk_paned_set_position(GTK_PANED(tab->paned), 0);
gtk_box_pack_start(GTK_BOX(tab->page), tab->paned, TRUE, TRUE, 0);
/* The sidebar is now at the window level (window_state_t.paned),
* not per-tab. The webview is packed directly into the tab's page. */
gtk_box_pack_start(GTK_BOX(tab->page), GTK_WIDGET(tab->webview),
TRUE, TRUE, 0);
/* Build the tab label. */
tab->tab_label = build_tab_label(tab);
@@ -2337,13 +2428,36 @@ void tab_manager_init(GtkContainer *parent,
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(css);
gtk_container_add(parent, g_notebook);
/* Build a window-level GtkPaned: left = sidebar container, right =
* notebook. The sidebar is per-window (not per-tab), so it persists
* across tab switches. Hidden by default — shown when the user
* toggles it via Ctrl+Shift+A, the menu item, or the ";" shortcut. */
g_main_window.paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(g_main_window.paned, TRUE);
gtk_widget_set_hexpand(g_main_window.paned, TRUE);
g_main_window.sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(g_main_window.paned),
g_main_window.sidebar_container, FALSE, FALSE);
gtk_widget_hide(g_main_window.sidebar_container);
gtk_paned_pack2(GTK_PANED(g_main_window.paned), g_notebook, TRUE, TRUE);
gtk_paned_set_position(GTK_PANED(g_main_window.paned), 0);
gtk_container_add(parent, g_main_window.paned);
/* Populate the main window's window_state_t. */
g_main_window.window = window;
g_main_window.notebook = g_notebook;
g_main_window.sidebar_webview = NULL;
g_main_window.sidebar_visible = FALSE;
/* The main window/notebook are the default active window/notebook
* for MCP get_active_webview(). Updated when auxiliary windows
* gain focus (see on_window_focus_in). */
g_active_window = window;
g_active_notebook = g_notebook;
g_active_ws = &g_main_window;
tab_manager_apply_settings();
}
@@ -2395,15 +2509,6 @@ int tab_manager_new_tab(const char *url) {
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* gtk_widget_show_all() recursively shows all children, including
* the sidebar container that tab_create() explicitly hid. Re-hide
* the sidebar so it stays hidden by default — it should only appear
* when the user toggles it via Ctrl+Shift+A, the menu item, or the
* ";" URL-bar shortcut. (Issue 2: sidebar visible on startup.) */
if (tab->sidebar_container) {
gtk_widget_hide(tab->sidebar_container);
}
/* Switch to the new tab (now that it's visible). */
gtk_notebook_set_current_page(GTK_NOTEBOOK(nb), page_num);
@@ -2589,82 +2694,120 @@ void tab_manager_reload(int index) {
}
}
/* ── Agent chat sidebar (split view) ─────────────────────────────── */
/* ── Agent chat sidebar (per-window) ─────────────────────────────── */
#define SIDEBAR_DEFAULT_WIDTH 280
#define AGENT_CHAT_URL_STR "sovereign://agents/chat"
/* Lazily create the sidebar webview for a tab. The webview shares the
* same WebKitWebContext as the main webview so the sovereign:// scheme
* works. It is packed into the sidebar container (the left pane of the
* GtkPaned). Called the first time the sidebar is shown for a tab. */
static void sidebar_create_webview(tab_info_t *tab) {
if (tab == NULL || tab->sidebar_webview != NULL) return;
/* Return the window_state_t for the currently focused window.
* Falls back to the main window if g_active_ws is NULL. */
static window_state_t *get_active_window_state(void) {
if (g_active_ws != NULL) return g_active_ws;
return &g_main_window;
}
/* Return the window_state_t whose notebook matches the given widget.
* Checks the main window first, then auxiliary windows. Returns NULL
* if no match. */
static window_state_t *window_state_for_notebook(GtkWidget *notebook) {
if (notebook == NULL) return NULL;
if (g_main_window.notebook == notebook) return &g_main_window;
if (g_aux_windows != NULL) {
for (guint i = 0; i < g_aux_windows->len; i++) {
window_state_t *ws = &g_array_index(g_aux_windows,
window_state_t, i);
if (ws->notebook == notebook) return ws;
}
}
return NULL;
}
/* Lazily create the sidebar webview for a window. The webview shares
* the same WebKitWebContext as the main webview so the sovereign://
* scheme works. It is packed into the sidebar container (the left pane
* of the window-level GtkPaned). Called the first time the sidebar is
* shown for a window. */
static void sidebar_create_webview(window_state_t *ws) {
if (ws == NULL || ws->sidebar_webview != NULL) return;
if (g_ctx == NULL) return;
tab->sidebar_webview = WEBKIT_WEB_VIEW(
ws->sidebar_webview = WEBKIT_WEB_VIEW(
webkit_web_view_new_with_context(g_ctx));
/* Match the main webview's settings so JS and the sovereign://
* bridge work. */
WebKitSettings *st = webkit_web_view_get_settings(tab->sidebar_webview);
WebKitSettings *st = webkit_web_view_get_settings(ws->sidebar_webview);
webkit_settings_set_enable_developer_extras(st, TRUE);
webkit_settings_set_enable_javascript(st, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(st, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(st, TRUE);
/* Inject window.nostr so the chat page's NIP-07 shim works. */
nostr_inject_setup(tab->sidebar_webview);
nostr_inject_setup(ws->sidebar_webview);
/* The sidebar webview should NOT create new windows/tabs — links
* in the chat page should navigate within the sidebar, not open
* new browser tabs. We let the default navigation happen inside
* the sidebar webview. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->sidebar_webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->sidebar_webview), TRUE);
gtk_widget_set_vexpand(GTK_WIDGET(ws->sidebar_webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(ws->sidebar_webview), TRUE);
/* Pack into the sidebar container. */
gtk_box_pack_start(GTK_BOX(tab->sidebar_container),
GTK_WIDGET(tab->sidebar_webview), TRUE, TRUE, 0);
gtk_box_pack_start(GTK_BOX(ws->sidebar_container),
GTK_WIDGET(ws->sidebar_webview), TRUE, TRUE, 0);
/* Load the chat page. */
webkit_web_view_load_uri(tab->sidebar_webview, AGENT_CHAT_URL_STR);
webkit_web_view_load_uri(ws->sidebar_webview, AGENT_CHAT_URL_STR);
}
void tab_manager_toggle_sidebar(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->paned == NULL) return;
window_state_t *ws = get_active_window_state();
if (ws == NULL || ws->paned == NULL) return;
if (tab->sidebar_visible) {
if (ws->sidebar_visible) {
/* Hide the sidebar. Set position to 0 so the sidebar gets no
* space and the divider disappears. Keep the webview alive. */
gtk_paned_set_position(GTK_PANED(tab->paned), 0);
gtk_widget_hide(tab->sidebar_container);
tab->sidebar_visible = FALSE;
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
gtk_widget_hide(ws->sidebar_container);
ws->sidebar_visible = FALSE;
} else {
/* Show the sidebar. Create the webview lazily on first show.
* Set position to 280 so the sidebar gets space and the
* divider appears (resizable). */
if (tab->sidebar_webview == NULL) {
sidebar_create_webview(tab);
if (ws->sidebar_webview == NULL) {
sidebar_create_webview(ws);
}
gtk_paned_set_position(GTK_PANED(tab->paned),
gtk_paned_set_position(GTK_PANED(ws->paned),
SIDEBAR_DEFAULT_WIDTH);
gtk_widget_show_all(tab->sidebar_container);
tab->sidebar_visible = TRUE;
gtk_widget_show_all(ws->sidebar_container);
ws->sidebar_visible = TRUE;
}
}
WebKitWebView *tab_manager_get_main_webview(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return NULL;
/* Always return the main webview, never the sidebar. */
/* Always return the main webview, never the sidebar. The sidebar
* is now at the window level (outside the notebook), so
* tab_manager_get_active() — which resolves by notebook page —
* will never return the sidebar. */
return tab->webview;
}
gboolean tab_manager_sidebar_visible(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return FALSE;
return tab->sidebar_visible;
window_state_t *ws = get_active_window_state();
if (ws == NULL) return FALSE;
return ws->sidebar_visible;
}
void tab_manager_hide_sidebar_after_show_all(void) {
/* gtk_widget_show_all() on the main window recursively shows all
* children, including the sidebar container. Re-hide it so the
* sidebar stays hidden by default — it should only appear when
* the user toggles it. */
if (g_main_window.sidebar_container && !g_main_window.sidebar_visible) {
gtk_widget_hide(g_main_window.sidebar_container);
gtk_paned_set_position(GTK_PANED(g_main_window.paned), 0);
}
}
+25 -15
View File
@@ -30,15 +30,6 @@ typedef struct {
GtkWidget *favicon; /* favicon image in tab_label */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
/* Agent chat sidebar (split view). The sidebar is a second webview
* showing sovereign://agents/chat, shown alongside the main webview
* in a GtkPaned. When sidebar_visible is FALSE, the sidebar
* container is hidden but the webview is kept alive so chat state
* persists across toggles. */
WebKitWebView *sidebar_webview; /* agent chat sidebar (NULL if not yet created) */
GtkWidget *sidebar_container; /* GtkScrolledWindow holding sidebar_webview */
GtkWidget *paned; /* GtkPaned: sidebar | main webview */
gboolean sidebar_visible; /* whether the sidebar is currently shown */
} tab_info_t;
/*
@@ -170,11 +161,23 @@ void tab_manager_set_avatar(const char *pubkey_hex);
void tab_manager_toggle_inspector(void);
/*
* Toggle the agent chat sidebar for the active tab. When showing, if
* the sidebar webview has not been created yet, it is created (sharing
* the same WebKitWebContext so sovereign:// works) and loads
* sovereign://agents/chat. When hiding, the sidebar container is
* hidden but the webview is kept alive so chat state persists.
* Zoom control for the active tab's webview.
* tab_manager_zoom_in() — multiply zoom by 1.1 (capped at 5.0)
* tab_manager_zoom_out() — multiply zoom by 1/1.1 (floored at 0.25)
* tab_manager_zoom_reset()— set zoom back to 1.0 (100%)
* No-ops if there is no active tab or webview.
*/
void tab_manager_zoom_in(void);
void tab_manager_zoom_out(void);
void tab_manager_zoom_reset(void);
/*
* Toggle the agent chat sidebar for the active window. The sidebar is
* per-window (not per-tab), so it persists across tab switches within a
* window. When showing, if the sidebar webview has not been created yet,
* it is created (sharing the same WebKitWebContext so sovereign:// works)
* and loads sovereign://agents/chat. When hiding, the sidebar container
* is hidden but the webview is kept alive so chat state persists.
*/
void tab_manager_toggle_sidebar(void);
@@ -186,10 +189,17 @@ void tab_manager_toggle_sidebar(void);
WebKitWebView *tab_manager_get_main_webview(void);
/*
* Returns TRUE if the sidebar is currently visible for the active tab.
* Returns TRUE if the sidebar is currently visible for the active window.
*/
gboolean tab_manager_sidebar_visible(void);
/*
* Re-hide the sidebar container after gtk_widget_show_all(window).
* Call this in main.c after show_all so the sidebar (which is packed
* in the window-level GtkPaned) doesn't appear on startup.
*/
void tab_manager_hide_sidebar_after_show_all(void);
#ifdef __cplusplus
}
#endif
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.27"
#define SB_VERSION "v0.0.28"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 27
#define SB_VERSION_PATCH 28
#endif /* SOVEREIGN_BROWSER_VERSION_H */
+10 -1
View File
@@ -367,6 +367,10 @@ function renderMessage(m, index) {
function renderMessages(msgs) {
var c = document.getElementById('messages');
/* Check if the user is scrolled to the bottom (or near it) before
* re-rendering. If they've scrolled up to read history, don't
* auto-scroll back down — that's annoying. */
var wasAtBottom = (c.scrollHeight - c.scrollTop - c.clientHeight) < 50;
var html = '';
/* Only render user and assistant messages in the chat. Tool messages
* (tool call results) can be very large (e.g. entire web pages) and
@@ -409,7 +413,12 @@ function renderMessages(msgs) {
});
}
});
c.scrollTop = c.scrollHeight;
/* Only auto-scroll to the bottom if the user was already at the
* bottom. If they scrolled up to read history, preserve their
* scroll position. */
if (wasAtBottom) {
c.scrollTop = c.scrollHeight;
}
}
function fetchMessages() {
+1
View File
@@ -108,6 +108,7 @@ function jsKeyToGdk(e) {
if (k === '/') return 'slash';
if (k === '-') return 'minus';
if (k === '=') return 'equal';
if (k === '+') return 'plus';
}
var special = {
'Tab':'Tab','Enter':'Return','Escape':'Escape',
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💛 Yellow Flowers Gallery 💛</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', 'Times New Roman', serif;
background: linear-gradient(135deg, #fef9e7, #fff8dc, #fffacd);
color: #4a3b1f;
min-height: 100vh;
}
header {
text-align: center;
padding: 2.5rem 1rem 1.5rem;
background: linear-gradient(135deg, #ffd70022, #ffec8022);
border-bottom: 3px solid #ffd70044;
}
header h1 {
font-size: 2.5rem;
color: #b8860b;
text-shadow: 1px 1px 3px rgba(255, 215, 0, 0.3);
margin-bottom: 0.5rem;
}
header p {
font-size: 1.1rem;
color: #8b7355;
font-style: italic;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.15);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(255, 215, 0, 0.35);
}
.card img {
width: 100%;
height: 250px;
object-fit: cover;
display: block;
}
.card .info {
padding: 1rem 1.2rem 1.5rem;
text-align: center;
}
.card h3 {
color: #b8860b;
font-size: 1.25rem;
margin-bottom: 0.4rem;
}
.card p {
font-size: 0.9rem;
color: #8b7355;
line-height: 1.4;
}
.card .number {
display: inline-block;
background: #ffd70033;
color: #b8860b;
font-weight: bold;
border-radius: 50%;
width: 28px;
height: 28px;
line-height: 28px;
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
footer {
text-align: center;
padding: 2rem;
color: #8b7355;
font-size: 0.85rem;
}
</style>
</head>
<body>
<header>
<h1>🌻 Yellow Flowers Gallery 🌻</h1>
<p>A collection of 12 beautiful yellow flowers</p>
</header>
<div class="gallery">
<div class="card">
<img src="sunflower.jpg" alt="Sunflower">
<div class="info">
<div class="number">1</div>
<h3>Sunflower</h3>
<p>Iconic tall flower that turns to face the sun. 🌻</p>
</div>
</div>
<div class="card">
<img src="daffodil.jpg" alt="Daffodil">
<div class="info">
<div class="number">2</div>
<h3>Daffodil</h3>
<p>A cheerful spring flower with a trumpet-shaped center.</p>
</div>
</div>
<div class="card">
<img src="black_eyed_susan.jpg" alt="Black-eyed Susan">
<div class="info">
<div class="number">3</div>
<h3>Black-eyed Susan</h3>
<p>A daisy-like wildflower with a dark brown center.</p>
</div>
</div>
<div class="card">
<img src="marigold.jpg" alt="Marigold">
<div class="info">
<div class="number">4</div>
<h3>Marigold</h3>
<p>A popular garden flower with layered, ruffly petals.</p>
</div>
</div>
<div class="card">
<img src="yellow_tulip.jpg" alt="Yellow Tulip">
<div class="info">
<div class="number">5</div>
<h3>Yellow Tulip</h3>
<p>Elegant cup-shaped spring bloomer.</p>
</div>
</div>
<div class="card">
<img src="forsythia.jpg" alt="Forsythia">
<div class="info">
<div class="number">6</div>
<h3>Forsythia</h3>
<p>A shrub covered in bright yellow blossoms in early spring.</p>
</div>
</div>
<div class="card">
<img src="yellow_rose.jpg" alt="Yellow Rose">
<div class="info">
<div class="number">7</div>
<h3>Yellow Rose</h3>
<p>A classic symbol of friendship and joy. 🌹</p>
</div>
</div>
<div class="card">
<img src="goldenrod.jpg" alt="Goldenrod">
<div class="info">
<div class="number">8</div>
<h3>Goldenrod</h3>
<p>Tall, fluffy spikes of yellow that bloom in late summer.</p>
</div>
</div>
<div class="card">
<img src="yellow_daisy.jpg" alt="Yellow Daisy">
<div class="info">
<div class="number">9</div>
<h3>Yellow Daisy</h3>
<p>Simple, charming, and beloved worldwide.</p>
</div>
</div>
<div class="card">
<img src="yarrow.jpg" alt="Yarrow">
<div class="info">
<div class="number">10</div>
<h3>Yarrow</h3>
<p>Flat-topped clusters of tiny yellow flowers, great for pollinators.</p>
</div>
</div>
<div class="card">
<img src="primrose.jpg" alt="Primrose">
<div class="info">
<div class="number">11</div>
<h3>Primrose</h3>
<p>Soft, pale yellow flowers that bloom in early spring.</p>
</div>
</div>
<div class="card">
<img src="buttercup.jpg" alt="Buttercup">
<div class="info">
<div class="number">12</div>
<h3>Buttercup</h3>
<p>Glossy, shiny yellow petals often found in meadows.</p>
</div>
</div>
</div>
<footer>
<p>Images sourced via Openverse (Creative Commons) · Created with 💛</p>
</footer>
</body>
</html>