@fougere/admin 0.3.0-alpha.0 → 0.4.0-alpha.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/dist/dashboard.d.ts +3 -3
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/facets.d.ts.map +1 -1
- package/dist/facets.js +2 -24
- package/dist/facets.js.map +1 -1
- package/dist/provider.d.ts +4 -4
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js.map +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +4 -2
- package/dist/react.js.map +1 -1
- package/dist/resources.d.ts +1 -1
- package/dist/resources.d.ts.map +1 -1
- package/dist/resources.js +4 -4
- package/dist/resources.js.map +1 -1
- package/package.json +6 -5
- package/src/dashboard.tsx +611 -0
- package/src/extensions.ts +125 -0
- package/src/facets.ts +79 -0
- package/src/index.ts +59 -0
- package/src/provider.ts +269 -0
- package/src/react.tsx +522 -0
- package/src/resources.ts +147 -0
- package/src/runtime.ts +68 -0
- package/src/theme.tsx +527 -0
- package/src/topology-page.tsx +193 -0
- package/src/topology.ts +87 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** One card request, shared by resource discovery and every provider operation. */
|
|
2
|
+
import {
|
|
3
|
+
CALL_ENDPOINT,
|
|
4
|
+
fetcher as browserFetcher,
|
|
5
|
+
type Fetcher,
|
|
6
|
+
} from '@fougere/app/client';
|
|
7
|
+
import { applyAdminExtensions, type AdminExtension } from './extensions.js';
|
|
8
|
+
import {
|
|
9
|
+
createDataProvider,
|
|
10
|
+
createLazyDataProvider,
|
|
11
|
+
type FougereDataProvider,
|
|
12
|
+
} from './provider.js';
|
|
13
|
+
import { fetchCard, keysOf, resourcesOf, type AdminResource } from './resources.js';
|
|
14
|
+
|
|
15
|
+
export interface AdminRuntimeOptions {
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
fetcher?: Fetcher;
|
|
18
|
+
extensions?: readonly AdminExtension[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LoadedAdmin {
|
|
22
|
+
resources: AdminResource[];
|
|
23
|
+
provider: FougereDataProvider;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AdminRuntime {
|
|
27
|
+
/** Loads and caches the card-derived model. */
|
|
28
|
+
load(): Promise<LoadedAdmin>;
|
|
29
|
+
/** Synchronous shell required by react-admin; delegates after `load()` resolves. */
|
|
30
|
+
dataProvider: FougereDataProvider;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAdminRuntime(options: AdminRuntimeOptions = {}): AdminRuntime {
|
|
34
|
+
const {
|
|
35
|
+
endpoint = CALL_ENDPOINT,
|
|
36
|
+
fetcher = browserFetcher,
|
|
37
|
+
extensions = [],
|
|
38
|
+
} = options;
|
|
39
|
+
let loading: Promise<LoadedAdmin> | undefined;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One card request, shared — and a REFUSED one is forgotten.
|
|
43
|
+
*
|
|
44
|
+
* `loading ??=` alone memoizes the rejection too, so a receiver that was down when the
|
|
45
|
+
* panel opened stays down for the life of the tab: every later attempt awaits the same
|
|
46
|
+
* dead promise and there is no path back. Clearing the slot on failure is what makes a
|
|
47
|
+
* retry mean anything, and the rejection is re-thrown so the caller can say so — a
|
|
48
|
+
* discovery that fails silently leaves react-admin on its loading page forever, which
|
|
49
|
+
* is the one state where the panel tells the operator nothing at all.
|
|
50
|
+
*/
|
|
51
|
+
const load = (): Promise<LoadedAdmin> => loading ??= fetchCard(endpoint, fetcher)
|
|
52
|
+
.then((card) => {
|
|
53
|
+
const resources = applyAdminExtensions(resourcesOf(card), extensions);
|
|
54
|
+
return {
|
|
55
|
+
resources,
|
|
56
|
+
provider: createDataProvider({ resources: keysOf(resources), endpoint, fetcher }),
|
|
57
|
+
};
|
|
58
|
+
})
|
|
59
|
+
.catch((error: unknown) => {
|
|
60
|
+
loading = undefined;
|
|
61
|
+
throw error;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
load,
|
|
66
|
+
dataProvider: createLazyDataProvider(async () => (await load()).provider),
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/theme.tsx
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AppBar,
|
|
4
|
+
Layout,
|
|
5
|
+
Menu,
|
|
6
|
+
TitlePortal,
|
|
7
|
+
defaultDarkTheme,
|
|
8
|
+
defaultLightTheme,
|
|
9
|
+
useTranslate,
|
|
10
|
+
type AppBarProps,
|
|
11
|
+
type LayoutProps,
|
|
12
|
+
type RaThemeOptions,
|
|
13
|
+
} from 'react-admin';
|
|
14
|
+
import { SvgIcon, type SvgIconProps } from '@mui/material';
|
|
15
|
+
|
|
16
|
+
type Mode = 'light' | 'dark';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The accent, and only the accent.
|
|
20
|
+
*
|
|
21
|
+
* One saturated colour in the whole panel: the primary action, the focus ring, the
|
|
22
|
+
* active menu item, the mark. Everything else is neutral, and a STATUS is semantic
|
|
23
|
+
* (green/amber/red/grey) rather than brand-tinted — the version before this painted
|
|
24
|
+
* every chip in brand green, so `draft`, `published` and `suspended` were one colour.
|
|
25
|
+
*/
|
|
26
|
+
const fern = {
|
|
27
|
+
50: '#EDF9F2', 100: '#D3F0E0', 200: '#A8E1C3', 300: '#74CDA1', 400: '#45B37F',
|
|
28
|
+
500: '#22995F', 600: '#16794D', 700: '#116040', 800: '#0E4C34', 900: '#0B3D2B',
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The neutrals, by ROLE rather than by lightness.
|
|
33
|
+
*
|
|
34
|
+
* Nine names that each answer one question — what is behind the page, what is behind a
|
|
35
|
+
* card, which line separates two things, which grey is text. Picking a grey per site is
|
|
36
|
+
* what produces borders that jump between components and hovers nobody can see, and it
|
|
37
|
+
* is the difference this file could not state while it held three hex values.
|
|
38
|
+
*
|
|
39
|
+
* Dark is not an inversion: it is the same roles, instantiated again. A dark canvas is
|
|
40
|
+
* `#0B0C0E`, never `#000` — pure black has no room for a surface above it.
|
|
41
|
+
*/
|
|
42
|
+
const neutral = {
|
|
43
|
+
light: {
|
|
44
|
+
canvas: '#FCFCFD', surface: '#FFFFFF', subtle: '#F7F8F9', sunken: '#F1F3F5',
|
|
45
|
+
borderSubtle: '#ECEEF0', border: '#E1E4E8', borderStrong: '#D0D5DA',
|
|
46
|
+
muted: '#6E7781', text: '#1A1D21',
|
|
47
|
+
},
|
|
48
|
+
dark: {
|
|
49
|
+
canvas: '#0B0C0E', surface: '#141619', subtle: '#1A1D21', sunken: '#101215',
|
|
50
|
+
borderSubtle: '#212529', border: '#2A2F35', borderStrong: '#3A4048',
|
|
51
|
+
muted: '#969DA6', text: '#E6E9EC',
|
|
52
|
+
},
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
const createFougereTheme = (mode: Mode): RaThemeOptions => {
|
|
56
|
+
const dark = mode === 'dark';
|
|
57
|
+
const base = dark ? defaultDarkTheme : defaultLightTheme;
|
|
58
|
+
const c = neutral[mode];
|
|
59
|
+
const accent = dark ? '#4FC08D' : fern[600];
|
|
60
|
+
const ring = dark ? 'rgba(79,192,141,0.32)' : 'rgba(22,121,77,0.20)';
|
|
61
|
+
/**
|
|
62
|
+
* The one shadow in the file, and it is for what FLOATS — a menu, a popover, a dialog.
|
|
63
|
+
* A card is separated by its edge; a thing that hovers over the page is separated by
|
|
64
|
+
* the light it blocks, and removing that shadow too would make a menu unreadable.
|
|
65
|
+
*/
|
|
66
|
+
const overlay = dark
|
|
67
|
+
? '0 10px 30px -10px rgba(0,0,0,.70), 0 2px 8px -4px rgba(0,0,0,.60)'
|
|
68
|
+
: '0 8px 24px -8px rgba(16,24,32,.16), 0 2px 6px -2px rgba(16,24,32,.10)';
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
palette: {
|
|
73
|
+
...base.palette,
|
|
74
|
+
mode,
|
|
75
|
+
primary: {
|
|
76
|
+
main: accent,
|
|
77
|
+
light: dark ? '#7AD3AA' : fern[500],
|
|
78
|
+
dark: dark ? '#22995F' : fern[700],
|
|
79
|
+
contrastText: dark ? '#06180F' : '#FFFFFF',
|
|
80
|
+
},
|
|
81
|
+
secondary: { main: dark ? '#C9A227' : '#8A6D1F', contrastText: dark ? '#171203' : '#FFFFFF' },
|
|
82
|
+
background: { default: c.canvas, paper: c.surface },
|
|
83
|
+
text: { primary: c.text, secondary: c.muted, disabled: c.borderStrong },
|
|
84
|
+
divider: c.border,
|
|
85
|
+
success: { main: dark ? '#4FC08D' : '#127A4C' },
|
|
86
|
+
warning: { main: dark ? '#E0B341' : '#9A6B10' },
|
|
87
|
+
error: { main: dark ? '#F0736A' : '#C1372F' },
|
|
88
|
+
info: { main: dark ? '#67A9F5' : '#1D6FD0' },
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
shape: { borderRadius: 8 },
|
|
92
|
+
sidebar: { width: 232, closedWidth: 56 },
|
|
93
|
+
spacing: 8,
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A short scale, tightly set. A back-office is read at 13px, not 16 — the air
|
|
97
|
+
* belongs BETWEEN blocks, not inside the controls. Headings climb little (h1 is
|
|
98
|
+
* 24px, not 32) because nothing on an operator's screen needs to shout.
|
|
99
|
+
*
|
|
100
|
+
* The weights are 500/550/600/620, which only a VARIABLE font honours — the host
|
|
101
|
+
* must load one (`@fontsource-variable/inter`, or any variable family in the stack).
|
|
102
|
+
* With a static font every one of them rounds to 700, which is what the file did for
|
|
103
|
+
* its whole life: `Inter` was named here and loaded nowhere, so the panel ran on the
|
|
104
|
+
* system stack with four bold weights and read as a 2019 Material app.
|
|
105
|
+
*/
|
|
106
|
+
typography: {
|
|
107
|
+
...base.typography,
|
|
108
|
+
fontFamily: [
|
|
109
|
+
'"Inter Variable"', 'Inter', 'ui-sans-serif', '-apple-system',
|
|
110
|
+
'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif',
|
|
111
|
+
].join(', '),
|
|
112
|
+
fontSize: 13.5,
|
|
113
|
+
h1: { fontSize: '1.5rem', lineHeight: 1.25, fontWeight: 620, letterSpacing: '-0.021em' },
|
|
114
|
+
h2: { fontSize: '1.25rem', lineHeight: 1.3, fontWeight: 620, letterSpacing: '-0.018em' },
|
|
115
|
+
h3: { fontSize: '1.0625rem', lineHeight: 1.35, fontWeight: 600, letterSpacing: '-0.014em' },
|
|
116
|
+
h4: { fontSize: '0.9375rem', lineHeight: 1.4, fontWeight: 600, letterSpacing: '-0.008em' },
|
|
117
|
+
h5: { fontSize: '0.875rem', lineHeight: 1.45, fontWeight: 600, letterSpacing: '-0.004em' },
|
|
118
|
+
h6: { fontSize: '0.8125rem', lineHeight: 1.45, fontWeight: 600, letterSpacing: 0 },
|
|
119
|
+
body1: { fontSize: '0.875rem', lineHeight: 1.55 },
|
|
120
|
+
body2: { fontSize: '0.8125rem', lineHeight: 1.5 },
|
|
121
|
+
caption: { fontSize: '0.75rem', lineHeight: 1.45, fontWeight: 500 },
|
|
122
|
+
button: { fontSize: '0.8125rem', fontWeight: 550, letterSpacing: '-0.004em', textTransform: 'none' },
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
components: {
|
|
126
|
+
...base.components,
|
|
127
|
+
|
|
128
|
+
/** The shortest line in the file that stops this looking like Material. */
|
|
129
|
+
MuiButtonBase: { defaultProps: { disableRipple: true } },
|
|
130
|
+
|
|
131
|
+
MuiCssBaseline: {
|
|
132
|
+
styleOverrides: {
|
|
133
|
+
body: { backgroundImage: 'none', WebkitFontSmoothing: 'antialiased' },
|
|
134
|
+
'*::selection': { backgroundColor: dark ? 'rgba(79,192,141,.26)' : fern[100] },
|
|
135
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
136
|
+
'*': { transitionDuration: '0.01ms !important', animationDuration: '0.01ms !important' },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
MuiAppBar: {
|
|
142
|
+
styleOverrides: {
|
|
143
|
+
root: {
|
|
144
|
+
color: c.text,
|
|
145
|
+
backgroundColor: dark ? 'rgba(11,12,14,.78)' : 'rgba(252,252,253,.78)',
|
|
146
|
+
backgroundImage: 'none',
|
|
147
|
+
borderBottom: `1px solid ${c.borderSubtle}`,
|
|
148
|
+
boxShadow: 'none',
|
|
149
|
+
backdropFilter: 'blur(12px) saturate(1.4)',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
MuiDrawer: {
|
|
154
|
+
styleOverrides: {
|
|
155
|
+
paper: {
|
|
156
|
+
backgroundColor: c.canvas,
|
|
157
|
+
backgroundImage: 'none',
|
|
158
|
+
borderRight: `1px solid ${c.borderSubtle}`,
|
|
159
|
+
boxShadow: 'none',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
MuiPaper: {
|
|
165
|
+
styleOverrides: {
|
|
166
|
+
root: { backgroundImage: 'none' },
|
|
167
|
+
elevation1: { border: `1px solid ${c.border}`, boxShadow: 'none' },
|
|
168
|
+
elevation8: { boxShadow: overlay, border: `1px solid ${c.border}` },
|
|
169
|
+
elevation24: { boxShadow: overlay, border: `1px solid ${c.border}` },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
MuiCard: {
|
|
173
|
+
styleOverrides: {
|
|
174
|
+
root: { border: `1px solid ${c.border}`, borderRadius: 10, boxShadow: 'none' },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
MuiCardContent: {
|
|
178
|
+
styleOverrides: { root: { padding: 16, '&:last-child': { paddingBottom: 16 } } },
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Three weights, one hierarchy: `contained` is the action a screen is FOR,
|
|
183
|
+
* `outlined` an alternative to it, `text` everything else. A screen showing two
|
|
184
|
+
* contained buttons has answered no question for the reader.
|
|
185
|
+
*/
|
|
186
|
+
MuiButton: {
|
|
187
|
+
defaultProps: { disableElevation: true, size: 'small' },
|
|
188
|
+
styleOverrides: {
|
|
189
|
+
root: {
|
|
190
|
+
minHeight: 32,
|
|
191
|
+
borderRadius: 6,
|
|
192
|
+
paddingInline: 12,
|
|
193
|
+
transition: 'background-color 120ms ease, border-color 120ms ease, color 120ms ease',
|
|
194
|
+
'&.Mui-focusVisible': { outline: `2px solid ${accent}`, outlineOffset: 1 },
|
|
195
|
+
},
|
|
196
|
+
contained: { boxShadow: 'none', '&:hover': { boxShadow: 'none' } },
|
|
197
|
+
outlined: {
|
|
198
|
+
borderColor: c.border,
|
|
199
|
+
color: c.text,
|
|
200
|
+
'&:hover': { borderColor: c.borderStrong, backgroundColor: c.subtle },
|
|
201
|
+
},
|
|
202
|
+
text: { color: c.muted, '&:hover': { color: c.text, backgroundColor: c.subtle } },
|
|
203
|
+
sizeSmall: { minHeight: 28, paddingInline: 10, fontSize: '.78125rem' },
|
|
204
|
+
sizeLarge: { minHeight: 36, paddingInline: 16 },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
MuiIconButton: {
|
|
208
|
+
styleOverrides: {
|
|
209
|
+
root: {
|
|
210
|
+
borderRadius: 6,
|
|
211
|
+
padding: 6,
|
|
212
|
+
color: c.muted,
|
|
213
|
+
'&:hover': { color: c.text, backgroundColor: c.subtle },
|
|
214
|
+
'&.Mui-focusVisible': { outline: `2px solid ${accent}`, outlineOffset: 1 },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
MuiTextField: { defaultProps: { variant: 'outlined', margin: 'dense', size: 'small', fullWidth: true } },
|
|
220
|
+
MuiFormControl: { defaultProps: { variant: 'outlined', margin: 'dense', size: 'small', fullWidth: true } },
|
|
221
|
+
MuiInputLabel: { styleOverrides: { root: { fontSize: '.8125rem', color: c.muted } } },
|
|
222
|
+
MuiOutlinedInput: {
|
|
223
|
+
styleOverrides: {
|
|
224
|
+
root: {
|
|
225
|
+
borderRadius: 6,
|
|
226
|
+
minHeight: 34,
|
|
227
|
+
fontSize: '.8125rem',
|
|
228
|
+
backgroundColor: dark ? 'rgba(255,255,255,.02)' : c.surface,
|
|
229
|
+
transition: 'box-shadow 120ms ease, border-color 120ms ease',
|
|
230
|
+
'& .MuiOutlinedInput-notchedOutline': { borderColor: c.border },
|
|
231
|
+
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: c.borderStrong },
|
|
232
|
+
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: accent, borderWidth: 1 },
|
|
233
|
+
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring}` },
|
|
234
|
+
},
|
|
235
|
+
input: { paddingBlock: 7 },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/** Neutral by default. A status earns its colour from `color`, at the call site. */
|
|
240
|
+
MuiChip: {
|
|
241
|
+
styleOverrides: {
|
|
242
|
+
root: {
|
|
243
|
+
borderRadius: 6,
|
|
244
|
+
height: 22,
|
|
245
|
+
fontSize: '.75rem',
|
|
246
|
+
fontWeight: 550,
|
|
247
|
+
backgroundColor: c.subtle,
|
|
248
|
+
color: c.text,
|
|
249
|
+
border: `1px solid ${c.borderSubtle}`,
|
|
250
|
+
},
|
|
251
|
+
label: { paddingInline: 8 },
|
|
252
|
+
colorPrimary: {
|
|
253
|
+
backgroundColor: dark ? 'rgba(79,192,141,.14)' : fern[50],
|
|
254
|
+
color: dark ? '#8FDCB8' : fern[700],
|
|
255
|
+
borderColor: dark ? 'rgba(79,192,141,.24)' : fern[200],
|
|
256
|
+
},
|
|
257
|
+
// The root above paints every chip neutral, so a chip asking for `error` used to
|
|
258
|
+
// come out identical to the count beside it — and a refusal count that reads like
|
|
259
|
+
// a total is the one number on a topology page that must not.
|
|
260
|
+
colorError: {
|
|
261
|
+
backgroundColor: dark ? 'rgba(240,115,106,.14)' : '#FDF0EF',
|
|
262
|
+
color: dark ? '#F0736A' : '#C1372F',
|
|
263
|
+
borderColor: dark ? 'rgba(240,115,106,.26)' : '#F3CFCC',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
MuiTableCell: {
|
|
269
|
+
styleOverrides: {
|
|
270
|
+
root: { borderBottomColor: c.borderSubtle, paddingBlock: 8, paddingInline: 12, fontSize: '.8125rem' },
|
|
271
|
+
// Sentence case. Uppercase headers with positive tracking are the single most
|
|
272
|
+
// visible 2015-era Material signature, and they cost legibility to boot.
|
|
273
|
+
head: {
|
|
274
|
+
color: c.muted,
|
|
275
|
+
backgroundColor: c.subtle,
|
|
276
|
+
fontSize: '.75rem',
|
|
277
|
+
fontWeight: 550,
|
|
278
|
+
letterSpacing: 0,
|
|
279
|
+
textTransform: 'none',
|
|
280
|
+
paddingBlock: 9,
|
|
281
|
+
borderBottom: `1px solid ${c.border}`,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
MuiTableRow: {
|
|
286
|
+
styleOverrides: {
|
|
287
|
+
root: {
|
|
288
|
+
transition: 'background-color 100ms ease',
|
|
289
|
+
'&:hover': { backgroundColor: c.subtle },
|
|
290
|
+
'&.Mui-selected, &.Mui-selected:hover': {
|
|
291
|
+
backgroundColor: dark ? 'rgba(79,192,141,.10)' : fern[50],
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
MuiTooltip: {
|
|
298
|
+
styleOverrides: {
|
|
299
|
+
tooltip: {
|
|
300
|
+
backgroundColor: dark ? '#2A2F35' : '#1A1D21',
|
|
301
|
+
fontSize: '.75rem',
|
|
302
|
+
borderRadius: 6,
|
|
303
|
+
paddingBlock: 5,
|
|
304
|
+
paddingInline: 8,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
MuiMenu: {
|
|
309
|
+
styleOverrides: { paper: { borderRadius: 8, boxShadow: overlay, border: `1px solid ${c.border}` } },
|
|
310
|
+
},
|
|
311
|
+
MuiMenuItem: {
|
|
312
|
+
styleOverrides: { root: { fontSize: '.8125rem', minHeight: 32, borderRadius: 5, marginInline: 4 } },
|
|
313
|
+
},
|
|
314
|
+
MuiDivider: { styleOverrides: { root: { borderColor: c.borderSubtle } } },
|
|
315
|
+
MuiSkeleton: { styleOverrides: { root: { backgroundColor: c.sunken, borderRadius: 6 } } },
|
|
316
|
+
|
|
317
|
+
RaAppBar: {
|
|
318
|
+
styleOverrides: {
|
|
319
|
+
root: {
|
|
320
|
+
'& .RaAppBar-toolbar': { minHeight: 52, paddingInline: 14 },
|
|
321
|
+
'& .RaAppBar-menuButton': { marginRight: 6 },
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
RaLayout: {
|
|
326
|
+
styleOverrides: {
|
|
327
|
+
root: {
|
|
328
|
+
'& .RaLayout-appFrame': { marginTop: 52 },
|
|
329
|
+
'& .RaLayout-content': { padding: '20px 24px 28px', backgroundColor: c.canvas },
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
RaMenuItemLink: {
|
|
334
|
+
styleOverrides: {
|
|
335
|
+
root: {
|
|
336
|
+
minHeight: 34,
|
|
337
|
+
margin: '1px 8px',
|
|
338
|
+
paddingInline: 10,
|
|
339
|
+
borderRadius: 6,
|
|
340
|
+
fontSize: '.8125rem',
|
|
341
|
+
fontWeight: 500,
|
|
342
|
+
color: c.muted,
|
|
343
|
+
transition: 'background-color 100ms ease, color 100ms ease',
|
|
344
|
+
'& .MuiListItemIcon-root': {
|
|
345
|
+
minWidth: 30,
|
|
346
|
+
color: 'inherit',
|
|
347
|
+
'& .MuiSvgIcon-root': { fontSize: 18 },
|
|
348
|
+
},
|
|
349
|
+
'&:hover': { color: c.text, backgroundColor: c.subtle },
|
|
350
|
+
'&.RaMenuItemLink-active': {
|
|
351
|
+
color: dark ? '#8FDCB8' : fern[700],
|
|
352
|
+
fontWeight: 600,
|
|
353
|
+
backgroundColor: dark ? 'rgba(79,192,141,.11)' : fern[50],
|
|
354
|
+
'& .MuiSvgIcon-root': { color: accent },
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
RaDatagrid: {
|
|
360
|
+
styleOverrides: {
|
|
361
|
+
root: {
|
|
362
|
+
borderRadius: 10,
|
|
363
|
+
overflow: 'hidden',
|
|
364
|
+
border: `1px solid ${c.border}`,
|
|
365
|
+
'& .RaDatagrid-headerCell': { color: c.muted },
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
RaToolbar: {
|
|
370
|
+
styleOverrides: {
|
|
371
|
+
root: {
|
|
372
|
+
backgroundColor: 'transparent',
|
|
373
|
+
borderTop: `1px solid ${c.borderSubtle}`,
|
|
374
|
+
marginTop: 16,
|
|
375
|
+
paddingTop: 16,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
export const fougereLightTheme = createFougereTheme('light');
|
|
384
|
+
export const fougereDarkTheme = createFougereTheme('dark');
|
|
385
|
+
|
|
386
|
+
/** A tiny, code-native mark: crisp at any size and easy for themes to recolor. */
|
|
387
|
+
/**
|
|
388
|
+
* The mark — a sprig of two fronds, computed rather than drawn.
|
|
389
|
+
*
|
|
390
|
+
* It follows the herb glyph the site already brands with (`i-noto-herb`), redrawn here
|
|
391
|
+
* rather than vendored: one mark across the site, the repository and this panel, and no
|
|
392
|
+
* emoji artwork copied into a package that ships to npm.
|
|
393
|
+
*
|
|
394
|
+
* Two quadratic rachises, a leaflet per side at points along each — leaning toward the
|
|
395
|
+
* tip, shortening as they go — and the main one ends in a crozier, a frond that has not
|
|
396
|
+
* finished unrolling. That last curl is the only detail that makes it a FERN rather than
|
|
397
|
+
* a generic leaf at 24 pixels.
|
|
398
|
+
*
|
|
399
|
+
* Generated because that is what makes it editable: the shape is the CURVES and the
|
|
400
|
+
* COUNTS, so widening an arc or adding a pair is a number here. Hand-tuned `d=""`
|
|
401
|
+
* strings can only be redrawn, never adjusted.
|
|
402
|
+
*/
|
|
403
|
+
type Sprig = { from: readonly [number, number]; via: readonly [number, number]; to: readonly [number, number] };
|
|
404
|
+
|
|
405
|
+
const MAIN: Sprig = { from: [14.5, 29.5], via: [11.5, 16.5], to: [18.4, 8.1] };
|
|
406
|
+
const SIDE: Sprig = { from: [15.5, 26.5], via: [21, 21], to: [25.5, 13.5] };
|
|
407
|
+
|
|
408
|
+
function leaflets(
|
|
409
|
+
{ from, via, to }: Sprig,
|
|
410
|
+
count: number,
|
|
411
|
+
maxLength: number,
|
|
412
|
+
maxWidth: number,
|
|
413
|
+
span: readonly [number, number],
|
|
414
|
+
): string[] {
|
|
415
|
+
const [bx, by] = from; const [cx, cy] = via; const [tx, ty] = to;
|
|
416
|
+
const at = (t: number) => [
|
|
417
|
+
(1 - t) ** 2 * bx + 2 * (1 - t) * t * cx + t ** 2 * tx,
|
|
418
|
+
(1 - t) ** 2 * by + 2 * (1 - t) * t * cy + t ** 2 * ty,
|
|
419
|
+
] as const;
|
|
420
|
+
const along = (t: number) => {
|
|
421
|
+
const dx = 2 * (1 - t) * (cx - bx) + 2 * t * (tx - cx);
|
|
422
|
+
const dy = 2 * (1 - t) * (cy - by) + 2 * t * (ty - cy);
|
|
423
|
+
const n = Math.hypot(dx, dy);
|
|
424
|
+
return [dx / n, dy / n] as const;
|
|
425
|
+
};
|
|
426
|
+
const r = (n: number) => n.toFixed(2);
|
|
427
|
+
const out: string[] = [];
|
|
428
|
+
for (let i = 0; i < count; i += 1) {
|
|
429
|
+
const t = span[0] + i * ((span[1] - span[0]) / (count - 1));
|
|
430
|
+
const [px, py] = at(t);
|
|
431
|
+
const [ax, ay] = along(t);
|
|
432
|
+
const length = maxLength * (1 - 0.55 * t);
|
|
433
|
+
const width = maxWidth * (1 - 0.35 * t);
|
|
434
|
+
for (const side of [1, -1]) {
|
|
435
|
+
// The leaflet's axis: the normal, leaned toward the tip. Both sides lean the same
|
|
436
|
+
// way, which is what stops a generated frond from looking like a fish bone.
|
|
437
|
+
const kx = -ay * side + ax * 0.52;
|
|
438
|
+
const ky = ax * side + ay * 0.52;
|
|
439
|
+
const k = Math.hypot(kx, ky);
|
|
440
|
+
const [dx, dy] = [kx / k, ky / k];
|
|
441
|
+
const [ex, ey] = [px + dx * length, py + dy * length];
|
|
442
|
+
const [mx, my] = [px + dx * length * 0.42, py + dy * length * 0.42];
|
|
443
|
+
out.push(
|
|
444
|
+
`M${r(px)} ${r(py)}`
|
|
445
|
+
+ `C${r(mx - dy * width)} ${r(my + dx * width)} ${r(ex - dy * width * 0.35)} ${r(ey + dx * width * 0.35)} ${r(ex)} ${r(ey)}`
|
|
446
|
+
+ `C${r(ex + dy * width * 0.35)} ${r(ey - dx * width * 0.35)} ${r(mx + dy * width)} ${r(my - dx * width)} ${r(px)} ${r(py)}Z`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const MAIN_LEAFLETS = leaflets(MAIN, 8, 6.2, 2.0, [0.06, 0.82]);
|
|
454
|
+
const SIDE_LEAFLETS = leaflets(SIDE, 5, 4.2, 1.6, [0.14, 0.86]);
|
|
455
|
+
|
|
456
|
+
const FERN_DEEP = '#5B9821';
|
|
457
|
+
const FERN_LIGHT = '#8DC02C';
|
|
458
|
+
|
|
459
|
+
export function FougereMark({ size = 28 }: { size?: number }): ReactElement {
|
|
460
|
+
return (
|
|
461
|
+
<svg aria-hidden="true" width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
462
|
+
<g fill={FERN_DEEP}>
|
|
463
|
+
<path d="M14.5 29.5Q11.5 16.5 18.4 8.1" fill="none" stroke={FERN_DEEP} strokeWidth="1.5" strokeLinecap="round" />
|
|
464
|
+
<path
|
|
465
|
+
d="M18.4 8.1c1.1-1.5 3.7-1.7 4 .5c.25 1.85-2.1 2.55-2.75 1.1"
|
|
466
|
+
fill="none" stroke={FERN_DEEP} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
|
467
|
+
/>
|
|
468
|
+
{MAIN_LEAFLETS.map((d) => <path key={d} d={d} />)}
|
|
469
|
+
</g>
|
|
470
|
+
<g fill={FERN_LIGHT}>
|
|
471
|
+
<path d="M15.5 26.5Q21 21 25.5 13.5" fill="none" stroke={FERN_LIGHT} strokeWidth="1.25" strokeLinecap="round" />
|
|
472
|
+
{SIDE_LEAFLETS.map((d) => <path key={d} d={d} />)}
|
|
473
|
+
</g>
|
|
474
|
+
</svg>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function FougereAppBar(props: AppBarProps): ReactElement {
|
|
479
|
+
return (
|
|
480
|
+
<AppBar {...props} color="inherit" elevation={0}>
|
|
481
|
+
<span
|
|
482
|
+
aria-label="Fougere"
|
|
483
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, flexShrink: 0 }}
|
|
484
|
+
>
|
|
485
|
+
<span style={{ display: 'inline-flex', color: 'var(--mui-palette-primary-main, #2d5933)' }}>
|
|
486
|
+
<FougereMark />
|
|
487
|
+
</span>
|
|
488
|
+
<span style={{ fontSize: 16, fontWeight: 750, letterSpacing: '-0.025em' }}>Fougere</span>
|
|
489
|
+
</span>
|
|
490
|
+
<span
|
|
491
|
+
aria-hidden="true"
|
|
492
|
+
style={{ width: 1, height: 24, margin: '0 18px', background: 'currentColor', opacity: 0.12 }}
|
|
493
|
+
/>
|
|
494
|
+
<TitlePortal style={{ flex: 1, minWidth: 0, fontSize: 14, fontWeight: 600, opacity: 0.68 }} />
|
|
495
|
+
</AppBar>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const TopologyIcon = (props: SvgIconProps) => (
|
|
500
|
+
<SvgIcon {...props}><path d="M12 2a3 3 0 0 1 1 5.83V10h4a3 3 0 0 1 3 3v1.17a3 3 0 1 1-2 0V13a1 1 0 0 0-1-1h-4v2.17a3 3 0 1 1-2 0V12H7a1 1 0 0 0-1 1v1.17a3 3 0 1 1-2 0V13a3 3 0 0 1 3-3h4V7.83A3 3 0 0 1 12 2Z" /></SvgIcon>
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The derived menu, plus the one entry that is not a door.
|
|
505
|
+
*
|
|
506
|
+
* `Menu.ResourceItems` keeps the resources the card produced — the menu stays derived, and
|
|
507
|
+
* this adds beside it rather than replacing it. The topology entry shows even when nothing
|
|
508
|
+
* answers: the page is the only place that can say what is missing.
|
|
509
|
+
*/
|
|
510
|
+
const FougereMenu = () => {
|
|
511
|
+
const translate = useTranslate();
|
|
512
|
+
return (
|
|
513
|
+
<Menu>
|
|
514
|
+
<Menu.DashboardItem />
|
|
515
|
+
<Menu.ResourceItems />
|
|
516
|
+
<Menu.Item
|
|
517
|
+
to="/topology"
|
|
518
|
+
primaryText={translate('fougere.admin.topology.title', { _: 'Topology' })}
|
|
519
|
+
leftIcon={<TopologyIcon />}
|
|
520
|
+
/>
|
|
521
|
+
</Menu>
|
|
522
|
+
);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
export function FougereLayout(props: LayoutProps): ReactElement {
|
|
526
|
+
return <Layout {...props} appBar={FougereAppBar} menu={FougereMenu} />;
|
|
527
|
+
}
|