mfeflow 0.2.0 → 0.3.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/assets/features/analytics-clarity/feature.json +64 -0
- package/assets/features/analytics-clarity/files/clarity-test.ts +36 -0
- package/assets/features/analytics-clarity/files/clarity.ts +26 -0
- package/assets/features/analytics-gtm/files/init.ts +2 -2
- package/assets/features/commit-prompt/feature.json +27 -0
- package/assets/features/commit-prompt/files/cz.config.js +27 -0
- package/assets/features/data-react-query/feature.json +89 -0
- package/assets/features/data-react-query/files/keys.ts.eta +8 -0
- package/assets/features/data-react-query/files/remote-Query-test.tsx.eta +39 -0
- package/assets/features/data-react-query/files/remote-Query.tsx +15 -0
- package/assets/features/data-react-query/files/shell-Query-test.tsx +21 -0
- package/assets/features/data-react-query/files/shell-Query.tsx +14 -0
- package/assets/features/data-react-query/files/useHealth.fetch.ts.eta +15 -0
- package/assets/features/data-react-query/files/useHealth.http.ts.eta +11 -0
- package/assets/features/env-t3/feature.json +21 -0
- package/assets/features/forms/feature.json +90 -0
- package/assets/features/forms/files/ContactForm-test.tsx +35 -0
- package/assets/features/forms/files/ContactForm.tsx +50 -0
- package/assets/features/forms/files/schema.arktype.ts +9 -0
- package/assets/features/forms/files/schema.valibot.ts +9 -0
- package/assets/features/forms/files/schema.zod.ts +9 -0
- package/assets/features/i18n/feature.json +139 -0
- package/assets/features/i18n/files/LanguageSwitcher.tsx +28 -0
- package/assets/features/i18n/files/Languages.tsx +12 -0
- package/assets/features/i18n/files/language-switcher-test.tsx +19 -0
- package/assets/features/i18n/files/remote-I18n-test.tsx.eta +43 -0
- package/assets/features/i18n/files/remote-I18n.tsx.eta +36 -0
- package/assets/features/i18n/files/remote-ar.json.eta +3 -0
- package/assets/features/i18n/files/remote-config.ts +9 -0
- package/assets/features/i18n/files/remote-en.json.eta +3 -0
- package/assets/features/i18n/files/remote-load.ts +4 -0
- package/assets/features/i18n/files/shell-config.ts +20 -0
- package/assets/features/i18n/files/shell-init-test.ts +32 -0
- package/assets/features/i18n/files/shell-init.ts +7 -0
- package/assets/features/lint-oxlint/files/oxlintrc.json +1 -1
- package/assets/features/lint-oxlint/files/prettierignore +1 -0
- package/assets/features/storybook/feature.json +119 -0
- package/assets/features/storybook/files/App.stories.tsx.eta +8 -0
- package/assets/features/storybook/files/Home.stories.tsx +9 -0
- package/assets/features/storybook/files/button.stories.tsx +14 -0
- package/assets/features/storybook/files/remote-main.ts +13 -0
- package/assets/features/storybook/files/rsbuild.config.ts +10 -0
- package/assets/features/storybook/files/shell-main.ts +39 -0
- package/assets/features/tailwind/feature.json +87 -0
- package/assets/features/tailwind/files/Scope-test.tsx.eta +17 -0
- package/assets/features/tailwind/files/Scope.tsx.eta +15 -0
- package/assets/features/tailwind/files/postcss.config.mjs +1 -0
- package/assets/features/tailwind/files/remote-styles.css.eta +21 -0
- package/assets/features/tailwind/files/shell-styles.css.eta +22 -0
- package/assets/features/testing/files/vitest.config.ts +11 -2
- package/assets/features/theming/feature.json +103 -0
- package/assets/features/theming/files/Theme.tsx +15 -0
- package/assets/features/theming/files/ThemeToggle.tsx +23 -0
- package/assets/features/theming/files/apply-stored-test.ts +37 -0
- package/assets/features/theming/files/apply-stored.ts +13 -0
- package/assets/features/theming/files/head-script.ts +8 -0
- package/assets/features/theming/files/theme-toggle-test.tsx +18 -0
- package/assets/features/theming/files/theme.css +14 -0
- package/assets/features/theming/files/themes.ts +30 -0
- package/assets/features/theming/files/tokens-test.ts +9 -0
- package/assets/features/theming/files/tokens.ts +9 -0
- package/assets/features/ui-shadcn/feature.json +61 -0
- package/assets/features/ui-shadcn/files/button-test.tsx.eta +32 -0
- package/assets/features/ui-shadcn/files/button.tsx.eta +36 -0
- package/assets/features/ui-shadcn/files/components.json.eta +22 -0
- package/assets/features/ui-shadcn/files/utils-test.ts.eta +17 -0
- package/assets/features/ui-shadcn/files/utils.ts.eta +11 -0
- package/assets/templates/monorepo/files/root/gitignore.eta +1 -0
- package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -1
- package/dist/index.js +45 -0
- package/package.json +4 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tailwind",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tailwind CSS 4 through PostCSS. The shell loads Tailwind's reset (preflight) once; every app gets its own class prefix (cart:flex), so two remotes using the same utility with different tokens never collide. The colour tokens read the --mf-* theme variables, with fallbacks, so they follow theming whichever way round the two are added.",
|
|
5
|
+
"requires": ["core", "tsconfig"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "postcss.config.mjs",
|
|
9
|
+
"target": "shell",
|
|
10
|
+
"from": "files/postcss.config.mjs"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"to": "src/styles.css",
|
|
14
|
+
"target": "shell",
|
|
15
|
+
"from": "files/shell-styles.css.eta",
|
|
16
|
+
"template": true
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"to": "postcss.config.mjs",
|
|
20
|
+
"target": "remotes",
|
|
21
|
+
"from": "files/postcss.config.mjs"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"to": "src/styles.css",
|
|
25
|
+
"target": "remotes",
|
|
26
|
+
"from": "files/remote-styles.css.eta",
|
|
27
|
+
"template": true
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"to": "src/tailwind/Scope.tsx",
|
|
31
|
+
"target": "remotes",
|
|
32
|
+
"from": "files/Scope.tsx.eta",
|
|
33
|
+
"template": true
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"to": "src/tailwind/Scope.test.tsx",
|
|
37
|
+
"target": "remotes",
|
|
38
|
+
"from": "files/Scope-test.tsx.eta",
|
|
39
|
+
"template": true,
|
|
40
|
+
"when": {
|
|
41
|
+
"feature:testing": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"dependencies": [
|
|
46
|
+
{
|
|
47
|
+
"name": "tailwindcss",
|
|
48
|
+
"range": "4.3.3",
|
|
49
|
+
"target": "shell",
|
|
50
|
+
"dev": true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "@tailwindcss/postcss",
|
|
54
|
+
"range": "4.3.3",
|
|
55
|
+
"target": "shell",
|
|
56
|
+
"dev": true
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "tailwindcss",
|
|
60
|
+
"range": "4.3.3",
|
|
61
|
+
"target": "remotes",
|
|
62
|
+
"dev": true
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "@tailwindcss/postcss",
|
|
66
|
+
"range": "4.3.3",
|
|
67
|
+
"target": "remotes",
|
|
68
|
+
"dev": true
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"transforms": [
|
|
72
|
+
{
|
|
73
|
+
"op": "add-import",
|
|
74
|
+
"file": "src/bootstrap.tsx",
|
|
75
|
+
"target": "shell",
|
|
76
|
+
"from": "./styles.css"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"op": "wrap-provider",
|
|
80
|
+
"file": "src/exposed/App.tsx",
|
|
81
|
+
"target": "remotes",
|
|
82
|
+
"component": "Scope",
|
|
83
|
+
"from": "../tailwind/Scope",
|
|
84
|
+
"function": "App"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { Scope } from './Scope';
|
|
4
|
+
|
|
5
|
+
describe('the <%= it.app.name %> Tailwind scope', () => {
|
|
6
|
+
it('prefixes every class with this remote', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Scope>
|
|
9
|
+
<p>content</p>
|
|
10
|
+
</Scope>,
|
|
11
|
+
);
|
|
12
|
+
const root = screen.getByText('content').parentElement;
|
|
13
|
+
for (const name of (root?.className ?? '').split(' ')) {
|
|
14
|
+
expect(name.includes(':'), name).toBe(true);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import '../styles.css';
|
|
4
|
+
|
|
5
|
+
// prettier-ignore
|
|
6
|
+
const classes = '<%= prefix %>:p-2 <%= prefix %>:outline-2 <%= prefix %>:outline-primary';
|
|
7
|
+
|
|
8
|
+
/** Wraps this remote's App and loads its styles. The outline shows the primary token; delete it when you have your own markup. */
|
|
9
|
+
export function Scope({ children }: { children: ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<div data-tailwind="<%= it.app.name %>" className={classes}>
|
|
12
|
+
{children}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { plugins: { '@tailwindcss/postcss': {} } };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
/* No reset here: the shell has it. Classes are written <%= prefix %>:flex, and the theme variables are --<%= prefix %>-*. */
|
|
3
|
+
@layer theme, base, components, utilities;
|
|
4
|
+
@import 'tailwindcss/theme.css' layer(theme) prefix(<%= prefix %>);
|
|
5
|
+
@import 'tailwindcss/utilities.css' layer(utilities) prefix(<%= prefix %>);
|
|
6
|
+
|
|
7
|
+
/* Tokens read the theme variables (--mf-*). The second value is used when there is no theme. */
|
|
8
|
+
@theme inline {
|
|
9
|
+
--color-background: var(--mf-color-bg, #ffffff);
|
|
10
|
+
--color-foreground: var(--mf-color-fg, #111827);
|
|
11
|
+
--color-surface: var(--mf-color-surface, #f3f4f6);
|
|
12
|
+
--color-primary: var(--mf-color-primary, #2563eb);
|
|
13
|
+
--color-primary-foreground: var(--mf-color-primary-fg, #ffffff);
|
|
14
|
+
--color-secondary: var(--mf-color-surface, #f3f4f6);
|
|
15
|
+
--color-secondary-foreground: var(--mf-color-fg, #111827);
|
|
16
|
+
--color-accent: var(--mf-color-surface, #f3f4f6);
|
|
17
|
+
--color-accent-foreground: var(--mf-color-fg, #111827);
|
|
18
|
+
--color-destructive: #dc2626;
|
|
19
|
+
--color-border: var(--mf-color-border, #e5e7eb);
|
|
20
|
+
--color-ring: var(--mf-color-primary, #2563eb);
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
/* The shell is the only app that loads Tailwind's reset (preflight). */
|
|
3
|
+
@layer theme, base, components, utilities;
|
|
4
|
+
@import 'tailwindcss/theme.css' layer(theme) prefix(<%= prefix %>);
|
|
5
|
+
@import 'tailwindcss/preflight.css' layer(base);
|
|
6
|
+
@import 'tailwindcss/utilities.css' layer(utilities) prefix(<%= prefix %>);
|
|
7
|
+
|
|
8
|
+
/* Tokens read the theme variables (--mf-*). The second value is used when there is no theme. */
|
|
9
|
+
@theme inline {
|
|
10
|
+
--color-background: var(--mf-color-bg, #ffffff);
|
|
11
|
+
--color-foreground: var(--mf-color-fg, #111827);
|
|
12
|
+
--color-surface: var(--mf-color-surface, #f3f4f6);
|
|
13
|
+
--color-primary: var(--mf-color-primary, #2563eb);
|
|
14
|
+
--color-primary-foreground: var(--mf-color-primary-fg, #ffffff);
|
|
15
|
+
--color-secondary: var(--mf-color-surface, #f3f4f6);
|
|
16
|
+
--color-secondary-foreground: var(--mf-color-fg, #111827);
|
|
17
|
+
--color-accent: var(--mf-color-surface, #f3f4f6);
|
|
18
|
+
--color-accent-foreground: var(--mf-color-fg, #111827);
|
|
19
|
+
--color-destructive: #dc2626;
|
|
20
|
+
--color-border: var(--mf-color-border, #e5e7eb);
|
|
21
|
+
--color-ring: var(--mf-color-primary, #2563eb);
|
|
22
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
2
|
|
|
3
3
|
export default defineConfig({
|
|
4
|
-
// One copy of React and Testing Library, also when a package like @mfeflow/testing is linked from another folder.
|
|
5
|
-
resolve: {
|
|
4
|
+
// One copy of React, i18next and Testing Library, also when a package like @mfeflow/testing is linked from another folder.
|
|
5
|
+
resolve: {
|
|
6
|
+
dedupe: [
|
|
7
|
+
'react',
|
|
8
|
+
'react-dom',
|
|
9
|
+
'react-i18next',
|
|
10
|
+
'i18next',
|
|
11
|
+
'@testing-library/react',
|
|
12
|
+
'@testing-library/dom',
|
|
13
|
+
],
|
|
14
|
+
},
|
|
6
15
|
test: {
|
|
7
16
|
environment: 'jsdom',
|
|
8
17
|
include: ['src/**/*.test.{ts,tsx}'],
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "theming",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Light and dark themes as CSS variables (@mfeflow/theme): the shell owns the provider and the persisted choice, every remote reads the same --mf-* tokens with no theme code of its own. The stored theme is set by an inline script in the shell's <head>, before any bundle loads, so a reload never paints the wrong colours.",
|
|
5
|
+
"requires": ["core", "tsconfig"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "src/theme/themes.ts",
|
|
9
|
+
"target": "shell",
|
|
10
|
+
"from": "files/themes.ts"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"to": "src/theme/Theme.tsx",
|
|
14
|
+
"target": "shell",
|
|
15
|
+
"from": "files/Theme.tsx"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"to": "src/theme/ThemeToggle.tsx",
|
|
19
|
+
"target": "shell",
|
|
20
|
+
"from": "files/ThemeToggle.tsx"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"to": "src/theme/theme.css",
|
|
24
|
+
"target": "shell",
|
|
25
|
+
"from": "files/theme.css"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"to": "src/theme/apply-stored.ts",
|
|
29
|
+
"target": "shell",
|
|
30
|
+
"from": "files/apply-stored.ts"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"to": "src/theme/head-script.ts",
|
|
34
|
+
"target": "shell",
|
|
35
|
+
"from": "files/head-script.ts"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"to": "src/theme/apply-stored.test.ts",
|
|
39
|
+
"target": "shell",
|
|
40
|
+
"from": "files/apply-stored-test.ts",
|
|
41
|
+
"when": {
|
|
42
|
+
"feature:testing": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"to": "src/theme/ThemeToggle.test.tsx",
|
|
47
|
+
"target": "shell",
|
|
48
|
+
"from": "files/theme-toggle-test.tsx",
|
|
49
|
+
"when": {
|
|
50
|
+
"feature:testing": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"to": "src/theme/tokens.ts",
|
|
55
|
+
"target": "remotes",
|
|
56
|
+
"from": "files/tokens.ts"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"to": "src/theme/tokens.test.ts",
|
|
60
|
+
"target": "remotes",
|
|
61
|
+
"from": "files/tokens-test.ts",
|
|
62
|
+
"when": {
|
|
63
|
+
"feature:testing": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"dependencies": [
|
|
68
|
+
{
|
|
69
|
+
"name": "@mfeflow/theme",
|
|
70
|
+
"range": "latest",
|
|
71
|
+
"target": "shell"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"name": "@mfeflow/theme",
|
|
75
|
+
"range": "latest",
|
|
76
|
+
"target": "remotes"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"transforms": [
|
|
80
|
+
{
|
|
81
|
+
"op": "add-import",
|
|
82
|
+
"file": "rsbuild.config.ts",
|
|
83
|
+
"target": "shell",
|
|
84
|
+
"from": "./src/theme/head-script"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"op": "add-boot-step",
|
|
88
|
+
"file": "src/bootstrap.tsx",
|
|
89
|
+
"target": "shell",
|
|
90
|
+
"call": "applyStoredTheme",
|
|
91
|
+
"from": "./theme/apply-stored",
|
|
92
|
+
"await": false
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"op": "wrap-provider",
|
|
96
|
+
"file": "src/App.tsx",
|
|
97
|
+
"target": "shell",
|
|
98
|
+
"component": "Theme",
|
|
99
|
+
"from": "./theme/Theme",
|
|
100
|
+
"function": "App"
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { ThemeProvider } from '@mfeflow/theme/react';
|
|
3
|
+
import { ThemeToggle } from './ThemeToggle';
|
|
4
|
+
import { defaultTheme, followSystem, themes } from './themes';
|
|
5
|
+
import './theme.css';
|
|
6
|
+
|
|
7
|
+
/** Wraps the shell. The choice is stored under the shell's storage, so every tab and reload agrees. */
|
|
8
|
+
export function Theme({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<ThemeProvider themes={themes} defaultTheme={defaultTheme} followSystem={followSystem}>
|
|
11
|
+
{children}
|
|
12
|
+
<ThemeToggle />
|
|
13
|
+
</ThemeProvider>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useTheme } from '@mfeflow/theme/react';
|
|
2
|
+
|
|
3
|
+
/** Your component: move it into the header, restyle it, or swap it for a menu. */
|
|
4
|
+
export function ThemeToggle() {
|
|
5
|
+
const { theme, themes, setTheme } = useTheme();
|
|
6
|
+
return (
|
|
7
|
+
<label className="theme-toggle">
|
|
8
|
+
Theme{' '}
|
|
9
|
+
<select
|
|
10
|
+
value={theme.name}
|
|
11
|
+
onChange={(event) => {
|
|
12
|
+
setTheme(event.target.value);
|
|
13
|
+
}}
|
|
14
|
+
>
|
|
15
|
+
{themes.map((name) => (
|
|
16
|
+
<option key={name} value={name}>
|
|
17
|
+
{name}
|
|
18
|
+
</option>
|
|
19
|
+
))}
|
|
20
|
+
</select>
|
|
21
|
+
</label>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyStoredTheme, currentStartingTheme } from './apply-stored';
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
localStorage.clear();
|
|
6
|
+
document.documentElement.removeAttribute('data-theme');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('applyStoredTheme', () => {
|
|
10
|
+
it('uses the default theme when nothing is stored', () => {
|
|
11
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('puts the stored theme on <html> with its variables', () => {
|
|
15
|
+
localStorage.setItem('shell:theme', JSON.stringify('dark'));
|
|
16
|
+
applyStoredTheme();
|
|
17
|
+
expect(document.documentElement.dataset.theme).toBe('dark');
|
|
18
|
+
expect(document.documentElement.style.getPropertyValue('--mf-color-bg')).not.toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('ignores a stored value that is not a theme', () => {
|
|
22
|
+
localStorage.setItem('shell:theme', '{broken');
|
|
23
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
24
|
+
localStorage.setItem('shell:theme', JSON.stringify('purple'));
|
|
25
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(['constructor', '__proto__', 'toString'])(
|
|
29
|
+
'ignores a stored name that only exists on every object: %s',
|
|
30
|
+
(name) => {
|
|
31
|
+
localStorage.setItem('shell:theme', JSON.stringify(name));
|
|
32
|
+
expect(currentStartingTheme().name).toBe('light');
|
|
33
|
+
applyStoredTheme();
|
|
34
|
+
expect(document.documentElement.dataset.theme).toBe('light');
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { applyTheme, type Theme } from '@mfeflow/theme';
|
|
2
|
+
import { startingTheme } from '@mfeflow/theme/starting';
|
|
3
|
+
import { defaultTheme, followSystem, themes } from './themes';
|
|
4
|
+
|
|
5
|
+
/** The theme to show before React renders: the stored choice, else the system preference (if followSystem), else the default. */
|
|
6
|
+
export function currentStartingTheme(): Theme {
|
|
7
|
+
return startingTheme(themes, { defaultTheme, followSystem });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The first boot step: the variables are on <html> before the first render, also when the head script was blocked. */
|
|
11
|
+
export function applyStoredTheme(): void {
|
|
12
|
+
applyTheme(currentStartingTheme());
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerHeadScript } from '@mfeflow/rsbuild';
|
|
2
|
+
import { noFlashScript } from '@mfeflow/theme';
|
|
3
|
+
import { defaultTheme, followSystem, themes } from './themes';
|
|
4
|
+
|
|
5
|
+
// Imported by rsbuild.config.ts. The script goes first in the shell's <head>, so the stored theme is on <html>
|
|
6
|
+
// before any bundle has loaded. It needs the CSP to allow an inline script (a nonce or 'unsafe-inline');
|
|
7
|
+
// without that, apply-stored.ts still sets the theme before the first render.
|
|
8
|
+
registerHeadScript('theme', noFlashScript(themes, { defaultTheme, followSystem }));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fireEvent, screen } from '@testing-library/react';
|
|
2
|
+
import { renderWithPlatform } from '@mfeflow/testing';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { Theme } from './Theme';
|
|
5
|
+
|
|
6
|
+
describe('Theme', () => {
|
|
7
|
+
it('switches the theme on <html> and remembers it', () => {
|
|
8
|
+
renderWithPlatform(
|
|
9
|
+
<Theme>
|
|
10
|
+
<p>content</p>
|
|
11
|
+
</Theme>,
|
|
12
|
+
);
|
|
13
|
+
expect(document.documentElement.dataset.theme).toBe('light');
|
|
14
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'dark' } });
|
|
15
|
+
expect(document.documentElement.dataset.theme).toBe('dark');
|
|
16
|
+
expect(localStorage.getItem('shell:theme')).toBe('"dark"');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
html {
|
|
2
|
+
background: var(--mf-color-bg, #ffffff);
|
|
3
|
+
color: var(--mf-color-fg, #111827);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.theme-toggle {
|
|
7
|
+
position: fixed;
|
|
8
|
+
right: 1rem;
|
|
9
|
+
bottom: 1rem;
|
|
10
|
+
padding: 0.25rem 0.5rem;
|
|
11
|
+
border-radius: 0.5rem;
|
|
12
|
+
background: var(--mf-color-surface, #f3f4f6);
|
|
13
|
+
color: var(--mf-color-fg, #111827);
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineTheme } from '@mfeflow/theme';
|
|
2
|
+
|
|
3
|
+
// Every token becomes a --mf-<token> variable on <html>. Remotes read them with var(--mf-color-fg).
|
|
4
|
+
export const themes = [
|
|
5
|
+
defineTheme('light', {
|
|
6
|
+
'color-bg': '#ffffff',
|
|
7
|
+
'color-fg': '#111827',
|
|
8
|
+
'color-surface': '#f3f4f6',
|
|
9
|
+
'color-primary': '#2563eb',
|
|
10
|
+
'color-primary-fg': '#ffffff',
|
|
11
|
+
'color-border': '#e5e7eb',
|
|
12
|
+
}),
|
|
13
|
+
defineTheme(
|
|
14
|
+
'dark',
|
|
15
|
+
{
|
|
16
|
+
'color-bg': '#0b1020',
|
|
17
|
+
'color-fg': '#e5e7eb',
|
|
18
|
+
'color-surface': '#1f2937',
|
|
19
|
+
'color-primary': '#60a5fa',
|
|
20
|
+
'color-primary-fg': '#0b1020',
|
|
21
|
+
'color-border': '#374151',
|
|
22
|
+
},
|
|
23
|
+
{ colorScheme: 'dark' },
|
|
24
|
+
),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const defaultTheme = 'light';
|
|
28
|
+
|
|
29
|
+
// Use the system dark-mode setting when no theme is stored. The provider, the head script and the boot step all read this.
|
|
30
|
+
export const followSystem = true;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { tokens } from './tokens';
|
|
3
|
+
|
|
4
|
+
describe('tokens', () => {
|
|
5
|
+
it('are the shell variables, with a fallback for standalone runs', () => {
|
|
6
|
+
expect(tokens.primary).toBe('var(--mf-color-primary, #2563eb)');
|
|
7
|
+
expect(tokens.fg).toContain('--mf-color-fg');
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { cssVar } from '@mfeflow/theme';
|
|
2
|
+
|
|
3
|
+
// The shell sets these on <html>. The second argument is what a standalone run (no shell) falls back to.
|
|
4
|
+
export const tokens = {
|
|
5
|
+
bg: cssVar('color-bg', '#ffffff'),
|
|
6
|
+
fg: cssVar('color-fg', '#111827'),
|
|
7
|
+
surface: cssVar('color-surface', '#f3f4f6'),
|
|
8
|
+
primary: cssVar('color-primary', '#2563eb'),
|
|
9
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ui-shadcn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "shadcn/ui in every remote, on top of tailwind: components.json, the cn helper and a Button, copied into the remote so you own them. The classes carry the remote's Tailwind prefix and the colours come from the theme. Nothing is shared between remotes.",
|
|
5
|
+
"requires": ["core", "tsconfig", "tailwind"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "components.json",
|
|
9
|
+
"target": "remotes",
|
|
10
|
+
"from": "files/components.json.eta",
|
|
11
|
+
"template": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"to": "src/lib/utils.ts",
|
|
15
|
+
"target": "remotes",
|
|
16
|
+
"from": "files/utils.ts.eta",
|
|
17
|
+
"template": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"to": "src/components/ui/button.tsx",
|
|
21
|
+
"target": "remotes",
|
|
22
|
+
"from": "files/button.tsx.eta",
|
|
23
|
+
"template": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"to": "src/lib/utils.test.ts",
|
|
27
|
+
"target": "remotes",
|
|
28
|
+
"from": "files/utils-test.ts.eta",
|
|
29
|
+
"template": true,
|
|
30
|
+
"when": {
|
|
31
|
+
"feature:testing": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"to": "src/components/ui/button.test.tsx",
|
|
36
|
+
"target": "remotes",
|
|
37
|
+
"from": "files/button-test.tsx.eta",
|
|
38
|
+
"template": true,
|
|
39
|
+
"when": {
|
|
40
|
+
"feature:testing": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"dependencies": [
|
|
45
|
+
{
|
|
46
|
+
"name": "class-variance-authority",
|
|
47
|
+
"range": "0.7.1",
|
|
48
|
+
"target": "remotes"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "clsx",
|
|
52
|
+
"range": "2.1.1",
|
|
53
|
+
"target": "remotes"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "tailwind-merge",
|
|
57
|
+
"range": "3.7.0",
|
|
58
|
+
"target": "remotes"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Button } from './button';
|
|
5
|
+
|
|
6
|
+
const prefix = '<%= prefix %>';
|
|
7
|
+
|
|
8
|
+
describe('Button', () => {
|
|
9
|
+
it('is a button that does not submit a form unless asked', () => {
|
|
10
|
+
render(<Button>Save</Button>);
|
|
11
|
+
expect(screen.getByRole('button', { name: 'Save' }).getAttribute('type')).toBe('button');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('carries the remote prefix on its classes, and lets a caller override one', () => {
|
|
15
|
+
render(<Button className={`${prefix}:px-8`}>Save</Button>);
|
|
16
|
+
const { className } = screen.getByRole('button');
|
|
17
|
+
expect(className).toContain(`${prefix}:bg-primary`);
|
|
18
|
+
expect(className).toContain(`${prefix}:px-8`);
|
|
19
|
+
expect(className).not.toContain(`${prefix}:px-4`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not click while disabled', () => {
|
|
23
|
+
const onClick = vi.fn();
|
|
24
|
+
render(
|
|
25
|
+
<Button disabled onClick={onClick}>
|
|
26
|
+
Save
|
|
27
|
+
</Button>,
|
|
28
|
+
);
|
|
29
|
+
fireEvent.click(screen.getByRole('button'));
|
|
30
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import type { ButtonHTMLAttributes } from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
// Every class starts with <%= prefix %>: (this remote's Tailwind prefix). Colours are the theme tokens from src/styles.css.
|
|
7
|
+
// prettier-ignore
|
|
8
|
+
const buttonVariants = cva(
|
|
9
|
+
'<%= prefix %>:inline-flex <%= prefix %>:items-center <%= prefix %>:justify-center <%= prefix %>:gap-2 <%= prefix %>:rounded-md <%= prefix %>:text-sm <%= prefix %>:font-medium <%= prefix %>:transition-colors <%= prefix %>:disabled:pointer-events-none <%= prefix %>:disabled:opacity-50 <%= prefix %>:focus-visible:outline-2 <%= prefix %>:focus-visible:outline-offset-2 <%= prefix %>:focus-visible:outline-ring',
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: '<%= prefix %>:bg-primary <%= prefix %>:text-primary-foreground <%= prefix %>:hover:opacity-90',
|
|
14
|
+
secondary: '<%= prefix %>:bg-secondary <%= prefix %>:text-secondary-foreground <%= prefix %>:hover:opacity-80',
|
|
15
|
+
outline: '<%= prefix %>:border <%= prefix %>:border-border <%= prefix %>:bg-background <%= prefix %>:hover:bg-accent <%= prefix %>:hover:text-accent-foreground',
|
|
16
|
+
destructive: '<%= prefix %>:bg-destructive <%= prefix %>:text-white <%= prefix %>:hover:opacity-90',
|
|
17
|
+
ghost: '<%= prefix %>:hover:bg-accent <%= prefix %>:hover:text-accent-foreground',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: '<%= prefix %>:h-9 <%= prefix %>:px-4 <%= prefix %>:py-2',
|
|
21
|
+
sm: '<%= prefix %>:h-8 <%= prefix %>:px-3',
|
|
22
|
+
lg: '<%= prefix %>:h-10 <%= prefix %>:px-6',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: { variant: 'default', size: 'default' },
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export interface ButtonProps
|
|
30
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
|
31
|
+
|
|
32
|
+
export function Button({ className, variant, size, type = 'button', ...props }: ButtonProps) {
|
|
33
|
+
return (
|
|
34
|
+
<button type={type} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
{
|
|
3
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
4
|
+
"style": "new-york",
|
|
5
|
+
"rsc": false,
|
|
6
|
+
"tsx": true,
|
|
7
|
+
"tailwind": {
|
|
8
|
+
"config": "",
|
|
9
|
+
"css": "src/styles.css",
|
|
10
|
+
"baseColor": "neutral",
|
|
11
|
+
"cssVariables": false,
|
|
12
|
+
"prefix": "<%= prefix %>"
|
|
13
|
+
},
|
|
14
|
+
"aliases": {
|
|
15
|
+
"components": "@/components",
|
|
16
|
+
"ui": "@/components/ui",
|
|
17
|
+
"lib": "@/lib",
|
|
18
|
+
"utils": "@/lib/utils",
|
|
19
|
+
"hooks": "@/hooks"
|
|
20
|
+
},
|
|
21
|
+
"iconLibrary": "lucide"
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%_ const prefix = it.app.tailwindPrefix _%>
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { cn } from './utils';
|
|
4
|
+
|
|
5
|
+
const prefix = '<%= prefix %>';
|
|
6
|
+
|
|
7
|
+
describe('cn', () => {
|
|
8
|
+
it("keeps the last of two conflicting classes with this remote's prefix", () => {
|
|
9
|
+
expect(cn(`${prefix}:p-2`, `${prefix}:p-4`)).toBe(`${prefix}:p-4`);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('skips false values and keeps classes that do not conflict', () => {
|
|
13
|
+
expect(cn(`${prefix}:flex`, false, undefined, `${prefix}:gap-2`)).toBe(
|
|
14
|
+
`${prefix}:flex ${prefix}:gap-2`,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|