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 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
@@ -0,0 +1,9 @@
1
+ declare module "*.css?raw" {
2
+ const content: string;
3
+ export default content;
4
+ }
5
+
6
+ declare module "virtual:emdash-classic-theme/config" {
7
+ export const tokens: Record<string, string>;
8
+ export const cssRoute: string;
9
+ }
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
+ }