Files
el/lang/el-compiler/runtime/PLATFORM_BRIDGE_SPEC.md

555 lines
25 KiB
Markdown

# Platform Bridge Specification — el-native
This document is the authoritative reference for anyone implementing a new platform bridge for the el-native widget system. Read it top to bottom before writing a single line of code.
---
## What a Platform Bridge Is
The el compiler (`elc`) emits C code. That C code calls `__`-prefixed functions for everything OS-related: printing, file I/O, threading, and — when building a native UI app — widget creation and event handling. These `__` functions are the *OS boundary*.
For native UI, the bridge is the translation layer between that fixed C API and whatever platform toolkit you are targeting. The bridge owns:
1. A **slot table** of up to 4096 widget objects, indexed by `int64_t` handle.
2. Implementations of all 33 `__*` widget functions declared in `el_native_target.h`.
3. Callback dispatch from platform events back into El function symbols resolved via `dlsym` (or a platform equivalent).
The thin wrappers in `el_seed.c` (`#ifdef EL_TARGET_*` blocks) marshal between `el_val_t` and native C types, then call through to the bridge. The bridge itself never touches `el_val_t` — it works only with plain C types (`int64_t`, `const char*`, `int`, `float`).
**Existing bridges:**
| File | Platform | Toolkit | Language |
|------|----------|---------|----------|
| `el_appkit.m` | macOS | AppKit | ObjC (MRC) |
| `el_gtk4.c` | Linux | GTK4 | C |
| `el_win32.c` | Windows | Win32/ComCtl | C |
| `el_uikit.m` | iOS | UIKit | ObjC (MRC) |
| `el_android.c` + `ElBridge.java` | Android | View/JNI | C + Java |
| `el_sdl2.c` | Embedded Linux / Pi | SDL2 | C |
| `el_lvgl.c` | Microcontrollers | LVGL | C |
---
## The Slot System
Every widget — window, button, label, container, image — is stored in a static array:
```c
#define EL_<PLATFORM>_MAX_WIDGETS 4096
typedef struct {
ElWidgetKind kind;
/* platform-specific object reference (pointer, handle, ID...) */
/* callback names */
char* cb_click;
char* cb_change;
} ElWidget;
static ElWidget _el_widgets[EL_<PLATFORM>_MAX_WIDGETS];
```
Rules:
- **Slot 0 is never valid.** Scan starts at index 1. This ensures 0 is never a valid handle.
- **Handle = slot index.** An `int64_t` value returned to El code is a direct index into `_el_widgets[]`.
- **-1 = invalid.** All create functions return -1 on failure (table full, platform API error).
- **Slot is FREE until allocated, FREE again after destroy.** Use an `ElWidgetKind` enum where `0 = EL_WIDGET_FREE` to track liveness.
- **4096 slots is the system-wide maximum.** This is intentional and sufficient for any realistic UI. Do not increase it without a compelling reason.
### Slot allocation and release pattern
```c
static int64_t el_widget_alloc(ElWidgetKind kind, /* platform object ref */) {
for (int i = 1; i < EL_<PLATFORM>_MAX_WIDGETS; i++) {
if (_el_widgets[i].kind == EL_WIDGET_FREE) {
_el_widgets[i].kind = kind;
/* store platform object ref — retain/addref if needed */
_el_widgets[i].cb_click = NULL;
_el_widgets[i].cb_change = NULL;
return (int64_t)i;
}
}
return -1; /* table full */
}
static ElWidget* el_widget_get(int64_t handle) {
if (handle <= 0 || handle >= EL_<PLATFORM>_MAX_WIDGETS) return NULL;
if (_el_widgets[handle].kind == EL_WIDGET_FREE) return NULL;
return &_el_widgets[handle];
}
static void el_widget_free(int64_t handle) {
ElWidget* w = el_widget_get(handle);
if (!w) return;
/* release platform object ref */
w->kind = EL_WIDGET_FREE;
free(w->cb_click); w->cb_click = NULL;
free(w->cb_change); w->cb_change = NULL;
}
```
**NULL handle guard:** `el_widget_get` must return `NULL` for any handle that is 0, negative, out of range, or points to a `FREE` slot. Every `__` function that takes a handle must null-check the result of `el_widget_get` before doing anything. Failing to do so causes crashes or corruption when El code passes an uninitialized handle.
---
## The 33 Required Functions
Every bridge must implement all 33 functions listed below. They are grouped by category. The signatures shown are from `el_native_target.h` and from `el_seed.c`'s wrapper layer; the bridge itself uses plain C types (the wrappers do the `el_val_t` ↔ C-type conversion).
### Internal C signatures (what the bridge implements)
These are what your `.c` / `.m` file defines. The `el_seed.c` wrappers call these.
#### Lifecycle (2 functions)
```c
void el_<platform>_init(void);
```
**Purpose:** Initialize the platform UI toolkit. Must be idempotent (safe to call more than once). Called once from `__native_init` before any widget creation.
**Edge cases:** On platforms where the toolkit must be initialized before a display connection is established (X11, Wayland), this is where that happens. On Android, this is a no-op because ElBridge.java calls init from Java.
```c
void el_<platform>_run_loop(void);
```
**Purpose:** Start the platform event loop. On most platforms this **never returns**. Exceptions: Android (the loop is Java-managed — this must be a no-op) and headless test builds.
**Edge cases:** Must be called from the main thread. On iOS/UIKit, calls `UIApplicationMain` which never returns; El code must set `el_main_entry_fn` before calling this.
#### Window (3 functions)
```c
int64_t el_<platform>_window_create(const char* title, int w, int h, int mw, int mh);
```
**Purpose:** Create a top-level window. `w`/`h` = initial size in logical pixels. `mw`/`mh` = minimum size (0 = no minimum).
**Returns:** Slot handle, or -1 on failure.
**Edge cases:** NULL or empty `title` must be handled gracefully (use `""`). On mobile (iOS, Android), the concept of a "window" maps to the root view controller / activity root view — create that here.
```c
void el_<platform>_window_show(int64_t handle);
```
**Purpose:** Make the window visible. On some platforms windows are hidden at creation; this makes them appear.
**Edge cases:** NULL handle → return silently. Calling on an already-visible window is a no-op.
```c
void el_<platform>_window_set_title(int64_t handle, const char* title);
```
**Purpose:** Update the window's title bar text at runtime.
**Edge cases:** NULL handle or NULL title → no-op.
#### Layout containers (4 functions)
```c
int64_t el_<platform>_vstack_create(int spacing);
int64_t el_<platform>_hstack_create(int spacing);
```
**Purpose:** Create a vertical/horizontal linear container. `spacing` = gap between children in logical pixels.
**Returns:** Slot handle, or -1.
**Edge cases:** spacing = 0 is valid and common.
```c
int64_t el_<platform>_zstack_create(void);
```
**Purpose:** Create a z-axis layered container (children overlap, no stacking direction). Used for overlays.
**Returns:** Slot handle, or -1.
```c
int64_t el_<platform>_scroll_create(void);
```
**Purpose:** Create a scrollable container. Scrolls vertically by default. Only the first child added is the scrollable content.
**Returns:** Slot handle, or -1.
#### Leaf widgets (5 functions)
```c
int64_t el_<platform>_label_create(const char* text);
```
**Purpose:** Create a non-editable text label.
**Edge cases:** NULL/empty text → label with empty string, not a crash.
```c
int64_t el_<platform>_button_create(const char* label);
```
**Purpose:** Create a clickable button. The `label` is the button's visible text.
**Edge cases:** The button must wire up an action target at creation time so that click callbacks registered later via `el_<platform>_widget_on_click` will fire. On platforms with target-action (AppKit, UIKit), allocate the delegate object here.
```c
int64_t el_<platform>_text_field_create(const char* placeholder);
```
**Purpose:** Create a single-line text input. `placeholder` is the hint text shown when empty.
**Edge cases:** NULL placeholder → no hint text displayed.
```c
int64_t el_<platform>_text_area_create(const char* placeholder);
```
**Purpose:** Create a multi-line text input (scrollable).
**Edge cases:** Same as text_field_create. On AppKit, this wraps NSTextView inside NSScrollView — the slot's `obj` points to the scroll view, not the text view.
```c
int64_t el_<platform>_image_create(const char* path_or_name);
```
**Purpose:** Create an image widget. `path_or_name` can be a filesystem path or a platform resource name. Try path first, fall back to named resource.
**Edge cases:** Non-existent path → create an empty image widget (do not crash). NULL → same.
#### Widget properties (12 functions)
```c
void el_<platform>_widget_set_text(int64_t handle, const char* text);
```
**Purpose:** Update the text content of a label, button, text field, text area, or window title. Must dispatch on `kind` to use the correct API.
**Edge cases:** NULL handle → no-op. NULL text → treat as `""`.
```c
const char* el_<platform>_widget_get_text(int64_t handle);
```
**Purpose:** Return the current text of a widget. Returns a `const char*` that the `el_seed.c` wrapper wraps into an `el_val_t` string.
**Edge cases:** NULL handle → return `""` (never NULL). The caller in `el_seed.c` handles the `strdup` lifetime issue — see the AppKit reference implementation notes.
```c
void el_<platform>_widget_set_color(int64_t h, float r, float g, float b, float a);
void el_<platform>_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
```
**Purpose:** Set foreground (text) color and background color respectively. All channels are normalized floats [0.0, 1.0].
**Edge cases:** For containers, `set_color` may be a no-op (no text); `set_bg_color` should set the layer/surface background. On platforms without alpha compositing, clamp alpha to 0 or 1.
```c
void el_<platform>_widget_set_font(int64_t h, const char* family, int size, int bold);
```
**Purpose:** Set the font on a text-bearing widget. `family` = font family name or `"system"` for the platform default. `size` = point size. `bold` = 0 or 1.
**Edge cases:** If `family` is not found, fall back to the system font. Non-text widgets (containers, images) → no-op.
```c
void el_<platform>_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
```
**Purpose:** Set internal padding/insets for a container or text area.
**Edge cases:** On platforms where padding is per-view (not per-container), map to the nearest equivalent (margin, insets, text container inset). For leaf widgets other than text areas, this may be a partial no-op.
```c
void el_<platform>_widget_set_width(int64_t h, int width);
void el_<platform>_widget_set_height(int64_t h, int height);
```
**Purpose:** Apply a fixed-size constraint. `width`/`height` in logical pixels.
**Edge cases:** On platforms with Auto Layout or constraint systems, add a fixed-size constraint. Do not apply to windows (size is set at creation). Calling multiple times should override the previous constraint, not add another.
```c
void el_<platform>_widget_set_flex(int64_t h, int flex);
```
**Purpose:** Set the flex/expansion factor. `flex > 0` → the widget expands to fill available space. `flex == 0` → hugs content size.
**Edge cases:** Maps to content-hugging priority (AppKit), `GtkWidget::hexpand`/`vexpand` (GTK4), or layout weight (Android). For windows → no-op.
```c
void el_<platform>_widget_set_corner_radius(int64_t h, int radius);
```
**Purpose:** Apply rounded corners to the widget's visual layer.
**Edge cases:** Requires backing layer / GPU surface. On platforms without layer compositing (Win32 without DX), this may be a no-op or require manual painting. Radius in logical pixels.
```c
void el_<platform>_widget_set_disabled(int64_t h, int disabled);
```
**Purpose:** Enable or disable user interaction. `disabled = 1` → greyed out, non-interactive.
**Edge cases:** Only meaningful for interactive widgets (button, text field). For containers/labels → no-op.
```c
void el_<platform>_widget_set_hidden(int64_t h, int hidden);
```
**Purpose:** Show or hide the widget. `hidden = 1` → invisible but still in layout.
**Edge cases:** For windows, map to `orderOut`/`hide` or equivalent. For views, use `setHidden`/`gtk_widget_set_visible` or equivalent.
#### Tree management (3 functions)
```c
void el_<platform>_widget_add_child(int64_t parent, int64_t child);
```
**Purpose:** Attach a child widget to a parent container. Dispatch on parent kind:
- Window → add to root content view/container
- VStack/HStack → add as arranged/linear child
- ZStack → add as overlapping subview
- Scroll → set as document/content view (first child only)
- Other → add as plain subview
**Edge cases:** NULL parent or child handle → no-op. Attempting to add a window as a child → no-op. Adding the same child twice is platform-defined behavior (tolerate it).
```c
void el_<platform>_widget_remove_child(int64_t parent, int64_t child);
```
**Purpose:** Detach child from its parent. The child slot remains allocated; the widget is not destroyed.
**Edge cases:** NULL handles → no-op. Child not currently attached → no-op.
```c
void el_<platform>_widget_destroy(int64_t handle);
```
**Purpose:** Destroy a widget: remove from superview/parent, release the platform object, release callback strings, mark slot as FREE.
**Edge cases:** For windows, close the window. Free any delegate/target objects stored in side tables. After destroy, the handle is invalid — El code must not use it again (this is the caller's responsibility, not enforced here).
#### Event registration (3 functions)
```c
void el_<platform>_widget_on_click(int64_t h, const char* fn_name);
void el_<platform>_widget_on_change(int64_t h, const char* fn_name);
void el_<platform>_widget_on_submit(int64_t h, const char* fn_name);
```
**Purpose:** Register an El callback function by symbol name.
- `on_click` → button press
- `on_change` → text field value change (keystroke-level)
- `on_submit` → text field Enter key (text field only; stored in `cb_click` slot in AppKit)
The implementation stores `strdup(fn_name)` in the widget's `cb_click` or `cb_change` field. The platform event handler calls `el_<platform>_invoke_cb` (see callback ABI section).
**Edge cases:** NULL or empty `fn_name` → clear the callback (`free` + set NULL). Calling on a non-interactive widget (label, image) → no-op or store silently (harmless).
#### Manifest reader (1 function)
```c
/* Note: __manifest_read is implemented in el_seed.c, not in the bridge. */
/* Bridges do NOT need to implement this. */
```
`__manifest_read` is handled entirely in the platform-independent section of `el_seed.c`. It reads a JSON/EL manifest file from the path in the `EL_MANIFEST` environment variable. Bridge authors do not need to implement this.
---
## The Callback ABI
When a platform event fires (button clicked, text changed), the bridge must call back into the El runtime. The mechanism:
```c
typedef void (*ElCb2)(int64_t handle, int64_t data);
static void el_<platform>_invoke_cb(const char* fn_name, int64_t handle, const char* data) {
if (!fn_name || !*fn_name) return;
void* sym = dlsym(RTLD_DEFAULT, fn_name);
if (!sym) return;
ElCb2 fn = (ElCb2)sym;
fn(handle, (int64_t)(uintptr_t)(data ? data : ""));
}
```
The El callback signature (in El source):
```
fn handler(handle: Int, data: String) -> Void
```
Which compiles to:
```c
void handler(int64_t handle, int64_t data)
```
Where `data` is a `const char*` cast to `int64_t` (the el `String` representation). For click events, `data` is `""`. For change/submit events, `data` is the current widget text.
**On platforms without `dlsym`** (Windows, some embedded systems): use `GetProcAddress(GetModuleHandle(NULL), fn_name)` on Windows, or maintain a manual symbol registration table for embedded targets where dynamic linking is unavailable.
**Thread safety for callbacks:** Callbacks fired from a background thread must be marshalled to the UI thread before calling into El code. El code may call `__widget_set_text` or other UI functions synchronously from within the callback — those must run on the UI thread.
---
## Thread Safety Contract
**All platform UI operations must execute on the main/UI thread.** This is a hard requirement on every platform (AppKit, UIKit, GTK4, Win32, Android View, SDL2 main thread rule).
The reference pattern (AppKit):
```c
static void el_appkit_sync_main(void (^block)(void)) {
if ([NSThread isMainThread]) {
block();
} else {
dispatch_sync(dispatch_get_main_queue(), block);
}
}
```
For other platforms:
- **GTK4:** `g_main_context_invoke` or `g_idle_add` + semaphore for synchronous dispatch
- **Win32:** `SendMessage(hwnd, WM_APP, ...)` or `PostMessage` + wait
- **Android:** `Activity.runOnUiThread`
- **SDL2:** All ops must be called from the thread that initialized SDL (the main thread)
- **LVGL:** `lv_lock()` / `lv_unlock()` for thread-safe access
El program flow is: `main()` → build UI (on main thread) → `__native_run_loop()`. Because UI is built before the run loop starts, most widget creation calls are already on the main thread. The sync dispatch wrapper exists to handle callbacks that arrive on worker threads (e.g., network callbacks that update UI).
---
## Integration Pattern
### In `el_native_target.h`
Add an `#ifdef EL_TARGET_<PLATFORM>` block declaring all 33 `__*` functions with their `el_val_t` signatures (identical to the existing blocks for MACOS, LINUX, etc.):
```c
#ifdef EL_TARGET_<PLATFORM>
void __native_init(void);
void __native_run_loop(void);
el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
el_val_t min_width, el_val_t min_height);
void __window_show(el_val_t handle);
void __window_set_title(el_val_t handle, el_val_t title);
el_val_t __vstack_create(el_val_t spacing);
el_val_t __hstack_create(el_val_t spacing);
el_val_t __zstack_create(void);
el_val_t __scroll_create(void);
el_val_t __label_create(el_val_t text);
el_val_t __button_create(el_val_t label);
el_val_t __text_field_create(el_val_t placeholder);
el_val_t __text_area_create(el_val_t placeholder);
el_val_t __image_create(el_val_t path_or_name);
void __widget_set_text(el_val_t handle, el_val_t text);
el_val_t __widget_get_text(el_val_t handle);
void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
el_val_t b, el_val_t a);
void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
el_val_t b, el_val_t a);
void __widget_set_font(el_val_t handle, el_val_t family,
el_val_t size, el_val_t bold);
void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
el_val_t bottom, el_val_t left);
void __widget_set_width(el_val_t handle, el_val_t width);
void __widget_set_height(el_val_t handle, el_val_t height);
void __widget_set_flex(el_val_t handle, el_val_t flex);
void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
void __widget_set_disabled(el_val_t handle, el_val_t disabled);
void __widget_set_hidden(el_val_t handle, el_val_t hidden);
void __widget_add_child(el_val_t parent, el_val_t child);
void __widget_remove_child(el_val_t parent, el_val_t child);
void __widget_destroy(el_val_t handle);
void __widget_on_click(el_val_t handle, el_val_t fn_name);
void __widget_on_change(el_val_t handle, el_val_t fn_name);
void __widget_on_submit(el_val_t handle, el_val_t fn_name);
el_val_t __manifest_read(el_val_t path);
#endif /* EL_TARGET_<PLATFORM> */
```
### In `el_seed.c`
Add an `#ifdef EL_TARGET_<PLATFORM>` block containing:
1. `extern` declarations of all `el_<platform>_*` functions (your bridge's C API)
2. Thin wrapper functions that marshal `el_val_t` ↔ C types and call through
The wrapper pattern (copy from the `EL_TARGET_MACOS` block and substitute the platform name):
```c
#ifdef EL_TARGET_<PLATFORM>
/* Forward declarations — implemented in el_<platform>.c */
extern void el_<platform>_init(void);
extern void el_<platform>_run_loop(void);
extern int64_t el_<platform>_window_create(const char* title, int w, int h, int mw, int mh);
/* ... all others ... */
/* Wrappers */
void __native_init(void) { el_<platform>_init(); }
void __native_run_loop(void) { el_<platform>_run_loop(); }
el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
el_val_t min_width, el_val_t min_height) {
return (el_val_t)el_<platform>_window_create(
EL_CSTR(title),
(int)(int64_t)width, (int)(int64_t)height,
(int)(int64_t)min_width, (int)(int64_t)min_height);
}
void __window_show(el_val_t h) { el_<platform>_window_show((int64_t)h); }
void __window_set_title(el_val_t h, el_val_t t) { el_<platform>_window_set_title((int64_t)h, EL_CSTR(t)); }
/* ... continue for all 33 functions ... */
#endif /* EL_TARGET_<PLATFORM> */
```
Key marshalling rules:
- `el_val_t``int64_t` handle: `(int64_t)h`
- `el_val_t``int`: `(int)(int64_t)value`
- `el_val_t``const char*`: `EL_CSTR(value)`
- `el_val_t``float`: `(float)el_to_float(value)` (for color channels)
- `int64_t` handle → `el_val_t`: `(el_val_t)handle`
- `const char*``el_val_t`: `EL_STR(str)` — but read the get_text note below
**`__widget_get_text` note:** The bridge returns `const char*`. The `el_seed.c` wrapper wraps it with `EL_STR(s)`. The returned pointer must remain valid until the El program is done with it. The AppKit implementation returns a `strdup`'d string — the caller (seed wrapper) stores it without tracking it in the arena. This is a known lifetime edge; be consistent with the platform's existing pattern.
---
## How el Strings Work
Inside El compiled C code, strings are `el_val_t` values where the value is the `uintptr_t` cast of a `const char*`:
```c
#define EL_STR(s) ((el_val_t)(uintptr_t)(s))
#define EL_CSTR(v) ((const char*)(uintptr_t)(v))
```
To construct a string result in `el_seed.c`:
```c
static char* s = strdup("hello");
return EL_STR(s);
```
To read a string argument passed from El:
```c
const char* text = EL_CSTR(some_el_val_t_argument);
```
The `EL_STR` / `EL_CSTR` macros are defined in `el_seed.h` and are available in `el_seed.c`. Bridge files (`el_<platform>.c`) do not use these macros — they only deal in `const char*` at their API boundary.
---
## Known Gotchas
### Duplicate symbols with `el_runtime.c`
`el_runtime.c` defines many of the same `__`-prefixed symbols as `el_seed.c`. When linking both (required for the native-hello example), the linker will reject duplicate definitions. The build system uses `nmedit` (macOS) to hide the `el_runtime.c` copies of symbols that `el_seed.c` already defines, keeping `el_seed.c` canonical.
If you are writing a build script for a new platform and see duplicate symbol link errors involving `__println`, `__print`, `__str_len`, etc., apply the same nmedit / `objcopy --weaken-symbol` / `strip --strip-symbol` trick from the macOS build to your platform's object file handling.
### The `nmedit` trick on macOS
```bash
# Build a keep-list: symbols defined in el_seed.o but also in el_runtime.o
nm el_seed.o | awk '/^[0-9a-f]+ T _/{print $3}' | sort > .seed_T.txt
nm el_runtime.o | awk '/^[0-9a-f]+ T _/{print $3}' | sort > .rt_T.txt
# Keep only symbols unique to el_runtime.o
comm -23 .rt_T.txt .seed_T.txt > .rt_keep.txt
nmedit -s .rt_keep.txt el_runtime.o
```
On Linux, use `objcopy` with `--weaken-symbol` for each duplicate, or link `el_seed.o` before `el_runtime.o` and use `--allow-multiple-definition` if your use case permits it.
### ObjC bridges must use MRC, not ARC
Bridges that use Objective-C (AppKit, UIKit) **must** compile without ARC (`-fno-objc-arc`). The reason: the widget table stores `id` values in a plain C struct. ARC cannot insert retain/release through C struct boundaries, and will reject explicit `[obj retain]` / `[obj release]` calls in ARC mode. Use:
```bash
clang -ObjC -fno-objc-arc -framework Cocoa -c el_appkit.m
```
### Widget table struct — don't put `id` fields in C structs under ARC
If you ever add ARC-managed object fields to the `ElWidget` struct, you will get a compile error. Keep the struct C-only (pointers as `void*` if you must, cast when using) or compile as MRC.
### The `el_seed.c` `__manifest_read` is platform-independent
`__manifest_read` is already implemented in `el_seed.c` (in the section compiled unconditionally). You do not implement it in your bridge. You do need to declare it in the `el_native_target.h` block for your platform (matching the other platforms), but `el_seed.c` already has the implementation.
---
## Completion Checklist
Before declaring your bridge ready for integration:
- [ ] All 33 `__*` functions implemented (including `__manifest_read` declaration, implementation is in seed)
- [ ] Slot table with 4096 entries, scan starting at index 1
- [ ] `el_widget_get` returns NULL for handle 0, negative, out-of-range, and FREE slots
- [ ] All functions null-check `el_widget_get` result before use
- [ ] `el_<platform>_init` is idempotent
- [ ] `el_<platform>_run_loop` either never returns or is documented as a no-op (Android)
- [ ] Callback dispatch via `dlsym` (or platform equivalent) implemented
- [ ] All UI operations dispatched to the main/UI thread
- [ ] `el_native_target.h` updated with `#ifdef EL_TARGET_<PLATFORM>` block
- [ ] `el_seed.c` updated with `#ifdef EL_TARGET_<PLATFORM>` extern + wrapper block
- [ ] Bridge compiles cleanly with no warnings: `cc -DEL_TARGET_<PLATFORM> -Wall -Wextra -c el_<platform>.c`
- [ ] Bridge links cleanly with `el_seed.o` and `el_runtime.o` (duplicate symbol check)
- [ ] `detect-platforms` script updated with detection logic for the new platform
- [ ] Basic smoke test: create window → add label → show window → run loop