polyadmin 0.1.0b1__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 (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
polyadmin/ui.py ADDED
@@ -0,0 +1,817 @@
1
+ """shadcn/ui's variant lookups, resolved server-side.
2
+
3
+ `UI_REGISTRY` resolves shadcn's class-variance-authority (cva) lookups
4
+ while the Jinja template renders, via the `ui` global::
5
+
6
+ {{ ui("button", "outline", "size-sm") }}
7
+
8
+ Each entry splits its class lists the way shadcn does:
9
+ ``base``/``variants``/``sizes`` are cva axes and compose; ``parts`` are
10
+ sub-components (CardTitle, TableHead) and *replace* the base.
11
+
12
+ There is no tailwind-merge here, so a base must never set a property its
13
+ variants or sizes also set: two competing utilities resolve by Tailwind's
14
+ output order, not the order written. Parts must also stay space-free
15
+ where classList.add() consumes them (``combobox``'s "item-active"),
16
+ which throws on a value containing a space.
17
+
18
+ Colors are the CSS variables from ``admin/theme.html`` rather than a
19
+ literal palette, which is what makes the admin themeable. Sizes are
20
+ prefixed ``size-`` because shadcn has both a variant and a size named
21
+ "default"; the prefix also tells the resolver which axis was given.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ # Each value is a dict with optional "base" (str) and optional
26
+ # "variants"/"sizes"/"parts" (dict[str, str]) keys.
27
+ UI_REGISTRY: dict[str, dict[str, object]] = {
28
+
29
+
30
+ "button": {
31
+ # No height, padding, or background here -- see the uiComponent
32
+ # note on why a base must not fight its own axes.
33
+ "base": "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium tracking-wide transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
34
+ "variants": {
35
+ # shadcn's six stock button variants, verbatim in intent.
36
+ "default": "bg-primary text-primary-foreground hover:bg-primary/90",
37
+ "destructive": "bg-destructive text-destructive-foreground hover:bg-destructive/90",
38
+ "outline": "border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
39
+ "secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80",
40
+ "ghost": "text-foreground hover:bg-accent hover:text-accent-foreground",
41
+ "link": "text-primary underline-offset-4 hover:underline",
42
+ # shadcn ships no destructive counterpart to `outline`. A
43
+ # detail page's Delete must match the weight of the outline
44
+ # Edit beside it (solid `destructive` is reserved for the
45
+ # confirmation page's submit), and a row's icon-only Delete
46
+ # wants the colour with no chrome at all.
47
+ "destructive-outline": "border border-destructive/40 bg-background text-destructive hover:bg-destructive hover:text-destructive-foreground",
48
+ "ghost-destructive": "text-destructive hover:bg-destructive/10 hover:text-destructive",
49
+ # Low-emphasis muted ghost: the view/edit row buttons, the
50
+ # sidebar close, the theme toggle.
51
+ "ghost-muted": "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
52
+ },
53
+ "sizes": {
54
+ "size-default": "h-10 px-4 py-2",
55
+ "size-sm": "h-9 px-3",
56
+ "size-xs": "h-8 px-2.5 text-xs",
57
+ "size-lg": "h-11 px-8",
58
+ "size-icon": "h-9 w-9 shrink-0",
59
+ "size-icon-sm": "h-8 w-8 shrink-0",
60
+ "size-icon-xs": "h-7 w-7 shrink-0",
61
+ },
62
+ },
63
+
64
+ "input": {
65
+ "base": "flex w-full rounded-md border border-input bg-background text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
66
+ "sizes": {
67
+ "size-default": "h-10 px-3 py-2",
68
+ "size-sm": "h-9 px-2",
69
+ },
70
+ "parts": {
71
+ # A standalone (not composed) control for an input sitting
72
+ # inside an already-bordered wrapper -- the combobox's search
73
+ # field -- so it contributes no border, ring, or background of
74
+ # its own.
75
+ "bare": "flex h-10 w-full bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
76
+ },
77
+ },
78
+
79
+ "textarea": {
80
+ "base": "flex w-full rounded-md border border-input bg-background text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
81
+ "sizes": {"size-default": "min-h-[80px] px-3 py-2"},
82
+ },
83
+
84
+ # A native <select>, not shadcn's Radix-backed SelectTrigger: a
85
+ # custom listbox does not post its value with a plain form submit,
86
+ # and the admin's forms have to keep working without JS. Styled to
87
+ # match the shadcn trigger (same height, border, ring).
88
+ "select": {
89
+ "base": "flex w-full items-center rounded-md border border-input bg-background text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
90
+ "sizes": {
91
+ "size-default": "h-10 px-3 py-2",
92
+ "size-sm": "h-9 px-2",
93
+ # <select multiple> sizes itself by its `size` attribute.
94
+ "size-auto": "h-auto px-3 py-2",
95
+ },
96
+ "parts": {
97
+ # Table-cell flavours. The base's w-full leaves a <td> select
98
+ # with no intrinsic width, so the column ignores the options
99
+ # and clips the widest ("Enterprise" as "Enterpri"). w-auto
100
+ # lets the browser measure them; max-w-full caps the damage
101
+ # one long option can do. Spelled out in full because a part
102
+ # cannot compose with a size.
103
+ "cell": "flex h-9 w-auto max-w-full items-center rounded-md border border-input bg-background px-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
104
+ "cell-multi": "flex h-auto w-auto max-w-full items-center rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
105
+ },
106
+ },
107
+
108
+ "label": {
109
+ "base": "block text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
110
+ },
111
+
112
+ # appearance-none is what makes the rest apply: a native checkbox
113
+ # draws itself and ignores border/radius/background. The check glyph
114
+ # is a background image on .ui-checkbox in theme.html.
115
+ "checkbox": {
116
+ "base": "ui-checkbox peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-sm outline-none transition-shadow checked:border-primary checked:bg-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
117
+ },
118
+
119
+ "radio": {
120
+ "base": "h-4 w-4 shrink-0 border-input bg-background accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
121
+ },
122
+
123
+ # Built on a native checkbox rather than a button, so it toggles with
124
+ # no JavaScript and posts like the checkbox it replaces. track and
125
+ # thumb are siblings of the input, not children: `peer` compiles to a
126
+ # sibling combinator and would not cross into a child.
127
+ "switch": {
128
+ "base": "relative inline-flex shrink-0 cursor-pointer items-center",
129
+ "parts": {
130
+ "input": "peer sr-only",
131
+ "track": "h-5 w-9 rounded-full border-2 border-transparent bg-input transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
132
+ "thumb": "pointer-events-none absolute left-0.5 block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform peer-checked:translate-x-4",
133
+ },
134
+ },
135
+
136
+ "badge": {
137
+ "base": "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none",
138
+ "variants": {
139
+ "default": "border-transparent bg-primary text-primary-foreground",
140
+ "secondary": "border-transparent bg-secondary text-secondary-foreground",
141
+ "destructive": "border-transparent bg-destructive text-destructive-foreground",
142
+ "outline": "border-border text-foreground",
143
+ },
144
+ },
145
+
146
+ "card": {
147
+ "base": "rounded-lg border border-border bg-card text-card-foreground shadow-sm",
148
+ "parts": {
149
+ "header": "flex flex-col gap-1.5 p-6",
150
+ "title": "text-sm font-medium leading-none tracking-tight text-muted-foreground",
151
+ "description": "text-sm text-muted-foreground",
152
+ "content": "p-6 pt-0",
153
+ "footer": "flex items-center p-6 pt-0",
154
+ },
155
+ },
156
+
157
+ # shadcn's login-04 block. Its social buttons, "Sign up" and "Forgot
158
+ # your password?" are deliberately absent: the framework has no route
159
+ # behind any of them. login-04 fills the right column with a
160
+ # photograph, which a framework cannot ship, so "aside" styles a
161
+ # typographic panel instead.
162
+ "login": {
163
+ "parts": {
164
+ "page": "flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10",
165
+ "container": "flex w-full max-w-sm flex-col gap-6 md:max-w-3xl",
166
+ # Overrides card's own padding: in this block the columns pad
167
+ # themselves, so the card must not.
168
+ "grid": "grid p-0 md:grid-cols-2",
169
+ "form": "p-6 md:p-8",
170
+ "stack": "flex flex-col gap-6",
171
+ "heading": "flex flex-col items-center text-center",
172
+ "title": "text-2xl font-bold",
173
+ "subtitle": "text-balance text-muted-foreground",
174
+ "group": "grid gap-2",
175
+ # bg-muted is the page's own colour, so without login-04's
176
+ # photograph over it the card read as half-width with text
177
+ # floating beside it. The chart tokens are defined in both
178
+ # themes, so a tint from them stays distinct without
179
+ # hardcoding a colour.
180
+ "aside": "relative hidden border-l border-border bg-gradient-to-br from-chart-1/20 via-chart-6/10 to-chart-3/20 md:block",
181
+ "panel": "absolute inset-0 flex flex-col items-center justify-center gap-3 p-8 text-center",
182
+ "logo": "h-12 w-auto opacity-80",
183
+ "brand": "text-xl font-semibold text-foreground",
184
+ },
185
+ },
186
+
187
+ # The 401/403/404 page. Parts only, like `login`: a centred card
188
+ # with the status number set large and quiet above the headline, so
189
+ # the page reads as an explanation rather than a stack trace.
190
+ "error": {
191
+ "parts": {
192
+ "page": "flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10",
193
+ "card": "flex w-full max-w-md flex-col items-center gap-3 rounded-lg border border-border bg-card p-8 text-center text-card-foreground shadow-sm",
194
+ "status": "text-5xl font-bold tracking-tight text-muted-foreground/40",
195
+ "title": "text-xl font-semibold",
196
+ "message": "text-balance text-sm text-muted-foreground",
197
+ },
198
+ },
199
+
200
+ # The native overflow box, with the scrollbar restyled by the .ui-
201
+ # scroll-area rules in theme.html (a scrollbar cannot be expressed as
202
+ # Tailwind utilities without a plugin). The axis is the whole
203
+ # decision, so there are two parts and no base.
204
+ "scroll-area": {
205
+ "parts": {
206
+ "x": "ui-scroll-area block max-w-xs overflow-x-auto overflow-y-hidden whitespace-nowrap",
207
+ "y": "ui-scroll-area block max-h-40 overflow-y-auto overflow-x-hidden",
208
+ },
209
+ },
210
+
211
+ "alert": {
212
+ # `border` with no color; the variants supply the color.
213
+ "base": "relative w-full rounded-lg border p-4",
214
+ "variants": {
215
+ "default": "border-border bg-card text-card-foreground",
216
+ "destructive": "border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive",
217
+ },
218
+ "parts": {
219
+ "title": "mb-1 text-sm font-medium leading-none tracking-tight",
220
+ "description": "text-sm opacity-90",
221
+ },
222
+ },
223
+
224
+ "separator": {
225
+ "base": "shrink-0 bg-border",
226
+ "variants": {
227
+ "horizontal": "h-px w-full",
228
+ "vertical": "h-full w-px",
229
+ },
230
+ },
231
+
232
+ "skeleton": {"base": "animate-pulse rounded-md bg-muted"},
233
+
234
+ "avatar": {
235
+ "base": "relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full bg-muted",
236
+ "parts": {
237
+ "image": "aspect-square h-full w-full object-cover",
238
+ "fallback": "flex h-full w-full items-center justify-center bg-muted text-xs font-medium text-muted-foreground",
239
+ },
240
+ },
241
+
242
+ # Shared text roles, so "the muted small text" is one decision
243
+ # rather than a repeated literal in twelve templates.
244
+ "text": {
245
+ "parts": {
246
+ "muted": "text-sm text-muted-foreground",
247
+ "muted-xs": "text-xs text-muted-foreground",
248
+ "empty": "text-sm text-muted-foreground",
249
+ "placeholder": "text-muted-foreground",
250
+ "heading": "text-sm font-semibold text-foreground",
251
+ "label-caps": "text-xs font-semibold uppercase tracking-wide text-muted-foreground",
252
+ "metric": "text-3xl font-semibold text-foreground",
253
+ "link": "text-primary underline-offset-4 hover:underline",
254
+ "error": "text-xs font-medium text-destructive",
255
+ },
256
+ },
257
+
258
+
259
+ "dialog": {
260
+ "parts": {
261
+ "overlay": "fixed inset-0 bg-black/80",
262
+ "container": "fixed inset-0 flex items-center justify-center p-4",
263
+ "content": "relative w-full max-w-lg rounded-lg border border-border bg-background p-6 text-foreground shadow-lg",
264
+ "content-sm": "relative w-full max-w-sm rounded-lg border border-border bg-background p-6 text-foreground shadow-lg",
265
+ "title": "text-base font-semibold leading-none tracking-tight text-foreground",
266
+ "description": "mt-2 text-sm text-muted-foreground",
267
+ "footer": "mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
268
+ },
269
+ },
270
+
271
+ "dropdown": {
272
+ "parts": {
273
+ "content": "z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
274
+ "item": "relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none",
275
+ "label": "px-2 py-1.5 text-xs font-semibold text-muted-foreground",
276
+ "separator": "-mx-1 my-1 h-px bg-muted",
277
+ },
278
+ },
279
+
280
+ "popover": {
281
+ "parts": {
282
+ "content": "z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none",
283
+ },
284
+ },
285
+
286
+ "tooltip": {
287
+ "parts": {
288
+ "content": "z-50 overflow-hidden rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground shadow-md",
289
+ },
290
+ },
291
+
292
+ # Sonner, the toaster shadcn/ui ships. Its geometry is kept as it
293
+ # ships: a 356px column in the viewport's corner, 14px between toasts,
294
+ # 16px of padding, 13px text, and a close button parked half outside
295
+ # the top-left corner. What is dropped is Sonner's collapsed
296
+ # peek-stack, which is choreography for an app raising toasts
297
+ # continuously -- see toasts.html.
298
+ "toast": {
299
+ "parts": {
300
+ # pointer-events-none is load-bearing: the viewport spans a
301
+ # strip of the screen even when empty, and record pages put a
302
+ # sticky action bar in that same corner, so it would otherwise
303
+ # swallow clicks on Save. Each toast re-enables pointer events
304
+ # for itself.
305
+ #
306
+ # The padding is Sonner's offset from the viewport edge (32px,
307
+ # 16px below `sm`), so the column inside it is exactly the
308
+ # 356px Sonner uses.
309
+ "list": (
310
+ "pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col "
311
+ "gap-[14px] p-4 sm:inset-x-auto sm:right-0 sm:bottom-0 sm:w-[420px] sm:p-8"
312
+ ),
313
+ "root": (
314
+ "pointer-events-auto group relative flex w-full items-center gap-1.5 "
315
+ "rounded-lg border border-border bg-background p-4 text-foreground shadow-lg"
316
+ ),
317
+ "icon": "flex size-4 shrink-0 items-center justify-center",
318
+ "title": "text-[13px] font-medium leading-tight",
319
+ "description": "mt-1 text-[13px] leading-snug text-muted-foreground",
320
+ # Sonner parks the close button on the toast's top-left corner,
321
+ # half outside it, and only reveals it on hover.
322
+ "close": (
323
+ "absolute left-0 top-0 flex size-5 -translate-x-1/3 -translate-y-1/3 items-center "
324
+ "justify-center rounded-full border border-border bg-background text-foreground/60 "
325
+ "opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none "
326
+ "focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
327
+ ),
328
+ },
329
+ },
330
+
331
+ "sheet": {
332
+ "parts": {
333
+ "overlay": "fixed inset-0 z-40 bg-black/60",
334
+ # Deliberately carries no background or width: the sidebar
335
+ # composes this with `ui "sidebar"`, and two competing
336
+ # bg-*/w-* utilities resolve by Tailwind's output order rather
337
+ # than intent. Panels that aren't the sidebar add "panel".
338
+ "content": "fixed z-50 shadow-lg transition-transform duration-300 ease-in-out",
339
+ "panel": "bg-background",
340
+ "side-left": "inset-y-0 left-0 h-full border-r border-border",
341
+ "side-right": "inset-y-0 right-0 h-full border-l border-border",
342
+ "width-panel": "w-72",
343
+ },
344
+ },
345
+
346
+
347
+ # shadcn's sidebar-07 block, at its exact widths (16rem open, 3rem
348
+ # collapsed, 18rem mobile sheet). Its --sidebar-* colour scale is
349
+ # deliberately not reproduced: the Zinc values are within a hair of
350
+ # card/accent/border, and restyling the admin must mean editing
351
+ # theme.html's variables and nothing else.
352
+ "sidebar": {
353
+ "base": (
354
+ "flex h-full shrink-0 flex-col border-r border-border bg-card "
355
+ "transition-[width] duration-200 ease-linear"
356
+ ),
357
+ "parts": {
358
+ "expanded": "w-64",
359
+ # 3rem exactly: p-2 either side of a size-8 icon button, so
360
+ # the icon column doesn't shift during the transition.
361
+ "collapsed": "w-12",
362
+ "header": "flex flex-col gap-2 p-2",
363
+ "content": "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden p-2",
364
+ "footer": "flex flex-col gap-2 border-t border-border p-2",
365
+ "group": "flex w-full min-w-0 flex-col",
366
+ "group-label": (
367
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs "
368
+ "font-medium text-muted-foreground/70"
369
+ ),
370
+ "menu": "flex w-full min-w-0 flex-col gap-1",
371
+ # The sub-menu's left rule is what makes nesting legible once
372
+ # a group is open; it has no icon column of its own.
373
+ "menu-sub": "mx-3.5 flex min-w-0 flex-col gap-1 border-l border-border px-2.5 py-0.5",
374
+ # SidebarRail: the hairline strip on the sidebar's edge that
375
+ # toggles it. Invisible until hovered, hence ::after not a border.
376
+ "rail": (
377
+ "absolute inset-y-0 -right-2 z-20 hidden w-4 cursor-w-resize md:block "
378
+ "after:absolute after:inset-y-0 after:left-1/2 after:w-px "
379
+ "after:transition-colors hover:after:bg-border"
380
+ ),
381
+ # SidebarInset: the content column beside the sidebar.
382
+ "inset": "relative flex min-h-0 min-w-0 flex-1 flex-col bg-background",
383
+ # The block's h-16 header: trigger, separator, breadcrumbs.
384
+ "topbar": "flex h-16 shrink-0 items-center gap-2 border-b border-border px-4",
385
+ },
386
+ },
387
+
388
+ # SidebarMenuButton. p-2 + a size-4 icon is exactly the collapsed
389
+ # sidebar's 3rem, so nothing shifts horizontally as it animates --
390
+ # only the label clips away.
391
+ "nav-item": {
392
+ # No background here: active/inactive supply it.
393
+ "base": "flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
394
+ "variants": {
395
+ "active": "bg-accent font-medium text-accent-foreground",
396
+ "inactive": "text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground",
397
+ },
398
+ "sizes": {
399
+ # Base carries padding, not height, so these don't fight it.
400
+ "size-default": "h-8",
401
+ # The taller brand and user buttons at the sidebar's two ends.
402
+ "size-lg": "h-12",
403
+ },
404
+ },
405
+
406
+ "tabs": {
407
+ "parts": {
408
+ "list": "inline-flex h-9 items-center justify-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground",
409
+ "trigger": "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
410
+ "trigger-on": "bg-background text-foreground shadow-sm",
411
+ "trigger-off": "text-muted-foreground hover:text-foreground",
412
+ # Underline flavor, for the dashboard's Tabs container widget
413
+ # -- a pill row would fight the card it sits inside.
414
+ "underline-list": "mb-3 flex gap-1 border-b border-border",
415
+ "underline-trigger": "-mb-px border-b-2 px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
416
+ "underline-trigger-on": "border-primary text-foreground",
417
+ "underline-trigger-off": "border-transparent text-muted-foreground hover:text-foreground",
418
+ },
419
+ },
420
+
421
+ "accordion": {
422
+ "parts": {
423
+ "item": "border-b border-border",
424
+ "trigger": "flex w-full flex-1 items-center justify-between gap-3 py-3 text-sm font-medium text-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
425
+ "content": "overflow-hidden pb-3 text-sm text-muted-foreground",
426
+ "chevron": "shrink-0 transition-transform duration-200",
427
+ },
428
+ },
429
+
430
+ "breadcrumb": {
431
+ "base": "flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground",
432
+ "parts": {
433
+ "item": "flex items-center gap-1.5",
434
+ "link": "rounded transition-colors hover:text-foreground",
435
+ "page": "cursor-default font-semibold text-foreground",
436
+ "plain": "cursor-default",
437
+ "separator": "text-border",
438
+ },
439
+ },
440
+
441
+ # Toolbar + footer of the list table, from shadcn's Tasks example:
442
+ # search and filters left, page actions right; below, the
443
+ # DataTablePagination row.
444
+ #
445
+ # Stacked until lg, a row from lg up. lg, not sm, because the sidebar
446
+ # becomes a static 16rem column at md: the content area *shrinks*
447
+ # there (735px -> 549px), and measured, the five controls only stop
448
+ # wrapping into a ragged block at ~1024px.
449
+ "toolbar": {
450
+ "base": "flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between",
451
+ "parts": {
452
+ # Every control carries its own "item" to fill its line while
453
+ # stacked: stretch alone cannot, since several are buttons
454
+ # wrapped in a <form> or a positioning <div>. flex-1 so the
455
+ # filter cluster takes the slack once they are rows.
456
+ "filters": "flex flex-1 flex-col gap-2 lg:flex-row lg:flex-wrap lg:items-center",
457
+ "actions": "flex flex-col gap-2 lg:flex-row lg:items-center",
458
+ "item": "w-full lg:w-auto",
459
+ # Centred content in a full-width bar reads as floating, so
460
+ # while stacked the label goes hard left and the icon hard
461
+ # right. "item-label" takes the slack (which also leaves the
462
+ # base's justify-center nothing to centre, so the two never
463
+ # fight); "item-icon" moves a leading icon to the trailing
464
+ # edge without reordering the markup. Both revert at lg.
465
+ "item-label": "flex-1 text-left lg:flex-none",
466
+ "item-icon": "order-last lg:order-none",
467
+ },
468
+ },
469
+ # The filter drawer: one Filters trigger in the toolbar opening a
470
+ # sheet from the right. One trigger stays one trigger however many
471
+ # filters a ModelAdmin declares, which per-filter dropdowns don't.
472
+ "filter-panel": {
473
+ "parts": {
474
+ "header": "flex items-center justify-between gap-2 border-b border-border px-4 py-3",
475
+ "title": "text-sm font-semibold text-foreground",
476
+ "body": "flex-1 space-y-5 overflow-y-auto px-4 py-4",
477
+ "group-label": (
478
+ "mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
479
+ ),
480
+ "choice": (
481
+ "flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors "
482
+ "hover:bg-accent hover:text-accent-foreground"
483
+ ),
484
+ "choice-active": "bg-accent font-medium text-accent-foreground",
485
+ "choice-inactive": "text-muted-foreground",
486
+ "footer": "border-t border-border px-4 py-3",
487
+ # The count badge on the trigger, shown only while filters
488
+ # are actually narrowing the list.
489
+ "count": "rounded-sm px-1 font-mono text-xs font-normal",
490
+ # The range form under a date filter's presets.
491
+ "form": "mt-2 space-y-2 border-t border-border px-3 pt-3",
492
+ "form-row": "flex items-center gap-2",
493
+ "form-label": "w-10 shrink-0 text-xs text-muted-foreground",
494
+ "form-input": "h-8 flex-1 text-sm",
495
+ },
496
+ },
497
+
498
+ "pagination": {
499
+ "base": "flex flex-col items-center gap-3 sm:flex-row sm:justify-between",
500
+ "parts": {
501
+ "list": "flex h-9 items-center divide-x divide-border overflow-hidden rounded-md border border-border bg-card text-sm leading-tight text-muted-foreground",
502
+ "link": "relative inline-flex h-full items-center px-3 transition-colors hover:bg-accent hover:text-accent-foreground",
503
+ "disabled": "pointer-events-none text-muted-foreground/40",
504
+ "current": "hidden h-full items-center px-3 font-medium text-foreground sm:flex",
505
+ # The selection count, which stays visible (as "0 of N")
506
+ # even with nothing selected, exactly as the example does.
507
+ "selection": "flex-1 text-sm text-muted-foreground",
508
+ "controls": "flex items-center gap-4 lg:gap-8",
509
+ "rows-per-page": "flex items-center gap-2 text-sm font-medium",
510
+ # shadcn fixes this at w-[100px]; a translated "Page 1 of 8"
511
+ # is wider than that in most languages, so the width is a
512
+ # floor and the label never wraps to a second line.
513
+ "page-indicator": (
514
+ "flex min-w-[100px] items-center justify-center whitespace-nowrap "
515
+ "text-sm font-medium"
516
+ ),
517
+ "jumps": "flex items-center gap-2",
518
+ # The two outer jumps (first/last) are hidden on small
519
+ # screens in the example -- prev/next are enough there.
520
+ "jump-edge": "hidden h-8 w-8 p-0 lg:flex",
521
+ "jump": "h-8 w-8 p-0",
522
+ },
523
+ },
524
+
525
+ "table": {
526
+ "base": "w-full min-w-full caption-bottom text-sm",
527
+ "parts": {
528
+ "wrapper": "overflow-hidden rounded-lg border border-border bg-card",
529
+ "scroll": "overflow-x-auto",
530
+ # The "select all N matching" strip: only ever visible once
531
+ # a whole page is ticked, so it reads as a follow-up
532
+ # question rather than permanent chrome.
533
+ "select-all": "flex flex-wrap items-center justify-center gap-2 border-b border-border bg-muted/40 px-4 py-2 text-center text-sm text-muted-foreground",
534
+ "select-all-action": "font-medium text-foreground underline underline-offset-2 hover:no-underline",
535
+ "head": "[&_tr]:border-b [&_tr]:border-border",
536
+ "th": "px-4 py-2.5 text-left align-middle font-medium whitespace-nowrap text-muted-foreground",
537
+ "body": "divide-y divide-border",
538
+ # data-[state=selected], from shadcn/ui's own TableRow: a
539
+ # checked row-checkbox (list_content.html) tints the whole
540
+ # row, the same as a hover, so a selection reads at a glance
541
+ # instead of only through the toolbar's "N of M selected"
542
+ # count.
543
+ "row": "text-foreground transition-colors hover:bg-muted/50 has-[.row-checkbox:checked]:bg-muted",
544
+ "cell": "px-4 py-2.5 align-middle text-sm whitespace-nowrap",
545
+ "empty": "px-4 py-6 text-sm text-muted-foreground",
546
+ # Compact flavor for the dashboard Table widget and tabular
547
+ # inlines, which sit inside an existing card.
548
+ "th-compact": "px-3 py-2 text-left text-xs font-medium whitespace-nowrap text-muted-foreground",
549
+ "cell-compact": "px-3 py-2 align-middle whitespace-nowrap",
550
+ },
551
+ },
552
+
553
+
554
+ # The label + control + description + error unit. shadcn calls this
555
+ # FormItem/FormLabel/FormDescription/FormMessage; here it is what
556
+ # wraps every generated form input.
557
+ "field": {
558
+ "base": "mb-4",
559
+ "parts": {
560
+ "label": "block text-sm font-medium leading-none text-foreground",
561
+ "required": "ml-0.5 text-destructive",
562
+ "control": "mt-1.5",
563
+ "description": "mt-1.5 text-xs text-muted-foreground",
564
+ "message": "mt-1.5 text-xs font-medium text-destructive",
565
+ # A boolean puts its control on the same row as its name.
566
+ # items-start, not items-center: with a description the text
567
+ # block is two lines and centring leaves the toggle sitting
568
+ # low. row-label's 20px line box matches the switch height, so
569
+ # top-aligning reads as centred either way.
570
+ "row": "flex items-start gap-2",
571
+ "row-text": "min-w-0",
572
+ "row-label": "leading-5",
573
+ # A read-only field's value, shown instead of a control.
574
+ # Deliberately not input-shaped: a disabled-looking box
575
+ # invites clicking at it, a plain value does not.
576
+ "readonly": "py-1.5 text-sm text-foreground",
577
+ },
578
+ },
579
+
580
+ "combobox": {
581
+ "base": "relative",
582
+ "parts": {
583
+ "trigger": "flex items-center gap-2 rounded-md border border-input bg-background px-3 ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
584
+ # ui-scroll-area, so a long option list scrolls behind the same
585
+ # thin token-coloured bar the rest of the admin uses rather than
586
+ # the platform's own chunky one -- as close as CSS gets to Radix
587
+ # drawing its own over a hidden native one.
588
+ "content": "ui-scroll-area absolute z-50 mt-1 max-h-[280px] w-full overflow-y-auto rounded-md border border-border bg-popover py-1 text-sm text-popover-foreground shadow-md",
589
+ "item": "relative flex cursor-pointer select-none items-center rounded-sm px-3 py-1.5 text-popover-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
590
+ # Handed to classList.add()/remove() by the arrow-key
591
+ # handler, so this must stay a single space-free class.
592
+ "item-active": "bg-accent",
593
+ "empty": "px-3 py-1.5 text-muted-foreground",
594
+ "icon": "w-4 h-4 shrink-0 text-muted-foreground",
595
+ },
596
+ },
597
+
598
+ # The many-to-many control: shadcn's Combobox/Command idiom over the
599
+ # relation's options, selection shown as removable chips. Django's
600
+ # filter_horizontal without the two-pane layout, which needs width
601
+ # this form column doesn't have.
602
+ "multi-select": {
603
+ "parts": {
604
+ # min-h matches the single Select's h-10 so a field with
605
+ # nothing chosen lines up with its neighbours; it grows from
606
+ # there as chips wrap onto more lines.
607
+ "trigger": "min-h-10 justify-between gap-2 text-left",
608
+ "values": "flex flex-1 flex-wrap items-center gap-1",
609
+ "chip": "gap-1 py-0.5 pr-1 pl-2 font-normal",
610
+ # The chip's own remove affordance. Not a <button>: the chip
611
+ # lives inside the trigger <button>, and HTML forbids nesting
612
+ # one button in another.
613
+ "chip-remove": "rounded-sm p-0.5 transition-colors hover:bg-background/60",
614
+ "search": "flex items-center gap-2 border-b border-border px-3",
615
+ "list": "max-h-56 overflow-y-auto py-1",
616
+ },
617
+ },
618
+
619
+ "calendar": {
620
+ "base": "w-auto p-3",
621
+ "parts": {
622
+ "header": "flex items-center justify-between gap-2 pb-2",
623
+ "caption": "text-sm font-medium text-foreground",
624
+ "nav": "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
625
+ "grid": "w-full border-collapse",
626
+ "weekday": "h-8 w-8 p-0 text-center text-[0.7rem] font-normal text-muted-foreground",
627
+ # "day" carries no background so the three state parts below
628
+ # can be layered on via an Alpine :class binding.
629
+ "day": "h-8 w-8 rounded-md p-0 text-center text-sm font-normal text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
630
+ "day-selected": "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
631
+ "day-today": "border border-border font-semibold",
632
+ "day-outside": "text-muted-foreground/50",
633
+ },
634
+ },
635
+
636
+ "slider": {
637
+ "base": "relative flex w-full touch-none select-none items-center",
638
+ "parts": {
639
+ # A native range input, tinted with accent-color -- same
640
+ # no-JS-form reasoning as checkbox/radio.
641
+ "track": "h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
642
+ "output": "w-12 shrink-0 text-right text-sm tabular-nums text-muted-foreground",
643
+ },
644
+ },
645
+
646
+
647
+ "widget": {
648
+ "base": "rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-6",
649
+ "parts": {
650
+ "header": "mb-3 flex items-center gap-3",
651
+ "icon": "inline-flex shrink-0 items-center justify-center rounded-lg bg-muted p-2 text-foreground",
652
+ "title": "text-sm font-medium text-muted-foreground",
653
+ # The content area. The dashboard is a grid, so a card that
654
+ # grows with its content drags every other card in its row to
655
+ # the same height; bounding the body instead keeps a long list
656
+ # inside its own card and scrolls it there, with theme.html's
657
+ # .ui-scroll-area styling the bar.
658
+ #
659
+ # It scrolls in one direction only. A card is a fixed column of
660
+ # a grid, so anything wider than it is a widget that has to
661
+ # wrap, not a second scrollbar to drag: widgets/table.html
662
+ # wraps its cells rather than holding them on one line.
663
+ "body": "ui-scroll-area max-h-80 overflow-y-auto overflow-x-hidden",
664
+ "span-lg": "sm:col-span-2 xl:col-span-3",
665
+ "bar-track": "h-2 w-full rounded-full bg-muted",
666
+ "bar-fill": "h-2 rounded-full bg-primary",
667
+ },
668
+ },
669
+
670
+ # Form fieldset (Django's `fieldsets`): a titled, collapsible group
671
+ # of fields inside the record form. A group with no title renders
672
+ # bare -- no header, no border -- so the default single-group case
673
+ # looks exactly like the flat form it replaces.
674
+ "fieldset": {
675
+ "base": "mb-6 last:mb-0 rounded-lg border border-border bg-muted/20",
676
+ "parts": {
677
+ "trigger": "flex w-full items-center justify-between gap-2 rounded-t-lg px-4 py-3 text-left transition-colors hover:bg-muted/40",
678
+ "title": "text-sm font-semibold text-foreground",
679
+ "description": "px-4 pb-2 text-sm text-muted-foreground",
680
+ "body": "border-t border-border p-4",
681
+ "icon": "size-4 shrink-0 text-muted-foreground transition-transform",
682
+ },
683
+ },
684
+
685
+ # The detail page's History panel: a compact recent-activity list,
686
+ # shown only when the configured audit logger can read back.
687
+ "history": {
688
+ "base": "mt-6 rounded-lg border border-border bg-card",
689
+ "parts": {
690
+ "title": "border-b border-border px-4 py-2.5 text-sm font-semibold text-foreground",
691
+ "row": "flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 px-4 py-2 text-sm",
692
+ "when": "text-muted-foreground tabular-nums",
693
+ "who": "font-medium text-foreground",
694
+ "what": "text-muted-foreground",
695
+ "empty": "px-4 py-3 text-sm text-muted-foreground",
696
+ },
697
+ },
698
+
699
+ "panel": {
700
+ "base": "rounded-lg border border-border bg-card p-4 text-card-foreground",
701
+ "parts": {
702
+ "dashed": "rounded-lg border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground",
703
+ "form": "w-full rounded-lg border border-border bg-card p-7 text-card-foreground shadow-sm",
704
+ },
705
+ },
706
+ # Record-page shell for detail/create/edit: one full-height column
707
+ # holding the record and an action bar pinned to the bottom of the
708
+ # viewport. Used by resource/detail.html and components/
709
+ # form_wrapper.html so the two never disagree about width or
710
+ # alignment.
711
+ "page": {
712
+ # min-h-full (not h-full) is what lets the column grow past the
713
+ # viewport for a long record instead of clipping it.
714
+ "base": "flex min-h-full flex-col",
715
+ "parts": {
716
+ # my-auto gives "centred when it fits, scrolls when it
717
+ # doesn't" with no media query: free space splits above and
718
+ # below, and collapses to 0 once the content overflows.
719
+ "body": "mx-auto my-auto w-full max-w-xl space-y-4",
720
+ # max-w-xl is the measure for a column of form fields, but a
721
+ # tabular inline is a table: squeezing one into 576px is what
722
+ # clipped its own row actions, so a page holding one gets the
723
+ # wider cap.
724
+ "body-wide": "mx-auto my-auto w-full max-w-5xl space-y-4",
725
+ # sticky rather than fixed: it reserves its own space inside
726
+ # <main>'s scroll container, so it needs no sidebar-width
727
+ # offset and no compensating bottom padding. The negative
728
+ # margins cancel <main>'s p-4 so it spans the full width.
729
+ "actions": (
730
+ "sticky bottom-0 z-10 -mx-4 -mb-4 mt-4 border-t border-border "
731
+ "bg-background/95 px-4 py-3 backdrop-blur"
732
+ ),
733
+ # Matched to "body"'s max-w-xl so the buttons line up with the
734
+ # record. flex-col-reverse below sm floats the primary group
735
+ # to the top, so Delete is never the button under your thumb
736
+ # when the bar stacks.
737
+ "actions-inner": (
738
+ "mx-auto flex w-full max-w-xl flex-col-reverse gap-2 "
739
+ "sm:flex-row sm:items-center"
740
+ ),
741
+ # Matched to "body-wide" for the same reason "actions-inner"
742
+ # is matched to "body": the bar has to line up with the card
743
+ # above it, so widening one without the other leaves the
744
+ # buttons drifting.
745
+ "actions-inner-wide": (
746
+ "mx-auto flex w-full max-w-5xl flex-col-reverse gap-2 "
747
+ "sm:flex-row sm:items-center"
748
+ ),
749
+ # The right-hand group. sm:ml-auto does the separating, so
750
+ # the bar reads Delete-left / everything-else-right when a
751
+ # Delete is present and simply right-aligns when it isn't
752
+ # (create, and detail once Delete moved to the edit page).
753
+ "actions-primary": "flex flex-col gap-2 sm:ml-auto sm:flex-row",
754
+ },
755
+ },
756
+ }
757
+
758
+
759
+ class UnknownUIVariant(KeyError):
760
+ """Raised for an unknown component or modifier.
761
+
762
+ A typo raises rather than resolving to an empty class string, so it
763
+ surfaces in the adapter's own render tests instead of shipping an
764
+ unstyled button.
765
+ """
766
+
767
+
768
+ def _group(component: str, name: str) -> dict[str, str]:
769
+ return UI_REGISTRY[component].get(name, {}) # type: ignore[return-value]
770
+
771
+
772
+ def ui(component: str, *modifiers: str) -> str:
773
+ """Resolve a component's class string from :data:`UI_REGISTRY`.
774
+
775
+ A modifier naming a part resolves to that part alone; variant and
776
+ size modifiers compose with the base, defaulting whichever axis was
777
+ left out. Registered as the ``ui`` Jinja global.
778
+ """
779
+ if component not in UI_REGISTRY:
780
+ known = ", ".join(sorted(UI_REGISTRY))
781
+ raise UnknownUIVariant(f"unknown ui component {component!r} (known: {known})")
782
+
783
+ variants = _group(component, "variants")
784
+ sizes = _group(component, "sizes")
785
+ parts = _group(component, "parts")
786
+
787
+ # A single part reference stands alone.
788
+ if len(modifiers) == 1 and modifiers[0] in parts:
789
+ return parts[modifiers[0]]
790
+
791
+ base = UI_REGISTRY[component].get("base", "")
792
+ resolved: list[str] = [base] if base else []
793
+
794
+ has_size = any(m.startswith("size-") for m in modifiers)
795
+ has_variant = any(not m.startswith("size-") for m in modifiers)
796
+ if not has_variant and "default" in variants:
797
+ resolved.append(variants["default"])
798
+ if not has_size and "size-default" in sizes:
799
+ resolved.append(sizes["size-default"])
800
+
801
+ for modifier in modifiers:
802
+ if modifier in variants:
803
+ resolved.append(variants[modifier])
804
+ elif modifier in sizes:
805
+ resolved.append(sizes[modifier])
806
+ elif modifier in parts:
807
+ raise UnknownUIVariant(
808
+ f"ui {component!r} part {modifier!r} cannot be combined with other "
809
+ "modifiers (a part replaces the base; request it on its own)"
810
+ )
811
+ else:
812
+ known = ", ".join(sorted({**variants, **sizes, **parts}))
813
+ raise UnknownUIVariant(
814
+ f"ui component {component!r} has no modifier {modifier!r} (known: {known})"
815
+ )
816
+
817
+ return " ".join(p for p in resolved if p)