carterkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. carterkit/__init__.py +69 -0
  2. carterkit/buffer.py +230 -0
  3. carterkit/catalog.py +285 -0
  4. carterkit/client.py +138 -0
  5. carterkit/codegen.py +188 -0
  6. carterkit/controldocs/accordion.md +99 -0
  7. carterkit/controldocs/actions.md +51 -0
  8. carterkit/controldocs/animations.md +41 -0
  9. carterkit/controldocs/appearance.md +67 -0
  10. carterkit/controldocs/button.md +143 -0
  11. carterkit/controldocs/cardList.md +25 -0
  12. carterkit/controldocs/carousel.md +155 -0
  13. carterkit/controldocs/chat.md +114 -0
  14. carterkit/controldocs/color-picker.md +84 -0
  15. carterkit/controldocs/control-def.md +101 -0
  16. carterkit/controldocs/date-picker.md +148 -0
  17. carterkit/controldocs/divider.md +59 -0
  18. carterkit/controldocs/flip-card.md +99 -0
  19. carterkit/controldocs/gauge.md +198 -0
  20. carterkit/controldocs/graph.md +318 -0
  21. carterkit/controldocs/group-def.md +103 -0
  22. carterkit/controldocs/haptics.md +32 -0
  23. carterkit/controldocs/image.md +110 -0
  24. carterkit/controldocs/index.md +141 -0
  25. carterkit/controldocs/joystick.md +111 -0
  26. carterkit/controldocs/label.md +138 -0
  27. carterkit/controldocs/layout-config.md +129 -0
  28. carterkit/controldocs/list.md +92 -0
  29. carterkit/controldocs/log-console.md +121 -0
  30. carterkit/controldocs/long-press.md +46 -0
  31. carterkit/controldocs/map.md +165 -0
  32. carterkit/controldocs/picker.md +130 -0
  33. carterkit/controldocs/privacy.md +52 -0
  34. carterkit/controldocs/progress-ring.md +139 -0
  35. carterkit/controldocs/pulse.md +82 -0
  36. carterkit/controldocs/qr-code.md +101 -0
  37. carterkit/controldocs/segmented.md +145 -0
  38. carterkit/controldocs/slider.md +235 -0
  39. carterkit/controldocs/spacer.md +36 -0
  40. carterkit/controldocs/sparkline.md +120 -0
  41. carterkit/controldocs/status-light.md +114 -0
  42. carterkit/controldocs/stepper.md +131 -0
  43. carterkit/controldocs/sync.md +50 -0
  44. carterkit/controldocs/terms.md +40 -0
  45. carterkit/controldocs/text-input.md +126 -0
  46. carterkit/controldocs/theming.md +104 -0
  47. carterkit/controldocs/toggle.md +185 -0
  48. carterkit/controldocs/visibility.md +43 -0
  49. carterkit/controldocs/web-view.md +97 -0
  50. carterkit/e2ee.py +47 -0
  51. carterkit/grid.py +114 -0
  52. carterkit/infer.py +137 -0
  53. carterkit/theming.py +157 -0
  54. carterkit/tune.py +129 -0
  55. carterkit/validate.py +132 -0
  56. carterkit-0.1.0.dist-info/METADATA +98 -0
  57. carterkit-0.1.0.dist-info/RECORD +60 -0
  58. carterkit-0.1.0.dist-info/WHEEL +5 -0
  59. carterkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. carterkit-0.1.0.dist-info/top_level.txt +1 -0
