Initial check-in. Working, but incomplete.

This commit is contained in:
Ken Sternberg
2026-06-16 16:33:18 -07:00
parent 614aa6e420
commit 9cb335679b
17 changed files with 281 additions and 305 deletions
-56
View File
@@ -1,56 +0,0 @@
/**
* @file Color tokens — semantic surface, text, state, and brand colors.
*
* Light values are declared via `variable()`. Dark values are declared inside
* the `dark` theme block so they emit under `html[data-theme="dark"]`.
*
* Link tokens are wired through `ref()` so brand overrides to `color.primary`
* cascade to links without separate overrides. The dark theme intentionally
* re-points links to their own oklab values rather than chaining through
* primary because dark mode links need higher luminance than primary buttons.
*
* `warning` and `danger` deliberately stay on light values in dark mode — state
* colors keep consistent intensity across themes so warnings read as urgent.
*/
import { ref, theme, variable } from "../shared.js";
export const colorAccent = variable("color.accent", "#fd4b2d");
export const colorPrimary = variable("color.primary", "oklab(0.522 -0.0434 -0.1717)");
export const colorPrimaryHover = variable("color.primary-hover", "oklab(0.3763 -0.0324 -0.1182)");
export const colorText = variable("color.text", "oklab(0.1957 -0 0)");
export const colorTextMuted = variable("color.text-muted", "oklab(0.5364 -0.0026 -0.0089)");
export const colorLink = variable("color.link", ref(colorPrimary));
export const colorLinkHover = variable("color.link-hover", ref(colorPrimaryHover));
export const colorLinkVisited = variable("color.link-visited", "oklab(0.3679 0.0535 -0.1797)");
export const colorSurface = variable("color.surface", "oklab(1 -0 0)");
export const colorSurfaceMuted = variable("color.surface-muted", "oklab(0.9551 -0 0)");
export const colorSurfaceRaised = variable("color.surface-raised", "oklab(0.9851 -0 0)");
export const colorBorder = variable("color.border", "#d2d2d2");
export const colorBorderStrong = variable("color.border-strong", "#8a8d90");
export const colorInfo = variable("color.info", "oklab(0.6689 -0.0608 -0.1513)");
export const colorSuccess = variable("color.success", "oklab(0.5549 -0.1071 0.0852)");
export const colorWarning = variable("color.warning", "oklab(0.7864 0.0298 0.1604)");
export const colorDanger = variable("color.danger", "oklab(0.5331 0.1798 0.1034)");
// Dark theme overrides. Surface values are pinned near PatternFly 4's
// BackgroundColor--100 (#151515) and the legacy drawer surface (#18191a) — the
// earlier oklab(0.23) value visibly brightened every PF-backed dark panel.
theme("dark", (ctx) => {
ctx.variable(colorText, "oklab(0.9067 -0 0)");
ctx.variable(colorTextMuted, "oklab(0.7407 -0.0007 -0.0017)");
ctx.variable(colorLink, "oklab(70.367% -0.07498 -0.139)");
ctx.variable(colorLinkHover, "oklab(0.7706 -0.0485 -0.1012)");
ctx.variable(colorLinkVisited, "oklab(0.7137 0.0522 -0.151)");
ctx.variable(colorSurface, "oklab(0.183 -0 0)");
ctx.variable(colorSurfaceMuted, "oklab(0.14 -0 0)");
ctx.variable(colorSurfaceRaised, "oklab(0.225 -0 0)");
ctx.variable(colorBorder, "oklab(0.3906 0.0001 -0.0052)");
ctx.variable(colorBorderStrong, "oklab(0.4602 -0.0003 -0.0034)");
ctx.variable(colorInfo, "oklab(0.7706 -0.0485 -0.1012)");
ctx.variable(colorSuccess, "oklab(0.6488 -0.1066 0.0846)");
});
+151
View File
@@ -21,7 +21,10 @@
"@goauthentik/prettier-config": "../prettier-config",
"@goauthentik/tsconfig": "../tsconfig",
"@styleframe/cli": "^4.0.0",
"@types/culori": "^4.0.1",
"@types/node": "^25.7.0",
"@typescript/native-preview": "^7.0.0-dev.20260616.1",
"culori": "^4.0.2",
"eslint": "^9.39.3",
"pino": "^10.3.1",
"pino-pretty": "^13.1.2",
@@ -638,6 +641,13 @@
"@styleframe/license": "^2.0.2"
}
},
"node_modules/@types/culori": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/culori/-/culori-4.0.1.tgz",
"integrity": "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -944,6 +954,147 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-+AuZUl7nkLPXL1rwsyZZF7iasu0HkrL5O6aWwUC0cObD5EvNgxz3hXbBhsSvuJ8tb2JRpVwFsE0BHPcYVe5GkA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260616.1",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20260616.1",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20260616.1",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20260616.1",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20260616.1",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20260616.1",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20260616.1"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-9SwegwwE7fYutnZJjTi2PeUXhKGFg82MGjSpCFD2cj5v9YQcs5oO5QsmeeMit4fMG8Z83Jy8knOF97d9NaQ7Bg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-gl7R8OiwEBNxzs5wjbM9XOibTs5b6/gAgu0+En9pLpYuWR/EITs8Eh0mNQui4JYTp1SkoHvn09aRZKTQf21XTg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-QWXQS2CrhSpXbng7vBtCVDszFgwVBuJU8MCFhxZL0hH6s+XjQDSNNkGO1oHueBr+7HbSkkyqfNYVNXvgVMUJLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-CJjplWoE+EeYtbyNeP4fyuh0QcPiQBZJSLqPS07E3ugo3d9M/IG8WnL+3GFQ0g1p4c6QC/+OC0oTTQnGcNdd2Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-UVySBNnGTAnul2kO2/EhlVZl0CeTDcd6Lzi+WkvgyCgapTcU0BX1selQ4MEPtbVWKUbt9HBhxNku2dyaCcmKVw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-kdX0QcDXiESH0o5DFdSWw15Hth0EtQobT9tX28ofqBUwGU1FFhwTKzA6qo7chaYUSW5MMd6XIME9rBwk+WizLw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20260616.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260616.1.tgz",
"integrity": "sha512-EB0Pj/0+nXifS3+wN0HdR1mKu7IieSpjMXoDjdXtJAdiPGlmKazBgHj97qn6CBvXisgLx0Eyb7tLCEQNDG53cA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+4 -1
View File
@@ -55,11 +55,14 @@
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@goauthentik/prettier-config": "../prettier-config",
"@goauthentik/eslint-config": "../eslint-config",
"@goauthentik/prettier-config": "../prettier-config",
"@goauthentik/tsconfig": "../tsconfig",
"@styleframe/cli": "^4.0.0",
"@types/culori": "^4.0.1",
"@types/node": "^25.7.0",
"@typescript/native-preview": "^7.0.0-dev.20260616.1",
"culori": "^4.0.2",
"eslint": "^9.39.3",
"pino": "^10.3.1",
"pino-pretty": "^13.1.2",
-212
View File
@@ -1,212 +0,0 @@
/**
* @file Build script for `@goauthentik/theme`.
*
* Runs the styleframe transpiler against the configured token tree, then
* fans out the single emitted CSS string into one file per category
* (color, typography, spacing, shape, shadow, motion, z-index) and a top-
* level `index.css` that `@import`s them in deterministic order.
*
* Consumers can import the whole surface
* (`@import "@goauthentik/theme/index.css"`) or cherry-pick a category
* (`@import "@goauthentik/theme/color.css"`).
*
* Invoked by `npm run build:assets` and chained from `npm run build`.
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "../lib/node.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = resolve(__dirname, "..");
const OUT_DIR = resolve(PACKAGE_ROOT, "dist");
/**
* @typedef {object} Category
* @property {string} name Slug used for the output filename.
* @property {string[]} prefixes
* Token-name prefixes (after the `--ak-` strip) that belong to this category.
*/
/** @type {Category[]} */
const CATEGORIES = [
{ name: "color", prefixes: ["color-"] },
{
name: "typography",
prefixes: ["font-family-", "font-size-", "font-weight-", "line-height-"],
},
{ name: "spacing", prefixes: ["space-"] },
{ name: "shape", prefixes: ["radius-", "border-width-"] },
{ name: "shadow", prefixes: ["shadow-"] },
{ name: "motion", prefixes: ["duration-", "easing-"] },
{ name: "z-index", prefixes: ["z-index-"] },
];
const HEADER = [
"/*",
" * ⚠️ GENERATED FILE — do not edit directly.",
" *",
" * Source: packages/theme/lib/tokens/*.js",
" * Built: packages/theme/scripts/build.mjs",
" */",
"",
].join("\n");
/**
* One emitted block within the styleframe CSS output. `header` is the line
* that opens the block (`:root {`, `@media (...) {`, `html[data-theme="..."] {`).
* `prefix` is whitespace that should precede declarations inside the block,
* preserved verbatim from the source. `closer` is the closing brace(s).
*
* @typedef {object} ParsedBlock
* @property {string} header
* @property {string[]} declarations
* @property {string} closer
*/
/**
* Split the styleframe CSS output into top-level blocks. Each block is one of:
*
* :root { … }
* html[data-theme="…"] { … }
* @media (…) { :root { … } }
*
* Nested `:root` inside `@media` is preserved as part of the block — the
* inner declarations are kept as a flat list and re-wrapped on emit.
*
* @param {string} css
* @returns {ParsedBlock[]}
*/
function parseBlocks(css) {
/** @type {ParsedBlock[]} */
const blocks = [];
// Top-level blocks separated by blank lines in styleframe's output.
// Each block ends with a balanced closing brace at column zero. We don't
// need a real CSS parser — the styleframe emitter is regular enough that
// a small line-based state machine handles it.
const lines = css.split("\n");
let i = 0;
while (i < lines.length) {
while (i < lines.length && (lines[i] ?? "").trim() === "") i++;
if (i >= lines.length) break;
const opener = lines[i] ?? "";
const header = opener.trim();
if (!header.endsWith("{")) {
i++;
continue;
}
const openerIndent = opener.match(/^\s*/)?.[0] ?? "";
i++;
/** @type {string[]} */
const innerLines = [];
while (i < lines.length) {
const line = lines[i] ?? "";
const trimmed = line.trim();
const indent = line.match(/^\s*/)?.[0] ?? "";
if (trimmed === "}" && indent === openerIndent) {
i++;
break;
}
innerLines.push(line);
i++;
}
blocks.push({
header,
declarations: innerLines,
closer: "}",
});
}
return blocks;
}
/**
* Extract every `--ak-*: …;` declaration from a flat list of lines (which may
* include a nested `:root { … }` wrapper from an `@media` block).
*
* @param {string[]} lines
* @returns {string[]}
*/
function flattenDeclarations(lines) {
return lines.filter((line) => /^\s*--ak-[a-z0-9-]+\s*:/.test(line));
}
/**
* Build the CSS for one category by filtering each parsed block to that
* category's declarations and re-wrapping them.
*
* @param {Category} category
* @param {ParsedBlock[]} blocks
*/
function buildCategoryFile(category, blocks) {
/** @param {string} line */
const matches = (line) => {
const declaration = line.match(/--ak-([a-z0-9-]+):/);
if (!declaration || !declaration[1]) return false;
const name = declaration[1];
return category.prefixes.some((prefix) => name.startsWith(prefix));
};
/** @type {string[]} */
const sections = [];
for (const block of blocks) {
if (block.header.startsWith("@media")) {
// Drill into the inner `:root { … }` wrapper.
const inner = flattenDeclarations(block.declarations).filter(matches);
if (inner.length === 0) continue;
sections.push(
`${block.header}\n\t:root {\n${inner
.map((line) => "\t\t" + line.trim())
.join("\n")}\n\t}\n}`,
);
continue;
}
const filtered = block.declarations.filter(matches);
if (filtered.length === 0) continue;
sections.push(
`${block.header}\n${filtered.map((line) => "\t" + line.trim()).join("\n")}\n}`,
);
}
if (sections.length === 0) return null;
return HEADER + sections.join("\n\n") + "\n";
}
/**
* Build the index.css that `@import`s every emitted category file in order.
*
* @param {string[]} emittedNames
*/
function buildIndex(emittedNames) {
const imports = emittedNames.map((name) => `@import "./${name}.css";`).join("\n");
return HEADER + imports + "\n";
}
await mkdir(OUT_DIR, { recursive: true });
const { css } = await build();
const blocks = parseBlocks(css);
const emitted = [];
for (const category of CATEGORIES) {
const content = buildCategoryFile(category, blocks);
if (content === null) {
console.warn(`⚠️ No declarations found for category ${category.name}; skipping.`);
continue;
}
await writeFile(resolve(OUT_DIR, `${category.name}.css`), content, "utf-8");
emitted.push(category.name);
}
await writeFile(resolve(OUT_DIR, "index.css"), buildIndex(emitted), "utf-8");
console.log(`✅ Wrote dist/index.css + ${emitted.length} category files (${emitted.join(", ")})`);
@@ -9,5 +9,5 @@
* filesystem live in `./lib/node.js` (exposed via the `./build` subpath).
*/
export { instance, ref, selector, theme, variable } from "./lib/shared.js";
export * from "./lib/tokens/index.js";
export { instance, ref, selector, theme, variable } from "./shared.js";
export * from "./tokens/index.js";
@@ -1,4 +1,4 @@
/**
/*
* @file Node-only build helpers for `@goauthentik/theme`.
*
* Re-exports everything the browser entry exports, plus filesystem-touching
@@ -7,7 +7,8 @@
* @import { OutputFile } from "@styleframe/transpiler";
*/
/// <reference types="../types/node.js" />
import type { OutputFile } from "@styleframe/transpiler";
import { writeFile } from "node:fs/promises";
@@ -28,6 +29,10 @@ export * from "./tokens/index.js";
* this absolute path in addition to being returned.
*/
interface BuildOptions {
outFile?: string;
}
/**
* @typedef {object} BuildResult
* @property {string} css The full CSS string emitted by styleframe.
@@ -35,6 +40,11 @@ export * from "./tokens/index.js";
* to inspect every file styleframe produced.
*/
interface BuildResult {
css: string;
files: OutputFile[];
}
/**
* Transpile the configured token tree to CSS.
*
@@ -44,7 +54,7 @@ export * from "./tokens/index.js";
* @param {BuildOptions} [options]
* @returns {Promise<BuildResult>}
*/
export async function build(options = {}) {
export async function build(options: BuildOptions = {}): Promise<BuildResult> {
const output = await transpile(instance, { type: "css" });
const cssFile = output.files.find((file) => file.name.endsWith(".css"));
const css = cssFile?.content ?? "";
@@ -25,11 +25,12 @@ import { styleframe } from "styleframe";
* @type {StyleframeOptions}
*/
export const authentikStyleframeOptions = {
indent: " ",
variables: {
name: ({ name }) => "ak-" + name.replace(/\./g, "-"),
name: ({ name }: { name: string }) => `ak-${name.replace(/\./g, "-")}`
},
themes: {
selector: ({ name }) => `html[data-theme="${name}"]`,
selector: ({ name }: { name: string}) => `html[data-theme="${name}"]`,
},
};
+106
View File
@@ -0,0 +1,106 @@
/**
* @file Color tokens — semantic surface, text, state, and brand colors.
*
* Light values are declared via `variable()`. Dark values are declared inside
* the `dark` theme block so they emit under `html[data-theme="dark"]`.
*
* Link tokens are wired through `ref()` so brand overrides to `color.primary`
* cascade to links without separate overrides. The dark theme intentionally
* re-points links to their own oklab values rather than chaining through
* primary because dark mode links need higher luminance than primary buttons.
*
* `warning` and `danger` deliberately stay on light values in dark mode — state
* colors keep consistent intensity across themes so warnings read as urgent.
*/
import { instance, theme } from "../shared.js";
import { createVariableFunction } from "styleframe";
import { createUseVariable } from "@styleframe/theme";
import { formatHex, oklch as toOklch, toGamut } from "culori";
/*
* Restrict the OKLCH values to six decimal places; Javascript will give it to you accurate to 16
* places, but OKLCH doesn't much care past six, and 16 is just unreadable.
*
* Includes in each OKLCH output line a comment containing the RGB value, to help IDEs show the
* color accurately.
*/
const toSrgb = toGamut("rgb", "oklch");
const round = (v: number, precision = 6) => Number(v.toFixed(precision)).toString();
const oklchTransform = (value: string) => {
const c = toOklch(value);
if (!c || c.l == null || c.c == null) {
return value;
}
const result = `oklch(${round(c.l)} ${round(c.c)} ${round(c.h ?? 0)} / ${round(c.alpha ?? 1)})`;
return `${result} /* ${formatHex(toSrgb(c))} */`;
};
const useColorDesignTokens = createUseVariable("color", { transform: oklchTransform });
export const {
colorAccent,
colorPrimary,
colorPrimaryHover,
colorText,
colorTextMuted,
colorLink,
colorLinkHover,
colorLinkVisited,
colorSurface,
colorSurfaceRaised,
colorSurfaceMuted,
colorBorder,
colorBorderStrong,
colorInfo,
colorSuccess,
colorWarning,
colorDanger,
} = useColorDesignTokens(instance, {
"accent": "#fd4b2d",
"primary": "#0066cc",
"primary-hover": "#004080",
"text": "#151515",
"text-muted": "#6a6e73",
"link": "@color.primary",
"link-hover": "@color.primary-hover",
"link-visited": "#40199a",
"surface": "#ffffff",
"surface-raised": "#fafafa",
"surface-muted": "#f0f0f0",
"border": "#d2d2d2",
"border-strong": "#8a8d90",
"info": "#2b9af3",
"success": "#3e8635",
"warning": "#f0ab00",
"danger": "#c9190b",
});
type Variable = ReturnType<ReturnType<typeof createVariableFunction>>;
type VPPair = [Variable, string];
// Dark theme overrides. Surface values are pinned near PatternFly 4's
// BackgroundColor--100 (#151515) and the legacy drawer surface (#18191a) — the
// earlier oklab(0.23) value visibly brightened every PF-backed dark panel.
theme("dark", (ctx) => {
const darkColors: VPPair[] = [
[colorText, "#e0e0e0"],
[colorTextMuted, "#aaabac"],
[colorLink, "#20a9f8"],
[colorLinkHover, "#73bcf7"],
[colorLinkVisited, "#a18fff"],
[colorSurface, "#121212"],
[colorSurfaceMuted, "#090909"],
[colorSurfaceRaised, "#1c1c1c"],
[colorBorder, "#444548"],
[colorBorderStrong, "#57585a"],
[colorInfo, "#73bcf7"],
[colorSuccess, "#5ba352"]
];
darkColors.forEach(([v, p]) => ctx.variable(v, oklchTransform(p)));
});
@@ -11,8 +11,8 @@
* the same tree the rest of the package uses.
*/
import "./lib/tokens/index.js";
import "./src/tokens/index.js";
import { instance } from "./lib/shared.js";
import { instance } from "./src/shared.js";
export default instance;
-27
View File
@@ -1,27 +0,0 @@
declare module "process" {
import { Level } from "pino";
global {
namespace NodeJS {
interface ProcessEnv {
/**
* An environment variable used to determine
* whether Node.js is running in production mode.
*
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
*/
readonly NODE_ENV?: "development" | "production";
/**
* Whether or not we are running on a CI server.
*/
readonly CI?: string;
/**
* The application log level.
*/
readonly AK_LOG_LEVEL?: Level;
}
}
}
}