emdash-classic-theme 0.1.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/LICENSE +21 -0
- package/README.md +160 -0
- package/package.json +53 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +52 -0
- package/src/inject.ts +38 -0
- package/src/middleware-configured.ts +13 -0
- package/src/middleware.ts +13 -0
- package/src/serve.ts +32 -0
- package/src/skin.css +3184 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sites.pro.br
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# emdash-classic-theme
|
|
2
|
+
|
|
3
|
+
Gives the EmDash admin panel the look of wp-admin. It is one stylesheet, injected into the
|
|
4
|
+
`<head>` of `/_emdash/admin` by a middleware. Nothing in `node_modules` is patched, no core file
|
|
5
|
+
is copied, and no JavaScript runs inside the panel.
|
|
6
|
+
|
|
7
|
+
Dark sidebar `#1d2327`, active item `#2271b1`, canvas `#f0f0f1`, square corners, metabox-style
|
|
8
|
+
sections in the editor. If you have used wp-admin, your eyes already know where things are.
|
|
9
|
+
|
|
10
|
+
## Why the name
|
|
11
|
+
|
|
12
|
+
EmDash already ships the WordPress palette, and it already calls it `classic`. Open the compiled
|
|
13
|
+
`@emdash-cms/admin` stylesheet and you will find `#1d2327`, `#50575e`, `#646970`, `#f0f0f1`,
|
|
14
|
+
`#2271b1` and `#d63638` declared under `[data-theme="classic"]`. That decision is made and the
|
|
15
|
+
code is in your `node_modules` right now.
|
|
16
|
+
|
|
17
|
+
It just stops halfway. The `classic` block covers 20 of the 38 color tokens and 8 of the 16 text
|
|
18
|
+
tokens, with nothing for typography and nothing for radii, so the panel keeps its rounded, airy
|
|
19
|
+
geometry no matter what. And you cannot select it anyway: `admin.astro` hardcodes the attribute
|
|
20
|
+
and the `ThemeProvider` rewrites it on mount without reading any config. There is also no dark
|
|
21
|
+
variant, so the selector is written `[data-theme=classic]:not([data-mode=dark])` and all 28 tokens
|
|
22
|
+
stop matching the moment a visitor's system is set to dark.
|
|
23
|
+
|
|
24
|
+
This package finishes that theme from the outside: the missing tokens, the typography, the square
|
|
25
|
+
corners, the sidebar, the metaboxes. Hence `classic`, not some new name. The goal is for this to
|
|
26
|
+
become unnecessary, and there is an open request upstream to make it so (see below).
|
|
27
|
+
|
|
28
|
+
## Why you might want it
|
|
29
|
+
|
|
30
|
+
EmDash calls itself the spiritual successor to WordPress, and the migration path is genuinely
|
|
31
|
+
good: there are guides for porting themes, porting plugins and importing content. The one place
|
|
32
|
+
the migration does not help is the panel, which is exactly where a WordPress user's habits live.
|
|
33
|
+
The default admin is clean and modern, and that is the problem on the day you hand the keys to a
|
|
34
|
+
client who has been editing wp-admin since 2011.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm install emdash-classic-theme
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then pick one of the two ways to wire it. Both were exercised against emdash 0.36.0 on the
|
|
43
|
+
Cloudflare adapter.
|
|
44
|
+
|
|
45
|
+
### As an Astro integration (recommended)
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { defineConfig } from "astro/config";
|
|
49
|
+
import emdash from "emdash/astro";
|
|
50
|
+
import classicTheme from "emdash-classic-theme";
|
|
51
|
+
|
|
52
|
+
export default defineConfig({
|
|
53
|
+
integrations: [classicTheme(), emdash({ /* your config */ })],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Put `classicTheme()` before `emdash()` so its middleware wraps the panel's response.
|
|
58
|
+
|
|
59
|
+
### Through EmDash's `middleware.outer` slot
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
emdash({
|
|
63
|
+
middleware: { outer: "emdash-classic-theme/middleware" },
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use this if you prefer the documented hook. Note that `outer` is a single slot: if your site
|
|
68
|
+
already uses it for something else, take the integration above, or compose by hand with
|
|
69
|
+
`applySkin` and `buildHead` from `emdash-classic-theme/inject`.
|
|
70
|
+
|
|
71
|
+
The stylesheet is answered by that same middleware, at `/_emdash-classic-theme/skin.css`, with an
|
|
72
|
+
ETag derived from its own bytes. Astro's `injectRoute` is deliberately not used: a route injected
|
|
73
|
+
at that path never matched under the Cloudflare dev runner, and answering from the middleware also
|
|
74
|
+
keeps the two install paths behaving identically.
|
|
75
|
+
|
|
76
|
+
## Changing the colors
|
|
77
|
+
|
|
78
|
+
Every color, radius and dimension lives in a custom property named `--wp-*`, all of them declared
|
|
79
|
+
in block `[0]` at the top of `skin.css`. Override them by name, without the prefix:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
classicTheme({
|
|
83
|
+
tokens: {
|
|
84
|
+
accent: "#7f54b3",
|
|
85
|
+
"accent-hover": "#6b46a0",
|
|
86
|
+
ink: "#2c2c2c",
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The overrides are written into a `<style>` after the stylesheet, at the same specificity, so the
|
|
92
|
+
later declaration wins. About a dozen of the 32 tokens are worth touching for branding. The rest
|
|
93
|
+
are geometry you probably want to leave alone.
|
|
94
|
+
|
|
95
|
+
## What it does not do
|
|
96
|
+
|
|
97
|
+
It is a skin, not a reproduction. Icons stay Phosphor, the typeface stays EmDash's, and the page
|
|
98
|
+
structure is untouched. Someone who knows wp-admin well will spot the difference immediately. The
|
|
99
|
+
point is recognition, not disguise.
|
|
100
|
+
|
|
101
|
+
Dark mode is switched off on purpose (`color-scheme: light` in block `[0]`). This is not laziness.
|
|
102
|
+
Because the core rule is scoped `:not([data-mode=dark])`, a visitor whose system is dark loses all
|
|
103
|
+
28 WordPress-palette tokens at once and lands on light surfaces under light text: a success notice
|
|
104
|
+
measured 1.28:1 that way. WordPress has no dark mode either, so forcing light is consistent. The
|
|
105
|
+
consequence you are accepting is that the theme toggle in the panel header stops doing anything.
|
|
106
|
+
|
|
107
|
+
## Compatibility, honestly
|
|
108
|
+
|
|
109
|
+
| theme | tested against |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| 0.1.x | emdash 0.36.0, `@emdash-cms/admin` 0.36.0 |
|
|
112
|
+
|
|
113
|
+
The stylesheet anchors on `data-sidebar="content-container"`, `data-sidebar="menu-button"`,
|
|
114
|
+
`.emdash-sidebar` and the `#field-<slug>` ids. None of that is a public contract. It is the
|
|
115
|
+
internal DOM of Kumo, Cloudflare's design system, and it can change in any release with no warning
|
|
116
|
+
and no error: the failure mode is silent and visual. There are 457 `!important` declarations in
|
|
117
|
+
here for the same reason, most of them fighting inline styles that EmDash writes onto the sidebar
|
|
118
|
+
wrapper.
|
|
119
|
+
|
|
120
|
+
So pin your EmDash version, and open the panel and look at it after every bump. If the stylesheet
|
|
121
|
+
fails to load or the CSP changes, the panel falls back to its normal appearance without an error.
|
|
122
|
+
The risk you are taking is cosmetic, never functional.
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
One thing to know before you start, because it will otherwise cost you an afternoon: do not test
|
|
127
|
+
your changes through `npm link` or `bun link`. The Cloudflare dev runner refuses to read files
|
|
128
|
+
outside the project root, and a symlink puts the stylesheet there, so the panel answers 500 with
|
|
129
|
+
`Denied ID .../skin.css?raw` and nothing in that message points at the symlink. Run `npm pack` and
|
|
130
|
+
install the tarball instead. That is also closer to what a user gets.
|
|
131
|
+
|
|
132
|
+
Pull requests are welcome, with one condition that exists to protect everybody's panel: a visual
|
|
133
|
+
change has to arrive with measurements, not opinions. The numbers that matter are popup widths,
|
|
134
|
+
card heights, contrast ratios and focus ring visibility, measured against the real
|
|
135
|
+
`admin/dist/styles.css` served next to `skin.css`. A PR that moves one of them should say why.
|
|
136
|
+
|
|
137
|
+
Two contrast decisions are already logged and were taken with eyes open. `--wp-line` and
|
|
138
|
+
`--wp-field-line` are both `#dcdcde`, which measures 1.36:1 on white and therefore sits under the
|
|
139
|
+
3:1 that WCAG 1.4.11 asks of a border. What carries accessibility instead is the `#8c8f94` hover
|
|
140
|
+
state and the 2px focus ring, which is the part the standard actually needs you to see. The four
|
|
141
|
+
status text colors were moved off the `classic` values for the opposite reason: those fail AA
|
|
142
|
+
outright, with `#00a32a` measuring 3.35:1.
|
|
143
|
+
|
|
144
|
+
## Upstream
|
|
145
|
+
|
|
146
|
+
The right long-term fix is two additive lines in EmDash itself: a way to pick the admin theme, and
|
|
147
|
+
a way to set its default light or dark. The attribute and the CSS layer both exist already, so
|
|
148
|
+
this is a switch, not a feature. It is being asked for in
|
|
149
|
+
[discussion #1493](https://github.com/emdash-cms/emdash/discussions/1493). If you want this
|
|
150
|
+
package to become obsolete, that thread is where to say so.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT.
|
|
155
|
+
|
|
156
|
+
## Trademark
|
|
157
|
+
|
|
158
|
+
WordPress is a registered trademark of the WordPress Foundation. This project is not affiliated
|
|
159
|
+
with, endorsed by, or sponsored by them. The name is used here only to describe what the
|
|
160
|
+
stylesheet does.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "emdash-classic-theme",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A WordPress-looking skin for the EmDash CMS admin panel. CSS only, no core patching.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"emdash",
|
|
7
|
+
"emdash-cms",
|
|
8
|
+
"admin",
|
|
9
|
+
"skin",
|
|
10
|
+
"theme",
|
|
11
|
+
"wordpress",
|
|
12
|
+
"wp-admin",
|
|
13
|
+
"astro"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./middleware": "./src/middleware.ts",
|
|
20
|
+
"./middleware-configured": "./src/middleware-configured.ts",
|
|
21
|
+
"./inject": "./src/inject.ts",
|
|
22
|
+
"./skin.css": "./src/skin.css",
|
|
23
|
+
"./package.json": "./package.json",
|
|
24
|
+
"./serve": "./src/serve.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"astro": "^7.0.0",
|
|
33
|
+
"emdash": ">=0.36.0 <0.37.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"astro": "^7.3.1",
|
|
40
|
+
"typescript": "^5.9.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/marks-zyz/emdash-classic-theme.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/marks-zyz/emdash-classic-theme/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/marks-zyz/emdash-classic-theme#readme"
|
|
53
|
+
}
|
package/src/env.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
import { CSS_ROUTE, type SkinTokens } from "./inject.js";
|
|
3
|
+
|
|
4
|
+
const VIRTUAL_ID = "virtual:emdash-classic-theme/config";
|
|
5
|
+
const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`;
|
|
6
|
+
|
|
7
|
+
export interface ClassicThemeOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Overrides for the skin's own custom properties, written without the `--wp-` prefix
|
|
10
|
+
* (`{ accent: "#2271b1" }` sets `--wp-accent`). Names are listed in block [0] of skin.css.
|
|
11
|
+
*/
|
|
12
|
+
tokens?: SkinTokens;
|
|
13
|
+
/** Where the stylesheet is served from. Change only on a path collision. */
|
|
14
|
+
cssRoute?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function classicTheme(options: ClassicThemeOptions = {}): AstroIntegration {
|
|
18
|
+
const cssRoute = options.cssRoute ?? CSS_ROUTE;
|
|
19
|
+
const tokens = options.tokens ?? {};
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: "emdash-classic-theme",
|
|
23
|
+
hooks: {
|
|
24
|
+
"astro:config:setup": ({ addMiddleware, updateConfig }) => {
|
|
25
|
+
updateConfig({
|
|
26
|
+
vite: {
|
|
27
|
+
plugins: [
|
|
28
|
+
{
|
|
29
|
+
name: "emdash-classic-theme:config",
|
|
30
|
+
resolveId: (id: string) =>
|
|
31
|
+
id === VIRTUAL_ID ? RESOLVED_VIRTUAL_ID : null,
|
|
32
|
+
load: (id: string) =>
|
|
33
|
+
id === RESOLVED_VIRTUAL_ID
|
|
34
|
+
? `export const tokens = ${JSON.stringify(tokens)};\nexport const cssRoute = ${JSON.stringify(cssRoute)};`
|
|
35
|
+
: null,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
addMiddleware({
|
|
43
|
+
entrypoint: "emdash-classic-theme/middleware-configured",
|
|
44
|
+
order: "pre",
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { CSS_ROUTE };
|
|
52
|
+
export type { SkinTokens };
|
package/src/inject.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const CSS_ROUTE = "/_emdash-classic-theme/skin.css";
|
|
2
|
+
|
|
3
|
+
export type SkinTokens = Record<string, string>;
|
|
4
|
+
|
|
5
|
+
/** Same specificity as the skin's own token block, but later in the document, so it wins. */
|
|
6
|
+
export function buildHead(tokens: SkinTokens = {}, href: string = CSS_ROUTE): string {
|
|
7
|
+
const link = `<link rel="stylesheet" href="${href}">`;
|
|
8
|
+
const declarations = Object.entries(tokens)
|
|
9
|
+
.map(([name, value]) => `--wp-${name}:${value}`)
|
|
10
|
+
.join(";");
|
|
11
|
+
return declarations ? `${link}<style>html:root[data-mode]{${declarations}}</style>` : link;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function applySkin(response: Response, request: Request, head: string): Promise<Response> {
|
|
15
|
+
if (request.method !== "GET") return response;
|
|
16
|
+
if (!new URL(request.url).pathname.startsWith("/_emdash/admin")) return response;
|
|
17
|
+
if (response.status !== 200) return response;
|
|
18
|
+
if (!(response.headers.get("content-type") ?? "").includes("text/html")) return response;
|
|
19
|
+
|
|
20
|
+
const html = await response.text();
|
|
21
|
+
const closingHead = html.indexOf("</head>");
|
|
22
|
+
if (closingHead < 0) {
|
|
23
|
+
return new Response(html, {
|
|
24
|
+
status: response.status,
|
|
25
|
+
statusText: response.statusText,
|
|
26
|
+
headers: response.headers,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const headers = new Headers(response.headers);
|
|
31
|
+
headers.delete("content-length"); // body grew
|
|
32
|
+
|
|
33
|
+
return new Response(html.slice(0, closingHead) + head + html.slice(closingHead), {
|
|
34
|
+
status: response.status,
|
|
35
|
+
statusText: response.statusText,
|
|
36
|
+
headers,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineMiddleware } from "astro:middleware";
|
|
2
|
+
// Provided by the integration's Vite plugin. Only reachable when classicTheme() is registered.
|
|
3
|
+
import { cssRoute, tokens } from "virtual:emdash-classic-theme/config";
|
|
4
|
+
import { applySkin, buildHead } from "./inject.js";
|
|
5
|
+
import { serveSkin } from "./serve.js";
|
|
6
|
+
|
|
7
|
+
const HEAD = buildHead(tokens, cssRoute);
|
|
8
|
+
|
|
9
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
10
|
+
const stylesheet = serveSkin(context.request, cssRoute);
|
|
11
|
+
if (stylesheet) return stylesheet;
|
|
12
|
+
return applySkin(await next(), context.request, HEAD);
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineMiddleware } from "astro:middleware";
|
|
2
|
+
import { applySkin, buildHead, CSS_ROUTE } from "./inject.js";
|
|
3
|
+
import { serveSkin } from "./serve.js";
|
|
4
|
+
|
|
5
|
+
const HEAD = buildHead();
|
|
6
|
+
|
|
7
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
8
|
+
const stylesheet = serveSkin(context.request, CSS_ROUTE);
|
|
9
|
+
if (stylesheet) return stylesheet;
|
|
10
|
+
return applySkin(await next(), context.request, HEAD);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export { CSS_ROUTE };
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import css from "./skin.css?raw";
|
|
2
|
+
|
|
3
|
+
// FNV-1a over the stylesheet: a new build of the skin invalidates the cached copy on its own,
|
|
4
|
+
// so the href never needs a version query string.
|
|
5
|
+
function fingerprint(input: string): string {
|
|
6
|
+
let hash = 0x811c9dc5;
|
|
7
|
+
for (let i = 0; i < input.length; i++) {
|
|
8
|
+
hash ^= input.charCodeAt(i);
|
|
9
|
+
hash = Math.imul(hash, 0x01000193);
|
|
10
|
+
}
|
|
11
|
+
return `"${(hash >>> 0).toString(36)}"`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TAG = fingerprint(css);
|
|
15
|
+
|
|
16
|
+
/** Returns the stylesheet when the request is for it, and null when it is not ours to answer. */
|
|
17
|
+
export function serveSkin(request: Request, route: string): Response | null {
|
|
18
|
+
if (request.method !== "GET") return null;
|
|
19
|
+
if (new URL(request.url).pathname !== route) return null;
|
|
20
|
+
|
|
21
|
+
if (request.headers.get("if-none-match") === TAG) {
|
|
22
|
+
return new Response(null, { status: 304, headers: { etag: TAG } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Response(css, {
|
|
26
|
+
headers: {
|
|
27
|
+
"content-type": "text/css; charset=utf-8",
|
|
28
|
+
"cache-control": "public, max-age=300",
|
|
29
|
+
etag: TAG,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|