carterkit/client.py ADDED
@@ -0,0 +1,138 @@
1
+ """carter_connect — minimal Connect+ hub client. Wraps MeshSocket + E2EE so a maker
2
+ connects hardware to the Connect+ relay in a few lines. Transparent encryption when an
3
+ e2ee_key is provided (broadcasts AND request replies); cleartext otherwise.
4
+
5
+ Also exposes `notify_http(...)` and `CarterClient.notify(...)` for sending a one-shot
6
+ push to every device on a Connect+ account (POST /alerts/notify). `notify_http` is
7
+ stdlib-only (urllib) so a cron job can fire a notification without the MeshSocket stack."""
8
+ import asyncio
9
+ import base64
10
+ import json
11
+ import os
12
+ import sys
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ try:
17
+ from meshsocket import MeshSocket # pip install meshsocket
18
+ from .e2ee import E2EESession
19
+ except ImportError: # keep notify_http importable without the MeshSocket/crypto stack
20
+ MeshSocket = None
21
+ E2EESession = None
22
+
23
+
24
+ class CarterNotifyError(Exception):
25
+ """Raised when /alerts/notify rejects a send. `status` is the HTTP code (0 for a
26
+ client-side/config error); `detail` is the server body or a description."""
27
+ def __init__(self, status, detail):
28
+ super().__init__(f"notify failed ({status}): {detail}")
29
+ self.status = status
30
+ self.detail = detail
31
+
32
+
33
+ def notify_http(validator_url, session_jwt, title, body, *, channel=None, category=None,
34
+ badge=None, sound="default", data=None, _send=None):
35
+ """Send a one-shot push to every device on the account (POST /alerts/notify).
36
+
37
+ Stdlib-only. `validator_url` is the Connect+ validator base URL; `session_jwt` is the
38
+ Connect+ account session token (NOT the MeshSocket auth token). Returns the parsed
39
+ `{"sent": N, "stale": M}` response. Raises CarterNotifyError on an HTTP error or
40
+ ValueError on invalid title/body. `_send` is a test seam: a callable
41
+ (url, headers, body_bytes) -> dict that bypasses the network."""
42
+ if not title or len(title) > 256:
43
+ raise ValueError("title must be non-empty and <= 256 chars")
44
+ if not body or len(body) > 256:
45
+ raise ValueError("body must be non-empty and <= 256 chars")
46
+
47
+ payload = {"title": title, "body": body, "sound": sound}
48
+ if channel is not None:
49
+ payload["channel"] = channel
50
+ if category is not None:
51
+ payload["category"] = category
52
+ if badge is not None:
53
+ payload["badge"] = badge
54
+ if data is not None:
55
+ payload["data"] = data
56
+
57
+ url = validator_url.rstrip("/") + "/alerts/notify"
58
+ headers = {"Authorization": session_jwt, "Content-Type": "application/json"}
59
+ body_bytes = json.dumps(payload).encode()
60
+
61
+ if _send is not None:
62
+ return _send(url, headers, body_bytes)
63
+
64
+ req = urllib.request.Request(url, data=body_bytes, headers=headers, method="POST")
65
+ try:
66
+ with urllib.request.urlopen(req) as resp:
67
+ return json.loads(resp.read().decode())
68
+ except urllib.error.HTTPError as e:
69
+ raise CarterNotifyError(e.code, e.read().decode(errors="replace")) from None
70
+
71
+
72
+ class CarterClient:
73
+ def __init__(self, gateway_url, token, channel, role="device", name="hub", e2ee_key=None,
74
+ validator_url=None, session_jwt=None):
75
+ if MeshSocket is None:
76
+ raise ImportError("MeshSocket is unavailable; run `pip install meshsocket`. "
77
+ "(notify_http does not need it.)")
78
+ self._sock = MeshSocket(url=gateway_url, name=name, auth_token=token,
79
+ channel=channel, role=role, can_broadcast=True, can_route=False)
80
+ self._session = (E2EESession(base64.b64decode(e2ee_key), is_device_side=(role in ("device", "hub")))
81
+ if e2ee_key else None)
82
+ # Connect+ validator credentials for notify(); distinct from the mesh auth token.
83
+ self._validator_url = validator_url
84
+ self._session_jwt = session_jwt
85
+
86
+ def _open(self, payload):
87
+ if self._session and isinstance(payload, dict) and E2EESession.is_envelope(payload):
88
+ return self._session.open(payload)
89
+ return payload
90
+
91
+ def _seal(self, data):
92
+ return self._session.seal(data) if (self._session and data is not None) else data
93
+
94
+ def on(self, msg_type, handler):
95
+ """Register a command handler. handler(data: dict) gets DECRYPTED data and may return a
96
+ dict reply (auto-encrypted). Sync or async handlers are supported."""
97
+ async def wrapper(payload):
98
+ data = self._open(payload)
99
+ result = handler(data)
100
+ if asyncio.iscoroutine(result):
101
+ result = await result
102
+ return self._seal(result) if result is not None else None
103
+ self._sock.on(msg_type, wrapper)
104
+
105
+ def on_broadcast(self, handler):
106
+ """Register a handler for relayed broadcasts. handler(data: dict) gets DECRYPTED data."""
107
+ async def wrapper(payload):
108
+ result = handler(self._open(payload))
109
+ if asyncio.iscoroutine(result):
110
+ await result
111
+ return None
112
+ self._sock.on("broadcast", wrapper)
113
+
114
+ async def broadcast(self, msg_type, data):
115
+ await self._sock.send("broadcast_request", self._seal({**data, "msg_type": msg_type}))
116
+
117
+ async def request(self, command, data, timeout=5.0):
118
+ reply = await self._sock.request(command, self._seal(data), timeout=timeout)
119
+ return self._open(reply) if reply is not None else None
120
+
121
+ async def notify(self, title, body, *, channel=None, category=None,
122
+ badge=None, sound="default", data=None):
123
+ """Send a one-shot push to every device on the account. Requires `validator_url`
124
+ and `session_jwt` to have been passed to the constructor (the mesh auth token is
125
+ NOT the session JWT — distinct credentials). Returns `{"sent": N, "stale": M}`."""
126
+ if not self._validator_url or not self._session_jwt:
127
+ raise CarterNotifyError(0, "notify() requires validator_url and session_jwt "
128
+ "on the CarterClient constructor")
129
+ return await asyncio.to_thread(
130
+ notify_http, self._validator_url, self._session_jwt, title, body,
131
+ channel=channel, category=category, badge=badge, sound=sound, data=data)
132
+
133
+ async def connect(self):
134
+ await self._sock.start()
135
+ await self._sock.wait_until_ready()
136
+
137
+ async def close(self):
138
+ await self._sock.stop()
carterkit/codegen.py ADDED
@@ -0,0 +1,188 @@
1
+ """Backend codegen — generate a MeshSocket service that speaks to a given layout.
2
+
3
+ A layout declares the events its controls fire (actions) and the events/valuePaths
4
+ its display controls listen for (sync). From that contract we can emit a runnable
5
+ Python service skeleton (handles the actions, emits the telemetry) and a REST-poll
6
+ adapter template. Pure string generation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+
15
+ def _walk_controls(layout: dict) -> list[dict]:
16
+ out: list[dict] = []
17
+
18
+ def walk(children):
19
+ for ch in children or []:
20
+ if not isinstance(ch, dict):
21
+ continue
22
+ out.append(ch)
23
+ if ch.get("type") == "group":
24
+ walk(ch.get("children"))
25
+
26
+ for tab in layout.get("tabs", []):
27
+ walk(tab.get("children"))
28
+ return out
29
+
30
+
31
+ def analyze_layout(layout: dict) -> dict:
32
+ """Extract the wire contract: {actions: {event: mode}, emits: {event: [valuePaths]}}."""
33
+ actions: dict[str, str] = {}
34
+ emits: dict[str, set] = {}
35
+ for ch in _walk_controls(layout):
36
+ a = ch.get("action")
37
+ if isinstance(a, dict) and a.get("event"):
38
+ actions.setdefault(a["event"], a.get("mode", "send"))
39
+ for s in ch.get("sync") or []:
40
+ if isinstance(s, dict) and s.get("valuePath"):
41
+ emits.setdefault(s.get("event") or "broadcast", set()).add(s["valuePath"])
42
+ return {"actions": actions, "emits": {k: sorted(v) for k, v in emits.items()}}
43
+
44
+
45
+ def _ident(event: str) -> str:
46
+ s = re.sub(r"\W+", "_", event).strip("_")
47
+ return s or "evt"
48
+
49
+
50
+ def _connection(layout: dict) -> tuple[str, str]:
51
+ c = layout.get("connection") or {}
52
+ return c.get("url") or "ws://localhost:8765", c.get("token") or ""
53
+
54
+
55
+ def generate_service_stub(layout: dict) -> str:
56
+ """A runnable Python MeshSocket service skeleton for this layout."""
57
+ spec = analyze_layout(layout)
58
+ url, token = _connection(layout)
59
+ name = layout.get("name", "service")
60
+
61
+ handlers = []
62
+ for event, mode in sorted(spec["actions"].items()):
63
+ fn = f"on_{_ident(event)}"
64
+ reply = " return {\"ok\": True}" if mode == "request" else " return None"
65
+ handlers.append(
66
+ f'@socket.on("{event}")\n'
67
+ f'async def {fn}(payload):\n'
68
+ f' # action from a control (mode={mode})\n'
69
+ f' print("[action] {event}:", payload)\n'
70
+ f'{reply}\n')
71
+ handlers_block = "\n".join(handlers) if handlers else "# (layout fires no actions)\n"
72
+
73
+ emit_lines = []
74
+ for event, paths in sorted(spec["emits"].items()):
75
+ emit_lines.append(f' frame = {{}}')
76
+ for p in paths:
77
+ emit_lines.append(f' _set(frame, "{p}", round(random.uniform(0, 100), 2))')
78
+ send = ('await socket.send("broadcast_request", frame)' if event == "broadcast"
79
+ else f'await socket.send("{event}", frame)')
80
+ emit_lines.append(f' {send}')
81
+ emit_block = "\n".join(emit_lines) if emit_lines else " pass # no sync controls"
82
+
83
+ return f'''#!/usr/bin/env python3
84
+ """Auto-generated MeshSocket service for layout "{name}".
85
+
86
+ Handles the actions its controls fire and emits the telemetry they listen for.
87
+ Fill in the TODOs with your real device/data logic.
88
+ """
89
+ import asyncio
90
+ import random
91
+
92
+ from meshsocket import MeshSocket # pip install meshsocket
93
+
94
+ URL = "{url}"
95
+ TOKEN = "{token}"
96
+
97
+
98
+ def _set(d, dotted, value):
99
+ parts = dotted.split(".")
100
+ cur = d
101
+ for p in parts[:-1]:
102
+ cur = cur.setdefault(p, {{}})
103
+ cur[parts[-1]] = value
104
+
105
+
106
+ socket = MeshSocket(url=URL, name="{_ident(name)}-service", auth_token=TOKEN,
107
+ channel="home", role="server", can_broadcast=True)
108
+
109
+
110
+ {handlers_block}
111
+
112
+ async def telemetry_loop():
113
+ while True:
114
+ # TODO: replace random values with real readings
115
+ {emit_block}
116
+ await asyncio.sleep(1.0)
117
+
118
+
119
+ async def main():
120
+ asyncio.create_task(socket.start())
121
+ await socket.wait_until_ready()
122
+ await telemetry_loop()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ asyncio.run(main())
127
+ '''
128
+
129
+
130
+ def generate_rest_adapter(layout: dict, base_url: str = "https://api.example.com") -> str:
131
+ """A REST-poll → MeshSocket adapter template mapping API fields to the layout's
132
+ sync valuePaths."""
133
+ spec = analyze_layout(layout)
134
+ paths = sorted({p for ps in spec["emits"].values() for p in ps})
135
+ mapping = "\n".join(f' _set(frame, "{p}", data.get("{p.split(".")[-1]}"))'
136
+ for p in paths) or " pass # map API fields here"
137
+ url, token = _connection(layout)
138
+ return f'''#!/usr/bin/env python3
139
+ """Auto-generated REST -> MeshSocket adapter for "{layout.get('name','layout')}".
140
+
141
+ Polls a REST API and pushes the fields the layout's controls listen for.
142
+ """
143
+ import asyncio
144
+ import urllib.request
145
+ import json
146
+
147
+ from meshsocket import MeshSocket # pip install meshsocket
148
+
149
+ API = "{base_url}"
150
+ URL = "{url}"
151
+ TOKEN = "{token}"
152
+
153
+
154
+ def _set(d, dotted, value):
155
+ parts = dotted.split(".")
156
+ cur = d
157
+ for p in parts[:-1]:
158
+ cur = cur.setdefault(p, {{}})
159
+ cur[parts[-1]] = value
160
+
161
+
162
+ socket = MeshSocket(url=URL, name="rest-adapter", auth_token=TOKEN,
163
+ channel="home", role="server", can_broadcast=True)
164
+
165
+
166
+ def fetch():
167
+ with urllib.request.urlopen(API, timeout=10) as r:
168
+ return json.loads(r.read())
169
+
170
+
171
+ async def loop():
172
+ while True:
173
+ data = await asyncio.to_thread(fetch)
174
+ frame = {{}}
175
+ {mapping}
176
+ await socket.send("broadcast_request", frame)
177
+ await asyncio.sleep(2.0)
178
+
179
+
180
+ async def main():
181
+ asyncio.create_task(socket.start())
182
+ await socket.wait_until_ready()
183
+ await loop()
184
+
185
+
186
+ if __name__ == "__main__":
187
+ asyncio.run(main())
188
+ '''
@@ -0,0 +1,99 @@
1
+ ---
2
+ type: accordion
3
+ label: Accordion
4
+ icon: list.bullet.below.rectangle
5
+ category: controls
6
+ defaultSpan: [3, 2]
7
+ fields:
8
+ - name: accordionMode
9
+ type: enum
10
+ values: [single, multi]
11
+ default: single
12
+ description: Whether one or many sections can be open
13
+ - name: expandedIndex
14
+ type: number
15
+ default: 0
16
+ description: Section open on load (-1 = all collapsed)
17
+ - name: showChevron
18
+ type: bool
19
+ default: true
20
+ description: Show the disclosure chevron
21
+ themeFields:
22
+ - name: surfacePrimary
23
+ type: color
24
+ default: "#FFFFFF0F"
25
+ description: Section card background
26
+ - name: accentColor
27
+ type: color
28
+ default: "#667eea"
29
+ description: Chevron color
30
+ ---
31
+
32
+ # Accordion
33
+
34
+ A vertical stack of collapsible **sections**. Each section is a [[group-def|group]] whose
35
+ `label` is the header and whose grid of children is the expandable body. In `single` mode the
36
+ open section index is the control value (so it **syncs** and fires **actions**); in `multi`
37
+ mode sections open independently.
38
+
39
+ ## Type
40
+ `"accordion"`
41
+
42
+ ## Relevant Fields
43
+
44
+ | Field | Type | Default | Description |
45
+ |-------|------|---------|-------------|
46
+ | `panels` | [[group-def]][] | — | The sections, each a group definition |
47
+ | `accordionMode` | string | `"single"` | `"single"` (one open) or `"multi"` (many) |
48
+ | `expandedIndex` | number | `0` | Section open on load; `-1` = all collapsed |
49
+ | `showChevron` | bool | `true` | Show the disclosure chevron |
50
+ | `defaultValue` | number | — | Initial open index (overrides `expandedIndex` if set) |
51
+ | `containerAnimation` | object | — | Motion customization (see below) |
52
+
53
+ In `single` mode, an incoming [[sync]] opens a section (cascading through intermediate
54
+ sections on a multi-step jump); toggling fires the [[actions|action]] with the index (`-1`
55
+ when collapsing). In `multi` mode the open set is local UI state and the value reflects only
56
+ the last-toggled index.
57
+
58
+ ## Animation Customization
59
+
60
+ | Field | Type | Default | Description |
61
+ |-------|------|---------|-------------|
62
+ | `profile` | string | `bouncy` | Base spring profile |
63
+ | `duration` | number | — | Override expand/collapse seconds |
64
+ | `bounce` | number | — | Spring bounce 0–1 |
65
+ | `multiStep` | bool | `true` | Cascade through sections on a multi-step jump (single mode) |
66
+ | `stepInterval` | number | `0.12` | Seconds per intermediate step |
67
+ | `chevronRotation` | number | `90` | Chevron rotation° when expanded |
68
+
69
+ ## Examples
70
+
71
+ ### Single-open settings, synced
72
+ ```json
73
+ {
74
+ "type": "accordion",
75
+ "id": "settings",
76
+ "position": [0, 0],
77
+ "span": [4, 2],
78
+ "accordionMode": "single",
79
+ "expandedIndex": 0,
80
+ "containerAnimation": { "profile": "smooth", "chevronRotation": 90 },
81
+ "panels": [
82
+ { "type": "group", "id": "display", "label": "Display", "position": [0,0], "grid": { "columns": 1, "rows": 1 },
83
+ "children": [ { "type": "slider", "id": "bright", "position": [0,0], "label": "Brightness", "min": 0, "max": 100 } ] },
84
+ { "type": "group", "id": "network", "label": "Network", "position": [0,0], "grid": { "columns": 1, "rows": 1 },
85
+ "children": [ { "type": "toggle", "id": "wifi", "position": [0,0], "label": "Wi-Fi" } ] }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ ## Theming
91
+ Sections are glass cards from the active [[theming|theme]]; the chevron uses `accentColor`.
92
+
93
+ ## Related
94
+ - [[group-def]] — each section is a group
95
+ - [[carousel]] — horizontal paging
96
+ - [[flip-card]] — flip between faces
97
+ - [[actions]] — `{{value}}` section index
98
+ - [[sync]] — drive the open section from the server
99
+ - [[animations]] — base animation profiles
@@ -0,0 +1,51 @@
1
+ ---
2
+ type: actions
3
+ label: Actions
4
+ icon: bolt.fill
5
+ category: system
6
+ fields:
7
+ - name: method
8
+ type: string
9
+ description: Transport method (meshsocket)
10
+ - name: mode
11
+ type: string
12
+ description: Send mode (request, broadcast)
13
+ - name: event
14
+ type: string
15
+ description: Event name to fire
16
+ - name: payload
17
+ type: object
18
+ description: Data to send (supports {{value}} substitution)
19
+ ---
20
+
21
+ How controls send commands to the server.
22
+
23
+ ## Definition
24
+
25
+ ```json
26
+ "action": {
27
+ "method": "meshsocket",
28
+ "mode": "request",
29
+ "event": "set_power",
30
+ "payload": { "state": "{{value}}" }
31
+ }
32
+ ```
33
+
34
+ ## Modes
35
+
36
+ | Mode | Behavior |
37
+ |------|----------|
38
+ | `request` | Send + await response |
39
+ | `broadcast` | Fire and forget |
40
+
41
+ ## Substitution
42
+
43
+ `{{value}}` in any string becomes the current control value:
44
+ - [[toggle]]: `"true"` / `"false"`
45
+ - [[slider]]: `"75.0"`
46
+ - [[picker]]: `"Ocean"`
47
+
48
+ ## Related
49
+
50
+ - [[control-def]] — every control can have an action
51
+ - [[long-press]] — alternate action on long press
@@ -0,0 +1,41 @@
1
+ ---
2
+ type: animations
3
+ label: Animations
4
+ icon: sparkles
5
+ category: system
6
+ fields:
7
+ - name: profile
8
+ type: string
9
+ description: Named animation preset
10
+ - name: duration
11
+ type: number
12
+ description: Custom duration override
13
+ ---
14
+
15
+ Transition and interaction animations.
16
+
17
+ ## Profiles
18
+
19
+ | Name | SwiftUI |
20
+ |------|---------|
21
+ | `snappy` | `.snappy` |
22
+ | `smooth` | `.smooth(duration: 0.35)` |
23
+ | `bouncy` | `.bouncy(duration: 0.5)` |
24
+ | `gentle` | `.easeInOut(duration: 0.25)` |
25
+ | `instant` | `.linear(duration: 0)` |
26
+
27
+ ## Usage
28
+
29
+ ```json
30
+ "animation": "bouncy"
31
+ ```
32
+
33
+ Or custom:
34
+ ```json
35
+ "animation": { "profile": "smooth", "duration": 0.5 }
36
+ ```
37
+
38
+ ## Related
39
+
40
+ - [[visibility]] — uses `gentle` for show/hide
41
+ - [[control-def]] — any control can override its animation
@@ -0,0 +1,67 @@
1
+ ---
2
+ type: appearance
3
+ label: Appearance
4
+ icon: macwindow
5
+ category: models
6
+ ---
7
+
8
+ The `appearance` block on a [[layout-config|layout]] controls the app shell — color scheme, header bar, status bar, and background image. Colors and control styling live in [[theming|theme]]; appearance is about the chrome around your grid.
9
+
10
+ ```json
11
+ "appearance": {
12
+ "colorScheme": "system",
13
+ "showHeader": true,
14
+ "statusBarStyle": "auto",
15
+ "header": { "style": "transparent" },
16
+ "backgroundImage": { "asset": "bg", "blur": 20, "opacity": 0.5 }
17
+ }
18
+ ```
19
+
20
+ ## Fields
21
+
22
+ | Field | Type | Default | Description |
23
+ |-------|------|---------|-------------|
24
+ | `colorScheme` | `dark` / `light` / `system` | `system` | `system` follows the device and adapts the palette live. `dark`/`light` pin the whole app. |
25
+ | `showHeader` | bool | `true` | Show or hide the header bar |
26
+ | `statusBarStyle` | `auto` / `light` / `dark` | `auto` | Status bar content color |
27
+ | `header` | object | transparent | Header bar style (see below) |
28
+ | `backgroundImage` | object | — | Page background image (see below) |
29
+
30
+ ## Header
31
+
32
+ Omit `header` for a **transparent** bar — the page background shows through the bar and the status bar. Provide a color/gradient for a filled bar that **extends up into the status bar**.
33
+
34
+ | Field | Type | Description |
35
+ |-------|------|-------------|
36
+ | `style` | `transparent` / `material` / `color` | Inferred as `color` when `light`/`dark` are set; otherwise `transparent` |
37
+ | `light` | color or color[] | Fill for light mode — a hex string or a 2+ color gradient |
38
+ | `dark` | color or color[] | Fill for dark mode |
39
+
40
+ ```json
41
+ "header": {
42
+ "style": "color",
43
+ "dark": ["#0A0F1E", "#0E1A33"],
44
+ "light": "#EAF0FBF2"
45
+ }
46
+ ```
47
+
48
+ - **transparent** — no fill; seamless with the page. No divider.
49
+ - **material** — frosted glass bar (stays within the safe area).
50
+ - **color** — solid or gradient fill that bleeds into the status bar. If the active mode's fill is missing, it falls back to transparent.
51
+
52
+ ## Background Image
53
+
54
+ | Field | Type | Default | Description |
55
+ |-------|------|---------|-------------|
56
+ | `url` | string | — | Remote image URL (loaded async) |
57
+ | `asset` | string | — | Bundled image asset name |
58
+ | `contentMode` | `fill` / `fit` | `fill` | Scaling mode |
59
+ | `blur` | number | — | Gaussian blur radius |
60
+ | `opacity` | number | `1.0` | Image opacity (0–1) |
61
+ | `overlay` | color | — | Hex overlay for readability (e.g. `"#00000080"`) |
62
+
63
+ The image sits above the page background/gradient and below all controls, on every tab.
64
+
65
+ ## Related
66
+ - [[theming]] — Colors, fonts, per-control styling, light/dark variants
67
+ - [[layout-config]] — Where `appearance` lives