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.
- carterkit/__init__.py +69 -0
- carterkit/buffer.py +230 -0
- carterkit/catalog.py +285 -0
- carterkit/client.py +138 -0
- carterkit/codegen.py +188 -0
- carterkit/controldocs/accordion.md +99 -0
- carterkit/controldocs/actions.md +51 -0
- carterkit/controldocs/animations.md +41 -0
- carterkit/controldocs/appearance.md +67 -0
- carterkit/controldocs/button.md +143 -0
- carterkit/controldocs/cardList.md +25 -0
- carterkit/controldocs/carousel.md +155 -0
- carterkit/controldocs/chat.md +114 -0
- carterkit/controldocs/color-picker.md +84 -0
- carterkit/controldocs/control-def.md +101 -0
- carterkit/controldocs/date-picker.md +148 -0
- carterkit/controldocs/divider.md +59 -0
- carterkit/controldocs/flip-card.md +99 -0
- carterkit/controldocs/gauge.md +198 -0
- carterkit/controldocs/graph.md +318 -0
- carterkit/controldocs/group-def.md +103 -0
- carterkit/controldocs/haptics.md +32 -0
- carterkit/controldocs/image.md +110 -0
- carterkit/controldocs/index.md +141 -0
- carterkit/controldocs/joystick.md +111 -0
- carterkit/controldocs/label.md +138 -0
- carterkit/controldocs/layout-config.md +129 -0
- carterkit/controldocs/list.md +92 -0
- carterkit/controldocs/log-console.md +121 -0
- carterkit/controldocs/long-press.md +46 -0
- carterkit/controldocs/map.md +165 -0
- carterkit/controldocs/picker.md +130 -0
- carterkit/controldocs/privacy.md +52 -0
- carterkit/controldocs/progress-ring.md +139 -0
- carterkit/controldocs/pulse.md +82 -0
- carterkit/controldocs/qr-code.md +101 -0
- carterkit/controldocs/segmented.md +145 -0
- carterkit/controldocs/slider.md +235 -0
- carterkit/controldocs/spacer.md +36 -0
- carterkit/controldocs/sparkline.md +120 -0
- carterkit/controldocs/status-light.md +114 -0
- carterkit/controldocs/stepper.md +131 -0
- carterkit/controldocs/sync.md +50 -0
- carterkit/controldocs/terms.md +40 -0
- carterkit/controldocs/text-input.md +126 -0
- carterkit/controldocs/theming.md +104 -0
- carterkit/controldocs/toggle.md +185 -0
- carterkit/controldocs/visibility.md +43 -0
- carterkit/controldocs/web-view.md +97 -0
- carterkit/e2ee.py +47 -0
- carterkit/grid.py +114 -0
- carterkit/infer.py +137 -0
- carterkit/theming.py +157 -0
- carterkit/tune.py +129 -0
- carterkit/validate.py +132 -0
- carterkit-0.1.0.dist-info/METADATA +98 -0
- carterkit-0.1.0.dist-info/RECORD +60 -0
- carterkit-0.1.0.dist-info/WHEEL +5 -0
- carterkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|