@ultimat3/admin 22.15.0 → 24.0.0
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.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
package/src/actions.tsx
CHANGED
|
@@ -1,34 +1,71 @@
|
|
|
1
|
-
// Action buttons. The component renders `actionButtons()` and nothing
|
|
2
|
-
// not return a button, no markup exists for it — not hidden with CSS, not
|
|
3
|
-
// gate then decides the
|
|
1
|
+
// Action buttons, as native form submits. The component renders `actionButtons()` and nothing
|
|
2
|
+
// else: if the gate did not return a button, no markup exists for it — not hidden with CSS, not
|
|
3
|
+
// disabled. The same gate then decides the POST, so there is one authz system and the UI cannot
|
|
4
|
+
// lie about it.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
6
|
+
// A `<form method="post">` and a `type="submit"`, because an admin screen is `hydrate: 'never'`:
|
|
7
|
+
// the browser's own form handling is the client. These were `type="button"` with an `onClick`
|
|
8
|
+
// until the screens were mounted — markup that reads as a control and can never act.
|
|
8
9
|
|
|
9
10
|
import { t } from '@ultimat3/i18n';
|
|
10
|
-
import {
|
|
11
|
+
import { Button, Field, Input, Link } from '@ultimat3/ui';
|
|
11
12
|
import type { JSX } from 'solid-js';
|
|
12
|
-
import {
|
|
13
|
+
import { actionButtons } from './action-gate';
|
|
14
|
+
import styles from './admin.module.scss';
|
|
13
15
|
import type { AdminActor, AdminAuthz, AdminSubject } from './authz';
|
|
14
16
|
import { confirmationToken } from './permissions';
|
|
15
17
|
import type { AdminAction } from './registry';
|
|
16
18
|
|
|
19
|
+
/** The posted field that names which write a form is: an action here, a delete on the detail. */
|
|
20
|
+
export const OPERATION_FIELD = '_operation';
|
|
21
|
+
export const ACTION_OPERATION = 'action';
|
|
22
|
+
export const DELETE_OPERATION = 'delete';
|
|
23
|
+
/** A batch: posted at the LIST's URL, whose query string is the filter "all matching" means. */
|
|
24
|
+
export const BATCH_OPERATION = 'batch';
|
|
25
|
+
|
|
26
|
+
/** The query parameter a row's URL opens an action's own form with: `<row>?action=<name>`. */
|
|
27
|
+
export const ACTION_PARAM = 'action';
|
|
28
|
+
|
|
29
|
+
/** Where an action's form lives: the row's own URL, asked for that action. */
|
|
30
|
+
export const actionFormHref = (rowHref: string, name: string): string =>
|
|
31
|
+
`${rowHref}?${ACTION_PARAM}=${encodeURIComponent(name)}`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The echo a destructive write must carry. Typed, not pre-filled: the token is on screen so it
|
|
35
|
+
* can be copied, and copying it is the deliberate act.
|
|
36
|
+
*/
|
|
37
|
+
export function ConfirmationField(props: { readonly token: string }): JSX.Element {
|
|
38
|
+
return (
|
|
39
|
+
<Field label={t('admin.actions.confirm.body', { token: props.token })} required>
|
|
40
|
+
{(control) => (
|
|
41
|
+
<Input
|
|
42
|
+
id={control.id}
|
|
43
|
+
aria-describedby={control['aria-describedby']}
|
|
44
|
+
name="confirmation"
|
|
45
|
+
autocomplete="off"
|
|
46
|
+
size="sm"
|
|
47
|
+
required
|
|
48
|
+
/>
|
|
49
|
+
)}
|
|
50
|
+
</Field>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
export interface AdminActionsProps {
|
|
18
55
|
readonly actions: readonly AdminAction[];
|
|
19
56
|
readonly actor: AdminActor;
|
|
20
57
|
readonly authz: AdminAuthz;
|
|
21
|
-
// `| undefined` is explicit
|
|
22
|
-
//
|
|
58
|
+
// `| undefined` is explicit: under exactOptionalPropertyTypes a parent that forwards its own
|
|
59
|
+
// optional subject would otherwise not typecheck.
|
|
23
60
|
readonly subject?: AdminSubject | undefined;
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
readonly
|
|
30
|
-
|
|
31
|
-
readonly
|
|
61
|
+
/**
|
|
62
|
+
* Where the forms post: the URL of the row the action is about (`<base>/<resource>/<id>`), or
|
|
63
|
+
* the admin's base path for an app-wide action. The subject is the URL's, never a hidden field
|
|
64
|
+
* a stale page could have got wrong.
|
|
65
|
+
*/
|
|
66
|
+
readonly href: string;
|
|
67
|
+
/** The resource's sealed column names: a `when` never reads one. */
|
|
68
|
+
readonly sealed?: readonly string[] | undefined;
|
|
32
69
|
}
|
|
33
70
|
|
|
34
71
|
export function AdminActions(props: AdminActionsProps): JSX.Element {
|
|
@@ -37,58 +74,44 @@ export function AdminActions(props: AdminActionsProps): JSX.Element {
|
|
|
37
74
|
actor: props.actor,
|
|
38
75
|
authz: props.authz,
|
|
39
76
|
...(props.subject === undefined ? {} : { subject: props.subject }),
|
|
77
|
+
...(props.sealed === undefined ? {} : { sealed: props.sealed }),
|
|
40
78
|
});
|
|
41
79
|
|
|
42
|
-
const expected = confirmationToken(props.subject?.entity ?? 'admin', props.subject?.id ?? '');
|
|
43
|
-
const pending = props.pending ?? null;
|
|
44
|
-
const typed = props.confirmation ?? '';
|
|
45
|
-
|
|
46
|
-
const press = (button: AdminActionButton): void => {
|
|
47
|
-
if (button.destructive) props.onRequestConfirm?.(button);
|
|
48
|
-
else props.onRun?.(button, '');
|
|
49
|
-
};
|
|
50
|
-
|
|
51
80
|
if (buttons.length === 0) return <span class="x-admin-actions-empty" />;
|
|
52
81
|
|
|
53
|
-
|
|
54
|
-
<fieldset class="x-admin-actions" aria-label={t('admin.actions.label')}>
|
|
55
|
-
{buttons.map((button) => (
|
|
56
|
-
<button
|
|
57
|
-
type="button"
|
|
58
|
-
data-destructive={button.destructive ? 'true' : undefined}
|
|
59
|
-
onClick={() => press(button)}
|
|
60
|
-
>
|
|
61
|
-
{t(button.labelKey)}
|
|
62
|
-
</button>
|
|
63
|
-
))}
|
|
82
|
+
const expected = confirmationToken(props.subject?.entity ?? 'admin', props.subject?.id ?? '');
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
return (
|
|
85
|
+
<span class={styles['actions']}>
|
|
86
|
+
{buttons.map((button) =>
|
|
87
|
+
// An action that takes input is a LINK to its own form — a page the server renders, at
|
|
88
|
+
// the row's URL — and never a button that would post an input nobody typed.
|
|
89
|
+
button.form ? (
|
|
90
|
+
<Link
|
|
91
|
+
appearance="button"
|
|
92
|
+
size="sm"
|
|
93
|
+
variant="secondary"
|
|
94
|
+
tone={button.destructive ? 'danger' : 'neutral'}
|
|
95
|
+
href={actionFormHref(props.href, button.name)}
|
|
96
|
+
>
|
|
97
|
+
{t(button.labelKey)}
|
|
98
|
+
</Link>
|
|
99
|
+
) : (
|
|
100
|
+
<form class={styles['actionForm']} method="post" action={props.href}>
|
|
101
|
+
<input type="hidden" name={OPERATION_FIELD} value={ACTION_OPERATION} />
|
|
102
|
+
<input type="hidden" name="name" value={button.name} />
|
|
103
|
+
{button.destructive ? <ConfirmationField token={expected} /> : null}
|
|
104
|
+
<Button
|
|
105
|
+
type="submit"
|
|
106
|
+
size="sm"
|
|
107
|
+
variant="secondary"
|
|
108
|
+
tone={button.destructive ? 'danger' : 'neutral'}
|
|
109
|
+
>
|
|
110
|
+
{t(button.labelKey)}
|
|
111
|
+
</Button>
|
|
112
|
+
</form>
|
|
113
|
+
),
|
|
114
|
+
)}
|
|
115
|
+
</span>
|
|
93
116
|
);
|
|
94
117
|
}
|
package/src/actor.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Who the dashboard is acting as, by default: the actor the HTTP pipeline already resolved for
|
|
2
|
+
// this request, read off the framework's own request context — so the admin cannot grow a session
|
|
3
|
+
// lookup of its own beside the app's.
|
|
4
|
+
|
|
5
|
+
import { type Actor, isAnonymous, tryUseContext } from '@ultimat3/core';
|
|
6
|
+
import type { AdminActor } from './authz';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `null` for anonymous, and that is a real answer rather than a missing one: every operation an
|
|
10
|
+
* unauthenticated caller asks about is refused by the same `decideAll()` that refuses a signed-in
|
|
11
|
+
* actor who lacks the grant. The tenant rides along — a role-only rule would otherwise allow a row
|
|
12
|
+
* from another org.
|
|
13
|
+
*/
|
|
14
|
+
export const adminActorFrom = (
|
|
15
|
+
actor: Actor,
|
|
16
|
+
locale: string,
|
|
17
|
+
timeZone: string,
|
|
18
|
+
): AdminActor | null =>
|
|
19
|
+
isAnonymous(actor)
|
|
20
|
+
? null
|
|
21
|
+
: {
|
|
22
|
+
id: actor.id,
|
|
23
|
+
roles: actor.roles,
|
|
24
|
+
locale,
|
|
25
|
+
timeZone,
|
|
26
|
+
...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface AdminRequestActor {
|
|
30
|
+
readonly actor: AdminActor | null;
|
|
31
|
+
/** Every audit row is keyed by it, so a denial is traceable to the request that caused it. */
|
|
32
|
+
readonly requestId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The id an audit row carries when no request is in flight — a prerender, a script, a test. */
|
|
36
|
+
export const NO_REQUEST_ID = 'no-request';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The actor for the in-flight request. `tryUseContext()` and not `useContext()`: a test calls a
|
|
40
|
+
* screen with no request in flight, and "no context" is anonymous, not a crash.
|
|
41
|
+
*/
|
|
42
|
+
export const requestActor = (): AdminRequestActor => {
|
|
43
|
+
const ctx = tryUseContext();
|
|
44
|
+
if (ctx === undefined) return { actor: null, requestId: NO_REQUEST_ID };
|
|
45
|
+
return { actor: adminActorFrom(ctx.actor, ctx.locale, ctx.tz), requestId: ctx.requestId };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Anonymous is a real actor id on a decision — `decideAll()` refuses it exactly as it refuses a
|
|
50
|
+
* signed-in actor missing the grant, and an audit row that says `anonymous` is more useful than
|
|
51
|
+
* one that says nothing.
|
|
52
|
+
*/
|
|
53
|
+
export const ANONYMOUS_ADMIN_ACTOR: AdminActor = Object.freeze({ id: 'anonymous', roles: [] });
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// The admin's own layer, on @ultimat3/ui's tokens directly. Semantic roles and token rungs only:
|
|
2
|
+
// a raw colour here would paint one theme correctly and the other one wrong, and a raw length is
|
|
3
|
+
// a second scale beside the one every other screen is drawn on.
|
|
4
|
+
|
|
5
|
+
@use '@ultimat3/ui/tokens' as t;
|
|
6
|
+
|
|
7
|
+
.shell {
|
|
8
|
+
display: grid;
|
|
9
|
+
grid-template-columns: minmax(t.rem(192), t.rem(256)) 1fr;
|
|
10
|
+
gap: t.space(6);
|
|
11
|
+
min-block-size: 100dvh;
|
|
12
|
+
background: t.role('bg');
|
|
13
|
+
color: t.role('fg');
|
|
14
|
+
|
|
15
|
+
// One column under the `md` rung: the sidebar becomes a block above the screen it navigates.
|
|
16
|
+
@include t.respond-down('md') {
|
|
17
|
+
grid-template-columns: 1fr;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.skip {
|
|
22
|
+
@include t.visually-hidden;
|
|
23
|
+
|
|
24
|
+
&:focus-visible {
|
|
25
|
+
@include t.focus-ring;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.side {
|
|
30
|
+
@include t.surface('surface', 'sm');
|
|
31
|
+
@include t.padding-block(t.space(6));
|
|
32
|
+
@include t.padding-inline(t.space(4));
|
|
33
|
+
@include t.column(t.space(4));
|
|
34
|
+
|
|
35
|
+
border-inline-end: t.stroke(hairline) solid t.role('line');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.brand {
|
|
39
|
+
@include t.row(t.space(2));
|
|
40
|
+
|
|
41
|
+
font-size: t.text('lg');
|
|
42
|
+
font-weight: t.weight('semibold');
|
|
43
|
+
color: t.role('fg-strong');
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.actor {
|
|
48
|
+
margin: 0;
|
|
49
|
+
font-size: t.text('sm');
|
|
50
|
+
color: t.role('fg-muted');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.search {
|
|
54
|
+
display: grid;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.hidden {
|
|
58
|
+
@include t.visually-hidden;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.group {
|
|
62
|
+
display: grid;
|
|
63
|
+
gap: t.space(2);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.groupTitle {
|
|
67
|
+
@include t.label-caps;
|
|
68
|
+
|
|
69
|
+
margin: 0;
|
|
70
|
+
color: t.role('fg-muted');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.navList {
|
|
74
|
+
display: grid;
|
|
75
|
+
gap: t.space(1);
|
|
76
|
+
padding: 0;
|
|
77
|
+
margin: 0;
|
|
78
|
+
list-style: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.navLink {
|
|
82
|
+
@include t.transition(background-color, color);
|
|
83
|
+
|
|
84
|
+
display: block;
|
|
85
|
+
padding: t.space(2) t.space(3);
|
|
86
|
+
border-radius: t.radius('md');
|
|
87
|
+
color: t.role('fg');
|
|
88
|
+
text-decoration: none;
|
|
89
|
+
|
|
90
|
+
&:hover,
|
|
91
|
+
&[aria-current='page'] {
|
|
92
|
+
background: t.role('bg-soft');
|
|
93
|
+
color: t.role('fg-strong');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
&:focus-visible {
|
|
97
|
+
@include t.focus-ring;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.main {
|
|
102
|
+
@include t.padding-block(t.space(8));
|
|
103
|
+
@include t.padding-inline(t.space(6));
|
|
104
|
+
@include t.column(t.space(6));
|
|
105
|
+
|
|
106
|
+
min-inline-size: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.title {
|
|
110
|
+
margin: 0;
|
|
111
|
+
font-size: t.text('2xl');
|
|
112
|
+
color: t.role('fg-strong');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.toolbar {
|
|
116
|
+
@include t.row(t.space(3), center, space-between);
|
|
117
|
+
|
|
118
|
+
flex-wrap: wrap;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.panel {
|
|
122
|
+
display: grid;
|
|
123
|
+
gap: t.space(4);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.matrix {
|
|
127
|
+
inline-size: 100%;
|
|
128
|
+
border-collapse: collapse;
|
|
129
|
+
font-size: t.text('sm');
|
|
130
|
+
|
|
131
|
+
th,
|
|
132
|
+
td {
|
|
133
|
+
padding: t.space(2) t.space(3);
|
|
134
|
+
text-align: start;
|
|
135
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
136
|
+
vertical-align: top;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
th {
|
|
140
|
+
color: t.role('fg-muted');
|
|
141
|
+
font-weight: t.weight('semibold');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The two verdict rows carry a tone, not a colour: `success` and `danger` both resolve in the
|
|
146
|
+
// light and the dark scale.
|
|
147
|
+
.allowed td:last-child {
|
|
148
|
+
color: t.role('success');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.denied td:last-child {
|
|
152
|
+
color: t.role('danger');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.mono {
|
|
156
|
+
@include t.data-text;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.fields {
|
|
160
|
+
display: grid;
|
|
161
|
+
grid-template-columns: minmax(t.rem(128), max-content) 1fr;
|
|
162
|
+
gap: t.space(2) t.space(4);
|
|
163
|
+
margin: 0;
|
|
164
|
+
|
|
165
|
+
dt {
|
|
166
|
+
color: t.role('fg-muted');
|
|
167
|
+
font-size: t.text('sm');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dd {
|
|
171
|
+
margin: 0;
|
|
172
|
+
min-inline-size: 0;
|
|
173
|
+
overflow-wrap: anywhere;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.form {
|
|
178
|
+
@include t.column(t.space(4));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// One titled group of a detail page or a form. A `<section>` on the detail, a `<fieldset>` on the
|
|
182
|
+
// form — the same spacing, so the two read as one layout.
|
|
183
|
+
.section {
|
|
184
|
+
@include t.column(t.space(3));
|
|
185
|
+
|
|
186
|
+
padding: 0;
|
|
187
|
+
margin: 0;
|
|
188
|
+
border: 0;
|
|
189
|
+
|
|
190
|
+
h3,
|
|
191
|
+
legend {
|
|
192
|
+
margin: 0;
|
|
193
|
+
padding: 0;
|
|
194
|
+
font-size: t.text('sm');
|
|
195
|
+
font-weight: t.weight('semibold');
|
|
196
|
+
color: t.role('fg-muted');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The list's batch bar: what to run, over which rows, and the submit — one native form whose
|
|
201
|
+
// row checkboxes sit in the table and join it through their `form` attribute.
|
|
202
|
+
.batch {
|
|
203
|
+
@include t.row(t.space(3), end);
|
|
204
|
+
|
|
205
|
+
flex-wrap: wrap;
|
|
206
|
+
margin: 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The counts a batch answers with, one term per outcome.
|
|
210
|
+
.counts {
|
|
211
|
+
display: grid;
|
|
212
|
+
grid-template-columns: max-content max-content;
|
|
213
|
+
gap: t.space(1) t.space(4);
|
|
214
|
+
margin: 0;
|
|
215
|
+
|
|
216
|
+
dt {
|
|
217
|
+
color: t.role('fg-muted');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
dd {
|
|
221
|
+
@include t.data-text;
|
|
222
|
+
|
|
223
|
+
margin: 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.actions {
|
|
228
|
+
@include t.row(t.space(2));
|
|
229
|
+
|
|
230
|
+
flex-wrap: wrap;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One form per button — a native POST is the whole client on a page that never hydrates, and a
|
|
234
|
+
// single form around several submits could not say which one was pressed.
|
|
235
|
+
.actionForm {
|
|
236
|
+
@include t.row(t.space(2));
|
|
237
|
+
|
|
238
|
+
display: inline-flex;
|
|
239
|
+
margin: 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
.note,
|
|
244
|
+
.refusal {
|
|
245
|
+
margin: 0;
|
|
246
|
+
font-size: t.text('sm');
|
|
247
|
+
color: t.role('fg-muted');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.refusal {
|
|
251
|
+
@include t.surface('surface-raised', 'sm');
|
|
252
|
+
@include t.padding-block(t.space(4));
|
|
253
|
+
@include t.padding-inline(t.space(4));
|
|
254
|
+
|
|
255
|
+
border-inline-start: t.stroke(heavy) solid t.role('danger');
|
|
256
|
+
border-radius: t.radius('md');
|
|
257
|
+
color: t.role('fg');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.list {
|
|
261
|
+
display: grid;
|
|
262
|
+
gap: t.space(2);
|
|
263
|
+
padding: 0;
|
|
264
|
+
margin: 0;
|
|
265
|
+
list-style: none;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The filter bar: controls flow and wrap, each as wide as its label needs, with the submit last.
|
|
269
|
+
.filters {
|
|
270
|
+
@include t.row(t.space(3), end);
|
|
271
|
+
|
|
272
|
+
flex-wrap: wrap;
|
|
273
|
+
margin: 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Two bounds of one filter, or a control and the link beside it.
|
|
277
|
+
.range {
|
|
278
|
+
@include t.row(t.space(2), center);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.scopes {
|
|
282
|
+
@include t.row(t.space(1));
|
|
283
|
+
|
|
284
|
+
flex-wrap: wrap;
|
|
285
|
+
padding: 0;
|
|
286
|
+
margin: 0;
|
|
287
|
+
list-style: none;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The jobs overview: a row of figure tiles that wraps, each a link to the list it counts.
|
|
291
|
+
.tiles {
|
|
292
|
+
display: grid;
|
|
293
|
+
grid-template-columns: repeat(auto-fill, minmax(t.rem(160), 1fr));
|
|
294
|
+
gap: t.space(3);
|
|
295
|
+
padding: 0;
|
|
296
|
+
margin: 0;
|
|
297
|
+
list-style: none;
|
|
298
|
+
}
|