millwork 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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/testing.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Drive an app from a test: the real session, dispatcher, tasks and render, no socket.
|
|
2
|
+
|
|
3
|
+
``TestApp(app).open(path)`` yields a ``Page`` whose
|
|
4
|
+
actions are events through ``Session.dispatch``; what the server would have
|
|
5
|
+
sent to a tab is collected in ``messages``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
from collections.abc import AsyncIterator, Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from millwork.auth import Forbidden
|
|
19
|
+
from millwork.files import FileRef, _read_chunks
|
|
20
|
+
from millwork.nodes import Component
|
|
21
|
+
from millwork.protocol import Command, Event, Patch, ServerError, Stream, server_message
|
|
22
|
+
from millwork.session import Session
|
|
23
|
+
from millwork.ui.chat import Chat
|
|
24
|
+
from millwork.ui.files import FileUpload
|
|
25
|
+
from millwork.ui.form import Form
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from millwork.app import App
|
|
29
|
+
from millwork.protocol import Node
|
|
30
|
+
|
|
31
|
+
# How long to let a handler run between looks at whether it is waiting on the user.
|
|
32
|
+
_TICK = 0.01
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Found:
|
|
36
|
+
"""A node of the current tree, with the component that produced it."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, node: Node, component: Component | None) -> None:
|
|
39
|
+
self.id = node.id
|
|
40
|
+
self.type = node.type
|
|
41
|
+
self.props = node.props
|
|
42
|
+
self.children = node.children
|
|
43
|
+
self.component = component
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def rows(self) -> list[dict[str, Any]]:
|
|
47
|
+
"""A Table's rows as the tab receives them."""
|
|
48
|
+
rows = self.props.get("rows")
|
|
49
|
+
return list(rows) if isinstance(rows, list) else []
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return f"<{self.type} {self.id}>"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Page:
|
|
56
|
+
"""One open session, driven from the test and observed on its wire."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, session: Session[Any]) -> None:
|
|
59
|
+
self.session = session
|
|
60
|
+
self.messages: list[Any] = []
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------- reading
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def tree(self) -> dict[str, Node]:
|
|
66
|
+
return self.session.tree.nodes if self.session.tree is not None else {}
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def state(self) -> Any:
|
|
70
|
+
return self.session.state
|
|
71
|
+
|
|
72
|
+
def find_all(self, what: type[Component] | str) -> list[Found]:
|
|
73
|
+
"""Every node of a component type, or with a key (or a full id)."""
|
|
74
|
+
tree = self.session.tree
|
|
75
|
+
if tree is None:
|
|
76
|
+
return []
|
|
77
|
+
out = []
|
|
78
|
+
for node_id, node in tree.nodes.items():
|
|
79
|
+
if isinstance(what, str):
|
|
80
|
+
hit = node_id == what or node_id.rsplit("/", 1)[-1] == what
|
|
81
|
+
else:
|
|
82
|
+
hit = node.type == what.type
|
|
83
|
+
if hit:
|
|
84
|
+
out.append(Found(node, tree.components.get(node_id)))
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
def find(self, what: type[Component] | str) -> Found:
|
|
88
|
+
found = self.find_all(what)
|
|
89
|
+
if not found:
|
|
90
|
+
name = what if isinstance(what, str) else what.__name__
|
|
91
|
+
raise LookupError(f"no {name} on the page")
|
|
92
|
+
return found[0]
|
|
93
|
+
|
|
94
|
+
def text(self) -> str:
|
|
95
|
+
"""Every string prop of the tree, for a quick "is it there" check."""
|
|
96
|
+
parts: list[str] = []
|
|
97
|
+
for node in self.tree.values():
|
|
98
|
+
for value in node.props.values():
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
parts.append(value)
|
|
101
|
+
return "\n".join(parts)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def patches(self) -> list[Patch]:
|
|
105
|
+
return [m for m in self.messages if isinstance(m, Patch)]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def last_patch(self) -> Patch | None:
|
|
109
|
+
patches = self.patches
|
|
110
|
+
return patches[-1] if patches else None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def commands(self) -> list[Command]:
|
|
114
|
+
return [m for m in self.messages if isinstance(m, Command)]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def toasts(self) -> list[str]:
|
|
118
|
+
return [str(c.args.get("message", "")) for c in self.commands if c.name == "toast"]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def stream(self) -> list[dict[str, Any]]:
|
|
122
|
+
return [m.chunk for m in self.messages if isinstance(m, Stream)]
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def errors(self) -> list[str]:
|
|
126
|
+
return [m.message for m in self.messages if isinstance(m, ServerError)]
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------- acting
|
|
129
|
+
|
|
130
|
+
async def event(self, node: Found | str, name: str, payload: Any = None) -> None:
|
|
131
|
+
"""One event through the real dispatcher, then everything it set off."""
|
|
132
|
+
node_id = node.id if isinstance(node, Found) else self.find(node).id
|
|
133
|
+
self.session.spawn(
|
|
134
|
+
self.session.dispatch(Event(node=node_id, name=name, payload=payload)), "test event"
|
|
135
|
+
)
|
|
136
|
+
await self.settle()
|
|
137
|
+
|
|
138
|
+
async def click(self, node: Found | str) -> None:
|
|
139
|
+
await self.event(node, "click")
|
|
140
|
+
|
|
141
|
+
async def change(self, node: Found | str, value: Any) -> None:
|
|
142
|
+
await self.event(node, "change", value)
|
|
143
|
+
|
|
144
|
+
async def submit(self, form: Found | str, draft: Any) -> None:
|
|
145
|
+
"""Submit a draft (a dict or a model instance) as the tab would."""
|
|
146
|
+
if hasattr(draft, "model_dump"):
|
|
147
|
+
draft = draft.model_dump(mode="json")
|
|
148
|
+
await self.event(form, "submit", draft)
|
|
149
|
+
|
|
150
|
+
async def select(self, table: Found | str, keys: list[Any]) -> None:
|
|
151
|
+
await self.event(table, "select", keys)
|
|
152
|
+
|
|
153
|
+
async def action(self, node: Found | str, action: str, key: Any = None) -> None:
|
|
154
|
+
"""A row action on a Table (``[action, key]``) or a card action (``action``)."""
|
|
155
|
+
await self.event(node, "action", [action, key] if key is not None else action)
|
|
156
|
+
|
|
157
|
+
def _store(self, node_id: str, path: Path | str, accept: list[str] | None) -> FileRef:
|
|
158
|
+
p = Path(path)
|
|
159
|
+
mime = mimetypes.guess_type(p.name)[0]
|
|
160
|
+
files = self.session.app.files
|
|
161
|
+
return files.store(
|
|
162
|
+
self.session,
|
|
163
|
+
p.name,
|
|
164
|
+
_read_chunks(p),
|
|
165
|
+
mime=mime,
|
|
166
|
+
max_bytes=files.max_session_bytes,
|
|
167
|
+
accept=accept,
|
|
168
|
+
node=node_id,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def upload(
|
|
172
|
+
self, node: Found | str, path: Path | str, *, field: str | None = None
|
|
173
|
+
) -> FileRef:
|
|
174
|
+
"""Put a file into the session for a node, as the upload route would.
|
|
175
|
+
|
|
176
|
+
For a ``FileUpload`` the ``upload`` event follows. For a ``Form`` the
|
|
177
|
+
file is stored for the form and returned, to go into a draft at
|
|
178
|
+
``field``'s pointer; the submit then resolves it.
|
|
179
|
+
"""
|
|
180
|
+
found = node if isinstance(node, Found) else self.find(node)
|
|
181
|
+
component = found.component
|
|
182
|
+
accept: list[str] | None = None
|
|
183
|
+
if isinstance(component, FileUpload):
|
|
184
|
+
accept = component.accept
|
|
185
|
+
elif isinstance(component, Form):
|
|
186
|
+
if field is None:
|
|
187
|
+
raise ValueError("uploading to a Form takes field=, the file field's pointer")
|
|
188
|
+
elif not isinstance(component, Chat):
|
|
189
|
+
raise TypeError(f"{found.type} takes no uploads")
|
|
190
|
+
ref = self._store(found.id, path, accept)
|
|
191
|
+
if isinstance(component, FileUpload):
|
|
192
|
+
await self.event(found, "upload", [ref.model_dump(mode="json")])
|
|
193
|
+
return ref
|
|
194
|
+
|
|
195
|
+
async def send(
|
|
196
|
+
self,
|
|
197
|
+
chat: Found | str,
|
|
198
|
+
text: str,
|
|
199
|
+
*,
|
|
200
|
+
files: Iterable[Path | str] = (),
|
|
201
|
+
command: str | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""A chat turn: the message event with text, attachments and a command."""
|
|
204
|
+
found = chat if isinstance(chat, Found) else self.find(chat)
|
|
205
|
+
refs = [self._store(found.id, p, None) for p in files]
|
|
206
|
+
payload = {
|
|
207
|
+
"text": text,
|
|
208
|
+
"files": [r.model_dump(mode="json") for r in refs],
|
|
209
|
+
"command": command,
|
|
210
|
+
}
|
|
211
|
+
await self.event(found, "message", payload)
|
|
212
|
+
|
|
213
|
+
async def stop(self, chat: Found | str) -> None:
|
|
214
|
+
await self.event(chat, "stop")
|
|
215
|
+
|
|
216
|
+
async def answer(self, payload: Any = None) -> None:
|
|
217
|
+
"""Answer the open ``ask``: ``True``/``False`` press OK or Cancel, a dict
|
|
218
|
+
or model submits the form inside, ``None`` closes the dialog."""
|
|
219
|
+
asks = [a for a in self.session._asks.values() if not a.future.done()]
|
|
220
|
+
if not asks:
|
|
221
|
+
raise LookupError("nothing is being asked")
|
|
222
|
+
ask = asks[-1]
|
|
223
|
+
prefix = self.session._ask_root(ask.request)
|
|
224
|
+
inside = [n for n in self.tree.values() if n.id == prefix or n.id.startswith(prefix + "/")]
|
|
225
|
+
if payload is True or payload is False:
|
|
226
|
+
label = "ok" if payload else "cancel"
|
|
227
|
+
button = next((n for n in inside if n.id.rsplit("/", 1)[-1] == label), None)
|
|
228
|
+
if button is None:
|
|
229
|
+
raise LookupError(f"the ask has no {label} button")
|
|
230
|
+
await self.event(button.id, "click")
|
|
231
|
+
return
|
|
232
|
+
if payload is None:
|
|
233
|
+
await self.event(prefix, "close")
|
|
234
|
+
return
|
|
235
|
+
form = next((n for n in inside if n.type == "Form"), None)
|
|
236
|
+
if form is None:
|
|
237
|
+
raise LookupError("the ask has no form to answer")
|
|
238
|
+
await self.submit(form.id, payload)
|
|
239
|
+
|
|
240
|
+
async def navigate(self, path: str) -> None:
|
|
241
|
+
await self.session.navigate_to(path)
|
|
242
|
+
await self.settle()
|
|
243
|
+
|
|
244
|
+
async def settle(self) -> None:
|
|
245
|
+
"""Wait until the session has nothing left to do, or is waiting on the user.
|
|
246
|
+
|
|
247
|
+
Running tasks and spawned work are awaited; a handler blocked on an
|
|
248
|
+
open ``ask`` is left there for ``answer``. The coalesced render runs
|
|
249
|
+
at once rather than after its timer.
|
|
250
|
+
"""
|
|
251
|
+
s = self.session
|
|
252
|
+
for _ in range(10_000):
|
|
253
|
+
await asyncio.sleep(0)
|
|
254
|
+
await asyncio.sleep(0)
|
|
255
|
+
asking = any(not a.future.done() for a in s._asks.values())
|
|
256
|
+
# Delayed tasks (``s.later``) are not waited for; a running task or
|
|
257
|
+
# handler that is blocked on an open ask is left for ``answer``.
|
|
258
|
+
running = [t.future for t in s.tasks if t.status == "running"]
|
|
259
|
+
if running:
|
|
260
|
+
_, still = await asyncio.wait(running, timeout=_TICK)
|
|
261
|
+
if not (still and asking):
|
|
262
|
+
continue
|
|
263
|
+
spawned = [t for t in s._spawned if not t.done()]
|
|
264
|
+
if spawned:
|
|
265
|
+
_, still = await asyncio.wait(spawned, timeout=_TICK)
|
|
266
|
+
if not (still and asking):
|
|
267
|
+
continue
|
|
268
|
+
if s._render_scheduled and s._render_handle is not None:
|
|
269
|
+
s._render_handle.cancel()
|
|
270
|
+
s._render_scheduled = False
|
|
271
|
+
s._render_handle = None
|
|
272
|
+
await s._render()
|
|
273
|
+
continue
|
|
274
|
+
return
|
|
275
|
+
raise RuntimeError("the session did not settle")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TestApp:
|
|
279
|
+
"""Build the app once; open pages as sessions without a socket."""
|
|
280
|
+
|
|
281
|
+
__test__ = False # not a pytest collectable, despite the name
|
|
282
|
+
|
|
283
|
+
def __init__(self, app: App) -> None:
|
|
284
|
+
self.app = app
|
|
285
|
+
self._started = False
|
|
286
|
+
|
|
287
|
+
async def startup(self) -> None:
|
|
288
|
+
"""Run the app's startup hooks, once, before the first page opens."""
|
|
289
|
+
if self._started:
|
|
290
|
+
return
|
|
291
|
+
self._started = True
|
|
292
|
+
for fn in self.app._startup:
|
|
293
|
+
result = fn()
|
|
294
|
+
if asyncio.iscoroutine(result):
|
|
295
|
+
await result
|
|
296
|
+
|
|
297
|
+
async def shutdown(self) -> None:
|
|
298
|
+
for fn in self.app._shutdown:
|
|
299
|
+
result = fn()
|
|
300
|
+
if asyncio.iscoroutine(result):
|
|
301
|
+
await result
|
|
302
|
+
self._started = False
|
|
303
|
+
|
|
304
|
+
@contextlib.asynccontextmanager
|
|
305
|
+
async def open(
|
|
306
|
+
self, path: str = "/", *, user: Any = None, state: Any = None
|
|
307
|
+
) -> AsyncIterator[Page]:
|
|
308
|
+
"""Open a page as a new session: connect (unless ``state`` is given), mount, yield."""
|
|
309
|
+
await self.startup()
|
|
310
|
+
resolved = self.app.resolve(path)
|
|
311
|
+
if resolved is None:
|
|
312
|
+
raise LookupError(f"no page at {path}")
|
|
313
|
+
page_def, params = resolved
|
|
314
|
+
if self.app.auth is not None and not page_def.public and user is None:
|
|
315
|
+
raise Forbidden("Sign in to continue.")
|
|
316
|
+
session: Session[Any] = Session(self.app, page_def, params, path)
|
|
317
|
+
session.user = user
|
|
318
|
+
self.app.sessions[session.id] = session
|
|
319
|
+
page = Page(session)
|
|
320
|
+
|
|
321
|
+
async def send(text: str) -> None:
|
|
322
|
+
page.messages.append(server_message.validate_python(json.loads(text)["msg"]))
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
if state is not None:
|
|
326
|
+
session.state = state
|
|
327
|
+
else:
|
|
328
|
+
await session.connect()
|
|
329
|
+
await session.attach(send, 0)
|
|
330
|
+
await page.settle()
|
|
331
|
+
yield page
|
|
332
|
+
finally:
|
|
333
|
+
await session.close()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
__all__ = ["Found", "Page", "TestApp"]
|
millwork/theme.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""The theme: one JSON file per app, rendered into the HTML shell by the server.
|
|
2
|
+
|
|
3
|
+
Tokens use the shadcn vocabulary in CSS colour syntax,
|
|
4
|
+
plus ``success``, ``warning`` and ``info`` for the catalogue's tones. A
|
|
5
|
+
token left out keeps the bundle's default. Fonts are self-hosted from
|
|
6
|
+
``public/``; a font service URL is accepted and logged once. The few
|
|
7
|
+
interface strings the bundle needs are English here; an app in another
|
|
8
|
+
language sets them in its theme file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("millwork.theme")
|
|
22
|
+
|
|
23
|
+
Mode = Literal["light", "dark", "system"]
|
|
24
|
+
Density = Literal["comfortable", "compact"]
|
|
25
|
+
Scrollbars = Literal["auto", "thin"]
|
|
26
|
+
|
|
27
|
+
COLOUR_TOKENS = (
|
|
28
|
+
"background",
|
|
29
|
+
"foreground",
|
|
30
|
+
"card",
|
|
31
|
+
"card-foreground",
|
|
32
|
+
"popover",
|
|
33
|
+
"popover-foreground",
|
|
34
|
+
"primary",
|
|
35
|
+
"primary-foreground",
|
|
36
|
+
"secondary",
|
|
37
|
+
"secondary-foreground",
|
|
38
|
+
"muted",
|
|
39
|
+
"muted-foreground",
|
|
40
|
+
"accent",
|
|
41
|
+
"accent-foreground",
|
|
42
|
+
"destructive",
|
|
43
|
+
"destructive-foreground",
|
|
44
|
+
"success",
|
|
45
|
+
"success-foreground",
|
|
46
|
+
"warning",
|
|
47
|
+
"warning-foreground",
|
|
48
|
+
"info",
|
|
49
|
+
"info-foreground",
|
|
50
|
+
"border",
|
|
51
|
+
"input",
|
|
52
|
+
"ring",
|
|
53
|
+
"chart-1",
|
|
54
|
+
"chart-2",
|
|
55
|
+
"chart-3",
|
|
56
|
+
"chart-4",
|
|
57
|
+
"chart-5",
|
|
58
|
+
"sidebar",
|
|
59
|
+
"sidebar-foreground",
|
|
60
|
+
"sidebar-primary",
|
|
61
|
+
"sidebar-primary-foreground",
|
|
62
|
+
"sidebar-accent",
|
|
63
|
+
"sidebar-accent-foreground",
|
|
64
|
+
"sidebar-border",
|
|
65
|
+
"sidebar-ring",
|
|
66
|
+
)
|
|
67
|
+
OTHER_TOKENS = ("radius", "font-sans", "font-mono", "font-heading")
|
|
68
|
+
TOKENS = frozenset(COLOUR_TOKENS + OTHER_TOKENS)
|
|
69
|
+
|
|
70
|
+
# A token value is one CSS value: colours, lengths, font stacks. Nothing that
|
|
71
|
+
# could close the declaration or the style element, and no url().
|
|
72
|
+
_VALUE = re.compile(r"^[A-Za-z0-9 #%().,'\"/+*_-]+$")
|
|
73
|
+
_FORBIDDEN = re.compile(r"url\s*\(|expression\s*\(|@|\\|</", re.IGNORECASE)
|
|
74
|
+
_PATH = re.compile(r"^/(?!/)[A-Za-z0-9._~/-]+$")
|
|
75
|
+
|
|
76
|
+
STRINGS: dict[str, str] = {
|
|
77
|
+
"loading": "Loading…",
|
|
78
|
+
"reconnecting": "Reconnecting…",
|
|
79
|
+
"session_ended": "This session has ended.",
|
|
80
|
+
"reload": "Reload",
|
|
81
|
+
"other_tab": "This page was opened in another tab.",
|
|
82
|
+
"copied": "Copied",
|
|
83
|
+
"working": "Working",
|
|
84
|
+
"thinking": "Thinking",
|
|
85
|
+
"thought": "Thought",
|
|
86
|
+
"steps": "{n} steps",
|
|
87
|
+
"one_step": "1 step",
|
|
88
|
+
"send": "Send",
|
|
89
|
+
"stop": "Stop",
|
|
90
|
+
"attach": "Attach",
|
|
91
|
+
"remove": "Remove",
|
|
92
|
+
"copy": "Copy",
|
|
93
|
+
"you": "You",
|
|
94
|
+
"assistant": "Assistant",
|
|
95
|
+
"messages": "Messages",
|
|
96
|
+
"scroll_to_end": "Scroll to end",
|
|
97
|
+
"good_response": "Good response",
|
|
98
|
+
"bad_response": "Bad response",
|
|
99
|
+
"feedback_prompt": "What could be better?",
|
|
100
|
+
"skip": "Skip",
|
|
101
|
+
"regenerate": "Regenerate",
|
|
102
|
+
"edit": "Edit",
|
|
103
|
+
"save": "Save",
|
|
104
|
+
"cancel": "Cancel",
|
|
105
|
+
"actions": "Actions",
|
|
106
|
+
"download": "Download",
|
|
107
|
+
"add": "Add",
|
|
108
|
+
"choose": "Choose",
|
|
109
|
+
"search": "Search",
|
|
110
|
+
"no_results": "No results",
|
|
111
|
+
"open": "Open",
|
|
112
|
+
"download_failed": "The download failed.",
|
|
113
|
+
"filter": "Filter",
|
|
114
|
+
"all": "All",
|
|
115
|
+
"min": "Min",
|
|
116
|
+
"max": "Max",
|
|
117
|
+
"clear": "Clear",
|
|
118
|
+
"select_all": "Select all",
|
|
119
|
+
"select_row": "Select row",
|
|
120
|
+
"dismiss": "Dismiss",
|
|
121
|
+
"previous": "Previous",
|
|
122
|
+
"next": "Next",
|
|
123
|
+
"wait_for_reply": "Wait for the current reply to finish.",
|
|
124
|
+
"message_too_long": "The message is too long.",
|
|
125
|
+
"reply_failed": "The reply failed.",
|
|
126
|
+
"something_went_wrong": "Something went wrong.",
|
|
127
|
+
"no_page": "No page at {path}.",
|
|
128
|
+
"not_found_title": "Page not found",
|
|
129
|
+
"not_found_text": "There is nothing at {path}.",
|
|
130
|
+
"forbidden_title": "No access",
|
|
131
|
+
"forbidden_text": "You may not open this page.",
|
|
132
|
+
"home": "Home",
|
|
133
|
+
"try_again": "Try again in a moment.",
|
|
134
|
+
"page_failed": "The page failed to start.",
|
|
135
|
+
"render_failed": "The page failed to render.",
|
|
136
|
+
"too_many_sessions": "Too many open sessions.",
|
|
137
|
+
"too_many_actions": "Too many actions at once; wait a moment.",
|
|
138
|
+
"signed_out": "Signed out.",
|
|
139
|
+
"made_room": "This session made room for a newer one.",
|
|
140
|
+
"sign_in": "Sign in",
|
|
141
|
+
"sign_in_hint": "Sign in to continue.",
|
|
142
|
+
"sign_in_failed": "Sign-in failed",
|
|
143
|
+
"username": "Username",
|
|
144
|
+
"password": "Password",
|
|
145
|
+
"continue_with": "Continue with {provider}",
|
|
146
|
+
"no_sign_in": "No sign-in method is configured.",
|
|
147
|
+
"proxy_sign_in": "Sign-in happens before this app, and this request carried no identity.",
|
|
148
|
+
"proxy_denied": "You are signed in, but this app does not admit you.",
|
|
149
|
+
"sign_out": "Sign out",
|
|
150
|
+
"theme": "Theme",
|
|
151
|
+
"light": "Light",
|
|
152
|
+
"dark": "Dark",
|
|
153
|
+
"system": "System",
|
|
154
|
+
"menu": "Menu",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def under(base: str, path: str | None) -> str | None:
|
|
159
|
+
"""A same-origin path as the browser must ask for it: under the app's prefix
|
|
160
|
+
when the app runs under one. Anything that is not a path is left alone."""
|
|
161
|
+
if base and path and path.startswith("/"):
|
|
162
|
+
return base + path
|
|
163
|
+
return path
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_path(value: str, what: str) -> str:
|
|
167
|
+
if not _PATH.match(value):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"{what} must be a same-origin path such as /public/logo.svg, got {value!r}"
|
|
170
|
+
)
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Font(BaseModel):
|
|
175
|
+
"""One ``@font-face``, or one stylesheet link when ``url`` is a font service."""
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(extra="forbid")
|
|
178
|
+
|
|
179
|
+
family: str
|
|
180
|
+
src: str | None = None
|
|
181
|
+
url: str | None = None
|
|
182
|
+
weights: str = "100 900"
|
|
183
|
+
style: Literal["normal", "italic"] = "normal"
|
|
184
|
+
|
|
185
|
+
@model_validator(mode="after")
|
|
186
|
+
def _one_source(self) -> Font:
|
|
187
|
+
if (self.src is None) == (self.url is None):
|
|
188
|
+
raise ValueError("a font takes src (a file under public/) or url (a font service)")
|
|
189
|
+
if self.src is not None:
|
|
190
|
+
_check_path(self.src, "font src")
|
|
191
|
+
if self.url is not None and not self.url.startswith("https://"):
|
|
192
|
+
raise ValueError("a font url must be https")
|
|
193
|
+
if not re.match(r"^[A-Za-z0-9 _-]+$", self.family):
|
|
194
|
+
raise ValueError(f"font family name {self.family!r} has characters CSS cannot take")
|
|
195
|
+
if not re.match(r"^[0-9 ]+$", self.weights):
|
|
196
|
+
raise ValueError("font weights is a number or a range such as '100 900'")
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def css(self, base: str = "") -> str:
|
|
200
|
+
assert self.src is not None
|
|
201
|
+
fmt = {"woff2": "woff2", "woff": "woff", "ttf": "truetype", "otf": "opentype"}.get(
|
|
202
|
+
self.src.rsplit(".", 1)[-1].lower(), ""
|
|
203
|
+
)
|
|
204
|
+
src = f"url({under(base, self.src)})" + (f" format('{fmt}')" if fmt else "")
|
|
205
|
+
return (
|
|
206
|
+
f"@font-face{{font-family:'{self.family}';src:{src};"
|
|
207
|
+
f"font-weight:{self.weights};font-style:{self.style};font-display:swap}}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Logo(BaseModel):
|
|
212
|
+
model_config = ConfigDict(extra="forbid")
|
|
213
|
+
|
|
214
|
+
light: str
|
|
215
|
+
dark: str | None = None
|
|
216
|
+
|
|
217
|
+
_light = field_validator("light")(staticmethod(lambda v: _check_path(v, "logo")))
|
|
218
|
+
_dark = field_validator("dark")(staticmethod(lambda v: v and _check_path(v, "logo")))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Theme(BaseModel):
|
|
222
|
+
model_config = ConfigDict(extra="forbid")
|
|
223
|
+
|
|
224
|
+
mode: Mode = "system"
|
|
225
|
+
fonts: list[Font] = Field(default_factory=list)
|
|
226
|
+
tokens: dict[Literal["light", "dark"], dict[str, str]] = Field(default_factory=dict)
|
|
227
|
+
logo: Logo | None = None
|
|
228
|
+
favicon: str | None = None
|
|
229
|
+
login_image: str | None = None
|
|
230
|
+
scrollbars: Scrollbars = "thin"
|
|
231
|
+
density: Density = "comfortable"
|
|
232
|
+
locale: str = "en"
|
|
233
|
+
strings: dict[str, str] = Field(default_factory=dict)
|
|
234
|
+
|
|
235
|
+
_favicon = field_validator("favicon")(staticmethod(lambda v: v and _check_path(v, "favicon")))
|
|
236
|
+
_image = field_validator("login_image")(
|
|
237
|
+
staticmethod(lambda v: v and _check_path(v, "login_image"))
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@field_validator("logo", mode="before")
|
|
241
|
+
@classmethod
|
|
242
|
+
def _logo_shorthand(cls, v: Any) -> Any:
|
|
243
|
+
return {"light": v} if isinstance(v, str) else v
|
|
244
|
+
|
|
245
|
+
@field_validator("tokens")
|
|
246
|
+
@classmethod
|
|
247
|
+
def _known_tokens(cls, tokens: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
|
|
248
|
+
for scheme, values in tokens.items():
|
|
249
|
+
for name, value in values.items():
|
|
250
|
+
if name not in TOKENS:
|
|
251
|
+
raise ValueError(f"unknown theme token {name!r} in {scheme}")
|
|
252
|
+
if not _VALUE.match(value) or _FORBIDDEN.search(value):
|
|
253
|
+
raise ValueError(f"theme token {name} has a value CSS cannot take: {value!r}")
|
|
254
|
+
return tokens
|
|
255
|
+
|
|
256
|
+
@field_validator("locale")
|
|
257
|
+
@classmethod
|
|
258
|
+
def _locale(cls, v: str) -> str:
|
|
259
|
+
if not re.match(r"^[a-z]{2}(-[A-Za-z]{2,4})?$", v):
|
|
260
|
+
raise ValueError(f"locale must look like 'nl' or 'en-GB', got {v!r}")
|
|
261
|
+
return v
|
|
262
|
+
|
|
263
|
+
@field_validator("strings")
|
|
264
|
+
@classmethod
|
|
265
|
+
def _known_strings(cls, strings: dict[str, str]) -> dict[str, str]:
|
|
266
|
+
unknown = set(strings) - set(STRINGS)
|
|
267
|
+
if unknown:
|
|
268
|
+
raise ValueError(f"unknown interface strings: {sorted(unknown)}")
|
|
269
|
+
return strings
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def load(cls, path: Path) -> Theme:
|
|
273
|
+
try:
|
|
274
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
275
|
+
except json.JSONDecodeError as exc:
|
|
276
|
+
raise ValueError(f"{path}: not valid JSON ({exc})") from exc
|
|
277
|
+
return cls.model_validate(data)
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------- rendering
|
|
280
|
+
|
|
281
|
+
def css(self, base: str = "") -> str:
|
|
282
|
+
"""The style element's text: font faces, then the two token blocks. ``base``
|
|
283
|
+
is the path prefix the app runs under, for the font files."""
|
|
284
|
+
parts = [f.css(base) for f in self.fonts if f.src is not None]
|
|
285
|
+
light = self.tokens.get("light", {})
|
|
286
|
+
dark = self.tokens.get("dark", {})
|
|
287
|
+
for selector, values in ((":root", light), (".dark", dark)):
|
|
288
|
+
if values:
|
|
289
|
+
body = ";".join(f"--{name}:{value}" for name, value in values.items())
|
|
290
|
+
parts.append(f"{selector}{{{body}}}")
|
|
291
|
+
return "".join(parts)
|
|
292
|
+
|
|
293
|
+
def head(self) -> str:
|
|
294
|
+
"""Stylesheet links for font-service fonts; empty for a self-hosted theme."""
|
|
295
|
+
links = []
|
|
296
|
+
for f in self.fonts:
|
|
297
|
+
if f.url is not None:
|
|
298
|
+
log.warning("theme font %s loads from %s on every page view", f.family, f.url)
|
|
299
|
+
safe = f.url.replace('"', "%22")
|
|
300
|
+
links.append(f'<link rel="stylesheet" href="{safe}">')
|
|
301
|
+
return "".join(links)
|
|
302
|
+
|
|
303
|
+
def all_strings(self) -> dict[str, str]:
|
|
304
|
+
return {**STRINGS, **self.strings}
|
|
305
|
+
|
|
306
|
+
def text(self, key: str, **vars: Any) -> str:
|
|
307
|
+
"""A message the server sends to a person, in the theme's words."""
|
|
308
|
+
s = self.all_strings()[key]
|
|
309
|
+
for name, value in vars.items():
|
|
310
|
+
s = s.replace("{" + name + "}", str(value))
|
|
311
|
+
return s
|
|
312
|
+
|
|
313
|
+
def boot(self) -> dict[str, Any]:
|
|
314
|
+
"""What the bundle reads at start. Image paths are the app's own: the
|
|
315
|
+
bundle puts every same-origin path under the prefix when it renders it."""
|
|
316
|
+
return {
|
|
317
|
+
"mode": self.mode,
|
|
318
|
+
"logo": self.logo.model_dump() if self.logo else None,
|
|
319
|
+
"login_image": self.login_image,
|
|
320
|
+
"density": self.density,
|
|
321
|
+
"scrollbars": self.scrollbars,
|
|
322
|
+
"locale": self.locale,
|
|
323
|
+
"strings": self.all_strings(),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def find_theme(public_dir: Path | None) -> Theme:
|
|
328
|
+
"""``public/theme.json`` when the app has one, the default theme otherwise."""
|
|
329
|
+
if public_dir is not None:
|
|
330
|
+
candidate = public_dir / "theme.json"
|
|
331
|
+
if candidate.is_file():
|
|
332
|
+
theme = Theme.load(candidate)
|
|
333
|
+
log.info("theme loaded from %s", candidate)
|
|
334
|
+
return theme
|
|
335
|
+
return Theme()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = ["Font", "Logo", "STRINGS", "TOKENS", "Theme", "find_theme"]
|