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
@@ -0,0 +1,151 @@
1
+ ---
2
+ title: State and handlers
3
+ order: 1
4
+ ---
5
+
6
+ # State and handlers
7
+
8
+ A page is a function of the session's state. A handler changes that state;
9
+ the page renders again; the browser gets the difference. There is nothing
10
+ reactive to subscribe to and nothing to invalidate: whatever a page shows
11
+ comes from `s.state`, and the only place where things change is a handler.
12
+
13
+ ## The loop
14
+
15
+ `s.state` is an instance of the model named on the page, or on the app for
16
+ every page. A handler is any callable bound to an `on_*` prop. It receives
17
+ the session and the event's payload: the new value for `on_change`, the
18
+ validated model for a form's `on_submit`, nothing of use for `on_click`.
19
+ It may be sync or async. When it returns, the page renders once, under a
20
+ lock, so a render sees state as a handler left it.
21
+
22
+ ```python live
23
+ from pydantic import BaseModel
24
+
25
+
26
+ class Counter(BaseModel):
27
+ n: int = 0
28
+ note: str = ""
29
+
30
+
31
+ def bump(s, _):
32
+ state(Counter).n += 1
33
+
34
+
35
+ async def remember(s, text):
36
+ state(Counter).note = text
37
+
38
+
39
+ def example(s):
40
+ st = state(Counter)
41
+ return ui.Stack(
42
+ ui.Row(
43
+ ui.Button("Count", on_click=bump),
44
+ ui.Text(f"{st.n} so far", variant="muted"),
45
+ gap=3,
46
+ ),
47
+ ui.Input(value=st.note, placeholder="Type, then look right", on_change=remember),
48
+ ui.Text(f"Python holds {st.note!r}", variant="muted"),
49
+ gap=3,
50
+ )
51
+ ```
52
+
53
+ A handler that raises shows "Something went wrong." as a toast and the log
54
+ has the traceback; the page stays as it was. A handler that needs to ask
55
+ first uses `s.confirm` or `s.ask`, in the Feedback chapter.
56
+
57
+ ## Keys
58
+
59
+ Every component takes a `key`. A key is a component's identity across
60
+ renders: a keyed node keeps its browser-side state (what is typed, which
61
+ tab is open, where a list is scrolled) when the tree around it changes,
62
+ and a list of keyed rows that is reordered moves the rows instead of
63
+ redrawing them. Siblings without keys are told apart by position, which is
64
+ right for a fixed layout and wrong for a list built from data.
65
+
66
+ ```python live
67
+ from pydantic import BaseModel
68
+
69
+
70
+ class Todo(BaseModel):
71
+ items: list[str] = ["Read the manual", "Build a page", "Ship it"]
72
+
73
+
74
+ def drop(s, item):
75
+ state(Todo).items.remove(item)
76
+
77
+
78
+ def example(s):
79
+ st = state(Todo)
80
+ return ui.Stack(
81
+ *(
82
+ ui.Row(
83
+ ui.Text(item),
84
+ ui.Button(icon="x", size="sm", variant="ghost", on_click=lambda s, _, i=item: drop(s, i)),
85
+ justify="between",
86
+ key=item,
87
+ )
88
+ for item in st.items
89
+ ),
90
+ ui.Button(
91
+ "Put them back",
92
+ size="sm",
93
+ variant="outline",
94
+ on_click=lambda s, _: setattr(st, "items", Todo().items),
95
+ ),
96
+ gap=2,
97
+ )
98
+ ```
99
+
100
+ A key is also how `s.reset(key)` returns a browser-owned control to its
101
+ `default`, and how `ui.Button(form=key)` finds its form. `hidden=True`
102
+ keeps a component in the tree without showing it, so its browser-side
103
+ state survives; `cls` adds Tailwind classes for a spacing or sizing tweak.
104
+
105
+ ## Who owns a value
106
+
107
+ A control shows either what Python says (`value=`, with every change
108
+ reported through `on_change`) or what the browser holds (`default=`, with
109
+ changes reported but not required), one or the other. The Inputs chapter shows
110
+ the two side by side. Inside a `ui.Form` every control is browser-owned
111
+ draft state until the form is submitted.
112
+
113
+ ## Reaching the session from anywhere
114
+
115
+ Handlers get the session as their first argument. Code further down, a
116
+ helper or a library that wants to show a toast, calls `mw.session()`: it
117
+ returns the session of the handler or task that is running, on the loop
118
+ or in the thread of a sync task.
119
+
120
+ ```python live
121
+ import millwork as mw
122
+
123
+
124
+ def notify(what: str) -> None:
125
+ mw.session().toast(what, tone="success")
126
+
127
+
128
+ def example(s):
129
+ return ui.Button("Call a helper", variant="outline", on_click=lambda s, _: notify("The helper found the session."))
130
+ ```
131
+
132
+ `s.refresh()` asks for a render from code that is not a handler, such as
133
+ work spawned on the loop that changes state on its own. Many calls in one
134
+ tick are one render.
135
+
136
+ ## What the framework notices
137
+
138
+ Some mistakes the framework can see, and says so: once per session, as a
139
+ log line with the page's path, and in debug mode as a toast as well.
140
+
141
+ - **A slow page function**, over 20 ms. The page function builds the tree;
142
+ I/O belongs in `connect` or a task.
143
+ - **A list without keys**: four or more siblings of one type without a
144
+ key whose number changed between renders.
145
+ - **A handler that changed nothing**: no state, no command, no task.
146
+ Usually a forgotten `await`.
147
+ - **A sync task that changed state from its thread.** A task reports
148
+ through `progress()` and returns a result; the handler that awaits it
149
+ puts the result in state.
150
+
151
+ The Running chapter says how to turn debug mode on.
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: Testing
3
+ order: 4
4
+ ---
5
+
6
+ # Testing
7
+
8
+ A page is a function of state, handlers change state, and the wire is a
9
+ list of messages. So a test runs the real session, dispatcher, tasks and
10
+ render with no socket and no browser: `millwork.testing.TestApp` opens a
11
+ page, a test clicks and types through the real event path, and reads the
12
+ state and the messages a tab would have received.
13
+
14
+ ## A test
15
+
16
+ ```python
17
+ from millwork import ui
18
+ from millwork.testing import TestApp
19
+
20
+
21
+ async def test_a_click_counts(app):
22
+ async with TestApp(app).open("/") as page:
23
+ assert page.find("count").props["content"] == "count 0"
24
+ await page.click("more")
25
+ assert page.state.n == 1
26
+ assert page.find("count").props["content"] == "count 1"
27
+ assert page.last_patch is not None
28
+ ```
29
+
30
+ Tests are `async def`, run by pytest-asyncio in auto mode. `open(path)` runs the page's `connect` and the first render and
31
+ yields a `Page`; leaving the block closes the session, `close` hook
32
+ included. `open(path, user=User(...))` signs a person in; without one a
33
+ gated page raises `Forbidden`, which is a test of its own. `open(path,
34
+ state=State(...))` starts from a prepared state and skips `connect`.
35
+
36
+ ## Finding things
37
+
38
+ `page.find(ui.Table)` is the first node of a type, `page.find("more")` the
39
+ node with that key, `find_all` every one of them. A found node has `id`,
40
+ `type`, `props` as they would go to the tab, `children` and, on a table,
41
+ `rows`. `page.text()` is every string on the page, for a quick "is it
42
+ there". `page.state` is the live state and `page.session` the session
43
+ itself.
44
+
45
+ ## Acting
46
+
47
+ Each action is an event through the real dispatcher, followed by the real
48
+ render: `click(node)`, `change(node, value)`, `submit(form, draft)` with a
49
+ dict or a model instance, `select(table, keys)`, `action(node, id, key)`
50
+ for a row or menu action, `upload(node, path, field=)` which runs every
51
+ check an upload gets and returns the `FileRef`, `send(chat, text,
52
+ files=)`, `stop(chat)`, `navigate(path)`, and `event(node, name, payload)`
53
+ for anything else. A draft that fails validation produces the same
54
+ `form_issues` command the tab would get, at the same pointers.
55
+
56
+ ```python
57
+ async def test_the_form_rejects_a_short_name(app):
58
+ async with TestApp(app).open("/") as page:
59
+ form = page.find(ui.Form)
60
+ await page.submit(form, {"project_name": "x"})
61
+ assert page.commands[-1].name == "form_issues"
62
+ assert page.commands[-1].args["issues"][0]["pointer"] == "/project_name"
63
+ assert page.state.settings is None
64
+ ```
65
+
66
+ ## Questions and tasks
67
+
68
+ A handler that calls `s.confirm` or `s.ask` stops and waits; the test sees
69
+ the dialog on the page and answers it with `page.answer(value)`, `False`
70
+ or `None` for a dismissal. A task started by a handler has run by the time
71
+ the action returns: `page.session.tasks[-1]` has its `ok`, `result` and
72
+ `progress`. A chat turn's chunks are in `page.stream`, in order.
73
+
74
+ ```python
75
+ async def test_wiping_asks_first(app):
76
+ async with TestApp(app).open("/") as page:
77
+ await page.click("wipe")
78
+ assert page.find(ui.Dialog).props["title"] == "Wipe?"
79
+ await page.answer(False)
80
+ assert page.state.count == 0
81
+ await page.click("wipe")
82
+ await page.answer(True)
83
+ assert page.state.count == -1
84
+ ```
85
+
86
+ ## What the tab would have seen
87
+
88
+ `page.messages` is every message the server sent: `patches`, `last_patch`,
89
+ `commands` (toasts, downloads, navigations, form issues), `toasts` as
90
+ plain text, `stream` and `errors` are views on it. `settle()` waits until
91
+ the session has nothing left to do: running tasks and spawned work are
92
+ awaited, the coalesced render runs at once, and a handler waiting on an
93
+ open question is left for `answer`. Every action settles before it
94
+ returns, so a test reads the result on the next line.
95
+
96
+ ## Running one here
97
+
98
+ The manual can run a test on itself. The button below builds a small app,
99
+ opens it through `TestApp`, clicks, and reports what the assertions said.
100
+
101
+ ```python live
102
+ import millwork as mw
103
+ from pydantic import BaseModel
104
+ from millwork.testing import TestApp
105
+
106
+
107
+ class Count(BaseModel):
108
+ n: int = 0
109
+
110
+
111
+ def tiny_app() -> mw.App:
112
+ app = mw.App("tiny", state=Count, public=None)
113
+
114
+ @app.page("/")
115
+ def home(s):
116
+ return ui.Page(
117
+ ui.Text(f"count {s.state.n}", key="count"),
118
+ ui.Button("more", key="more", on_click=lambda s, _: setattr(s.state, "n", s.state.n + 1)),
119
+ )
120
+
121
+ return app
122
+
123
+
124
+ class Outcome(BaseModel):
125
+ text: str = "Not run yet."
126
+
127
+
128
+ async def run_test(s, _):
129
+ try:
130
+ async with TestApp(tiny_app()).open("/") as page:
131
+ assert page.find("count").props["content"] == "count 0"
132
+ await page.click("more")
133
+ assert page.state.n == 1
134
+ assert page.find("count").props["content"] == "count 1"
135
+ ops = [op.op for op in page.last_patch.ops]
136
+ state(Outcome).text = f"Passed. The click produced a patch with {ops}."
137
+ except AssertionError as exc:
138
+ state(Outcome).text = f"Failed: {exc}"
139
+
140
+
141
+ def example(s):
142
+ return ui.Row(
143
+ ui.Button("Run the test", icon="play", on_click=run_test),
144
+ ui.Text(state(Outcome).text, variant="muted"),
145
+ gap=4,
146
+ )
147
+ ```
@@ -0,0 +1,122 @@
1
+ ---
2
+ title: Theming
3
+ order: 3
4
+ ---
5
+
6
+ # Theming
7
+
8
+ An app's look is one JSON file: `public/theme.json`, found on its own, or
9
+ any path given as `App(theme=...)`. It holds the colour tokens for light
10
+ and dark, the fonts, the logo and a few options. The server renders it
11
+ into the page before the bundle loads, so there is no flash of the default
12
+ look. Without a file the app looks like stock shadcn.
13
+
14
+ ```json
15
+ {
16
+ "mode": "system",
17
+ "fonts": [{"family": "Inter", "src": "/public/fonts/InterVariable.woff2", "weights": "100 900"}],
18
+ "tokens": {
19
+ "light": {"primary": "#0a7ea4", "primary-foreground": "#ffffff", "radius": "0.5rem",
20
+ "font-sans": "'Inter', ui-sans-serif, system-ui, sans-serif"},
21
+ "dark": {"primary": "#2a9ac4"}
22
+ },
23
+ "logo": {"light": "/public/logo.svg", "dark": "/public/logo-dark.svg"},
24
+ "favicon": "/public/favicon.svg",
25
+ "login_image": "/public/login.jpg",
26
+ "density": "comfortable"
27
+ }
28
+ ```
29
+
30
+ ## Tokens
31
+
32
+ The tokens are shadcn's: `background`, `foreground`, `card`, `popover`,
33
+ `primary`, `secondary`, `muted`, `accent`, `destructive`, each with its
34
+ `-foreground`, then `border`, `input`, `ring`, `chart-1` to `chart-5`, the
35
+ `sidebar-*` group, `radius`, `font-sans`, `font-mono` and `font-heading`.
36
+ Three are millwork's own, with their `-foreground`: `success` and
37
+ `warning`, which the catalogue's `tone` vocabulary needs, and `info`, which
38
+ a custom component may use as `bg-info text-info-foreground`. A value is
39
+ one CSS value, hex, `oklch(...)`, a length or a font stack. A token left
40
+ out keeps the default, so a brand is four or five values. An unknown
41
+ token, or a value CSS could misuse, is refused when the app starts.
42
+
43
+ Light and dark are both always there: `mode` is `light`, `dark` or
44
+ `system`, and the user menu in the header lets a person choose, kept in
45
+ their browser. The tones below take their colours from this manual's
46
+ theme file, in whichever scheme you are reading.
47
+
48
+ ```python live
49
+ ui.Stack(
50
+ ui.Row(
51
+ ui.Badge("primary", tone="primary"),
52
+ ui.Badge("success", tone="success", icon="check"),
53
+ ui.Badge("warning", tone="warning"),
54
+ ui.Badge("destructive", tone="destructive"),
55
+ ui.Badge("accent", tone="accent"),
56
+ gap=2,
57
+ wrap=True,
58
+ ),
59
+ ui.Alert(title="Saved", description="A success alert takes the success tokens.", tone="success"),
60
+ gap=3,
61
+ )
62
+ ```
63
+
64
+ The five chart tokens are read in order by the charts. Pick them as a set
65
+ that stays apart for colour-blind readers in both schemes.
66
+
67
+ ## Fonts
68
+
69
+ A font is a file under `public/`, served by the app: `family`, `src`,
70
+ `weights` (one weight or a range) and `style`. It becomes an `@font-face`
71
+ rule, and the `font-sans` token names it. A `url` to a font service is
72
+ accepted instead of `src` and logged once at start, as a reminder that
73
+ every page view then calls a third party.
74
+
75
+ ## Logo, favicon and login image
76
+
77
+ `logo` is one path, or `light` and `dark` paths; the `ui.Header` shows it
78
+ unless `logo=False`, and `ui.Logo` puts it on a page of the app's own, an
79
+ error page for instance. `favicon` and `login_image` are paths under
80
+ `/public/` too. The login page and the framework's error pages show the
81
+ logo and the app's name. A theme sets colours, fonts and these images;
82
+ layout belongs to the components.
83
+
84
+ ## Options
85
+
86
+ `density` is `comfortable` or `compact`; compact tightens tables, forms,
87
+ stacks and cards for data tools. `scrollbars` is `thin` (the default, in
88
+ the page's own ink) or `auto` for the platform's. `locale` sets the page
89
+ language and how dates and numbers are formatted.
90
+
91
+ ## Strings
92
+
93
+ Interface copy comes from the app's own Python, in its own language. The
94
+ framework has strings of its own: the bundle's controls (`send`, `stop`,
95
+ `attach`, `search`, `no_results`, `clear`, `loading`, `reconnecting`,
96
+ `sign_in`, `username`, `password`) and the messages the server sends on
97
+ its own account (`wait_for_reply`, `message_too_long`, `something_went_wrong`,
98
+ `too_many_sessions`, `signed_out` and the rest). They are English; an app in
99
+ another language sets them in the theme's `strings` section, key by key,
100
+ and a key the framework does not know is refused. Every key is in the
101
+ table below, with its English.
102
+
103
+ ```json
104
+ {"locale": "nl", "strings": {"send": "Verstuur", "stop": "Stop", "search": "Zoeken"}}
105
+ ```
106
+
107
+ ```python live
108
+ from millwork.theme import STRINGS
109
+
110
+ ui.Table(
111
+ rows=[{"key": k, "english": v} for k, v in sorted(STRINGS.items())],
112
+ row_key="key",
113
+ height=320,
114
+ )
115
+ ```
116
+
117
+ ## While designing
118
+
119
+ In debug mode the theme file is watched: a save reaches every open tab as
120
+ a command, without a reload. `millwork docs --theme path/to/theme.json`
121
+ shows this whole manual in a theme, which is the quickest way to see a
122
+ token set on every component in both schemes.
@@ -0,0 +1,263 @@
1
+ ---
2
+ title: Charts
3
+ order: 5
4
+ ---
5
+
6
+ # Charts
7
+
8
+ A chart takes rows, like the Table does. `x` names the category or time
9
+ column and `y` the value column, or several of them. Each series takes the
10
+ next of the theme's five chart colours, in the order given, and keeps it
11
+ when a neighbour disappears. Five series is the limit: a sixth has no colour
12
+ of its own, so fold it into another or draw a second chart. Every chart has
13
+ a tooltip; a legend appears from two series up.
14
+
15
+ ## Bar chart
16
+
17
+ `ui.BarChart` compares magnitudes by category. `labels=True` writes the
18
+ value at the end of each bar; `format` chooses `number`, `integer`,
19
+ `percent` or `currency`.
20
+
21
+ ```python live
22
+ MONTHS = [
23
+ {"month": "Jan", "sales": 42, "costs": 31, "visits": 1200},
24
+ {"month": "Feb", "sales": 48, "costs": 30, "visits": 1350},
25
+ {"month": "Mar", "sales": 55, "costs": 34, "visits": 1600},
26
+ {"month": "Apr", "sales": 51, "costs": 35, "visits": 1500},
27
+ {"month": "May", "sales": 63, "costs": 38, "visits": 1900},
28
+ {"month": "Jun", "sales": 70, "costs": 41, "visits": 2100},
29
+ ]
30
+
31
+ ui.BarChart(MONTHS, x="month", y={"sales": "Sales", "costs": "Costs"}, labels=True, height=220)
32
+ ```
33
+
34
+ ## Stacked, percent and horizontal
35
+
36
+ `stacked=True` adds the series up; `stacked="percent"` draws every bar full
37
+ and shows each series' share. `horizontal=True` turns the chart on its side,
38
+ which suits long category names.
39
+
40
+ ```python live
41
+ SHARE = [
42
+ {"channel": "Direct", "new": 120, "returning": 340},
43
+ {"channel": "Search engines", "new": 410, "returning": 190},
44
+ {"channel": "Social media", "new": 230, "returning": 80},
45
+ {"channel": "Newsletter", "new": 40, "returning": 260},
46
+ ]
47
+
48
+ ui.Grid(
49
+ ui.BarChart(SHARE, x="channel", y=["new", "returning"], stacked=True, height=200),
50
+ ui.BarChart(
51
+ SHARE, x="channel", y=["new", "returning"], stacked="percent", horizontal=True, height=200
52
+ ),
53
+ cols={"sm": 1, "md": 2},
54
+ )
55
+ ```
56
+
57
+ ## Long rows
58
+
59
+ Data often arrives with one row per series and category. `by` names the
60
+ column that holds the series name, and `y` the one column with the values;
61
+ Python pivots the rows before they leave.
62
+
63
+ ```python live
64
+ REGIONS = [
65
+ {"quarter": "Q1", "region": "North", "orders": 120},
66
+ {"quarter": "Q1", "region": "South", "orders": 90},
67
+ {"quarter": "Q2", "region": "North", "orders": 140},
68
+ {"quarter": "Q2", "region": "South", "orders": 110},
69
+ {"quarter": "Q3", "region": "North", "orders": 135},
70
+ {"quarter": "Q3", "region": "South", "orders": 150},
71
+ ]
72
+
73
+ ui.BarChart(REGIONS, x="quarter", y="orders", by="region", height=200)
74
+ ```
75
+
76
+ ## A diverging scale
77
+
78
+ When the series are steps of one ordered scale, `diverging=True` draws them
79
+ to either side of the midpoint, coloured from one hue through grey to
80
+ another instead of five unrelated colours. The middle step, when there is
81
+ one, straddles the axis.
82
+
83
+ ```python live
84
+ WEATHER = [
85
+ {"month": "Mar", "freezing": 4, "cold": 12, "mild": 10, "warm": 5, "hot": 0},
86
+ {"month": "Apr", "freezing": 1, "cold": 8, "mild": 14, "warm": 7, "hot": 0},
87
+ {"month": "May", "freezing": 0, "cold": 3, "mild": 12, "warm": 13, "hot": 3},
88
+ {"month": "Jun", "freezing": 0, "cold": 1, "mild": 8, "warm": 15, "hot": 6},
89
+ ]
90
+
91
+ ui.BarChart(
92
+ WEATHER,
93
+ x="month",
94
+ y=["freezing", "cold", "mild", "warm", "hot"],
95
+ diverging=True,
96
+ horizontal=True,
97
+ height=200,
98
+ )
99
+ ```
100
+
101
+ ## Emphasis
102
+
103
+ Often one series is the point and the rest are context. `emphasis` names
104
+ the one that keeps its colour; the others recede into grey.
105
+
106
+ ```python live
107
+ ui.LineChart(
108
+ MONTHS,
109
+ x="month",
110
+ y={"sales": "Sales", "costs": "Costs"},
111
+ emphasis="sales",
112
+ labels=True,
113
+ height=200,
114
+ )
115
+ ```
116
+
117
+ ## Line and area
118
+
119
+ `ui.LineChart` draws a trend over an ordered x. `curve=True` smooths it,
120
+ `dots=True` marks the points, and `labels=True` names each line at its end
121
+ rather than numbering every point. `ui.AreaChart` fills under the line and
122
+ `stacked=True` adds the series up.
123
+
124
+ ```python live
125
+ ui.Grid(
126
+ ui.LineChart(MONTHS, x="month", y="visits", curve=True, dots=True, height=200),
127
+ ui.AreaChart(
128
+ MONTHS, x="month", y={"sales": "Sales", "costs": "Costs"}, stacked=True, height=200
129
+ ),
130
+ cols={"sm": 1, "md": 2},
131
+ )
132
+ ```
133
+
134
+ ## Clicks
135
+
136
+ `on_click` receives the point: its `x`, the `series` key and `value` for a
137
+ bar, and `values` for every series at that x. On a line or area chart the
138
+ click lands on the nearest x.
139
+
140
+ ```python live
141
+ from pydantic import BaseModel
142
+
143
+
144
+ class Picked(BaseModel):
145
+ text: str = "Click a bar."
146
+
147
+
148
+ def picked(s, point):
149
+ state(Picked).text = f"{point['series']} in {point['x']}: {point['value']}"
150
+
151
+
152
+ def example(s):
153
+ return ui.Stack(
154
+ ui.BarChart(MONTHS, x="month", y=["sales", "costs"], on_click=picked, height=180),
155
+ ui.Text(state(Picked).text, variant="muted"),
156
+ gap=2,
157
+ )
158
+ ```
159
+
160
+ ## Stat
161
+
162
+ `ui.Stat` is the headline number. `delta` is the change, drawn with its
163
+ direction; `good="down"` says a fall is the welcome move, for costs or
164
+ errors. `trend` draws a sparkline whose last point is the current one.
165
+
166
+ ```python live
167
+ ui.Grid(
168
+ ui.Stat(label="Revenue", value="€ 70k", delta=0.11, trend=[42, 48, 55, 51, 63, 70]),
169
+ ui.Stat(label="Costs", value="€ 41k", delta=0.08, good="down", trend=[31, 30, 34, 35, 38, 41]),
170
+ ui.Stat(label="Open tickets", value="12", delta=-3, delta_format="number", good="down"),
171
+ cols={"sm": 1, "md": 3},
172
+ )
173
+ ```
174
+
175
+ ## Plotly and other libraries
176
+
177
+ The catalogue draws the forms most pages need. For anything else, an app
178
+ brings its own library through a custom component. Plotly is the common
179
+ case; the same shape works for any library that draws into a div.
180
+
181
+ The page may only run scripts from its own origin, so the library is served
182
+ by the app itself: put `plotly.min.js` in the app's `public/` folder. The
183
+ custom component loads it once, then hands the figure to Plotly whenever
184
+ the props change.
185
+
186
+ ```jsx
187
+ // components/Plotly.jsx
188
+ import { useEffect, useRef } from "react";
189
+
190
+ let loading = null;
191
+
192
+ function loadPlotly() {
193
+ if (window.Plotly) return Promise.resolve(window.Plotly);
194
+ loading ??= new Promise((resolve, reject) => {
195
+ const script = document.createElement("script");
196
+ script.src = `${window.__mw.base || ""}/public/plotly.min.js`;
197
+ script.onload = () => resolve(window.Plotly);
198
+ script.onerror = () => reject(new Error("plotly.min.js did not load"));
199
+ document.head.appendChild(script);
200
+ });
201
+ return loading;
202
+ }
203
+
204
+ export default function Plotly({ props }) {
205
+ const ref = useRef(null);
206
+ useEffect(() => {
207
+ let alive = true;
208
+ loadPlotly().then((Plotly) => {
209
+ if (!alive || !ref.current) return;
210
+ const { data, layout } = props.figure;
211
+ Plotly.react(ref.current, data, layout, { responsive: true, displaylogo: false });
212
+ });
213
+ return () => {
214
+ alive = false;
215
+ };
216
+ }, [props.figure]);
217
+ useEffect(() => () => window.Plotly?.purge(ref.current), []);
218
+ return <div ref={ref} style={{ width: "100%", height: props.height ?? 320 }} />;
219
+ }
220
+ ```
221
+
222
+ On the Python side a figure is built as usual and sent as JSON. A
223
+ transparent background lets the page's own colours through in both
224
+ schemes; the figure's own colours are Plotly's, not the theme's. Plotly
225
+ Express wants pandas installed next to it; graph objects need nothing.
226
+
227
+ ```python
228
+ import json
229
+
230
+ import plotly.express as px
231
+
232
+ def example(s):
233
+ fig = px.line(MONTHS, x="month", y="visits", markers=True)
234
+ fig.update_layout(
235
+ paper_bgcolor="rgba(0,0,0,0)",
236
+ plot_bgcolor="rgba(0,0,0,0)",
237
+ margin={"l": 0, "r": 0, "t": 0, "b": 0},
238
+ )
239
+ return ui.Custom("Plotly", props={"figure": json.loads(fig.to_json()), "height": 300})
240
+ ```
241
+
242
+ A click on a bar or a point can come back the same way as in the Hello
243
+ component: register a Plotly event on the div and call `emit`.
244
+
245
+ ## Images from Python
246
+
247
+ Any library that renders to SVG or PNG works without a component at all:
248
+ the session keeps the bytes and `ui.Image` shows them. No tooltip, no theme
249
+ awareness, no script in the browser.
250
+
251
+ ```python
252
+ import io
253
+
254
+ import matplotlib.pyplot as plt
255
+
256
+ def example(s):
257
+ fig, ax = plt.subplots(figsize=(6, 3))
258
+ ax.plot([m["month"] for m in MONTHS], [m["visits"] for m in MONTHS])
259
+ out = io.BytesIO()
260
+ fig.savefig(out, format="svg", transparent=True)
261
+ plt.close(fig)
262
+ return ui.Image(src=s.files.add(out.getvalue(), name="visits.svg"), alt="Visits per month")
263
+ ```