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.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. 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"]