diff --git a/website/docs/developer-docs/docs/theming/index.mdx b/website/docs/developer-docs/docs/theming/index.mdx new file mode 100644 index 0000000000..fceba79dfe --- /dev/null +++ b/website/docs/developer-docs/docs/theming/index.mdx @@ -0,0 +1,75 @@ +--- +title: Documentation Theming +sidebar_label: Theming +--- + +import { + PaletteGroup, + ColorGroup, +} from "@goauthentik/docusaurus-theme/components/infima/Swatch/index.tsx"; +import { + DispositionDangerColorEntries, + DispositionInfoColorEntries, + DispositionSuccessColorEntries, + DispositionWarningColorEntries, + InfimaColorsMap, + UtilityColorEntries, +} from "@goauthentik/docusaurus-theme/components/infima/constants.ts"; + +:::info Advanced + +This section is intended for developers of authentik's documentation site. If you are looking to customize the theming of your own authentik instance, please refer to the [branding](../../../sys-mgmt/brands.md) documentation. + +::: + +The authentik documentation site is built using Meta's [Docusaurus](https://docusaurus.io/), which uses their internal [Infima CSS framework](https://infima.dev/) for its styling and theming capabilities. Infima's own documentation is limited, possibly due to it's internal nature and Docusaurus being the primary consumer. This document aims to provide an overview of how theming is handled in the authentik documentation site, and how you can customize it. + +## Infima Color Palette + +With the exception of a few customizations, our color palette is managed through Infima's theming system. + +### Primary + + + +### Secondary + + + +### Success + + + +### Info + + + +### Warning + + + +### Danger + + + +## Utility & UI Colors + + + +## Dispositions + +### Info + + + +### Success + + + +### Warning + + + +### Danger + + diff --git a/website/docs/sidebar.mjs b/website/docs/sidebar.mjs index 243d3fb727..e75bc1d148 100644 --- a/website/docs/sidebar.mjs +++ b/website/docs/sidebar.mjs @@ -857,6 +857,7 @@ const items = [ }, items: [ "developer-docs/docs/style-guide", + "developer-docs/docs/theming/index", { type: "category", label: "Templates", diff --git a/website/docusaurus-theme/components/infima/Swatch/index.tsx b/website/docusaurus-theme/components/infima/Swatch/index.tsx new file mode 100644 index 0000000000..74e03e41dc --- /dev/null +++ b/website/docusaurus-theme/components/infima/Swatch/index.tsx @@ -0,0 +1,110 @@ +import styles from "./styles.module.css"; + +import { + ColorEntry, + ColorGroupProp, + computeColor, + ComputedColor, + createComputedColorGroup, + Prefix, +} from "@goauthentik/docusaurus-theme/components/infima/shared.ts"; + +import { useColorMode } from "@docusaurus/theme-common"; +import { useMemo, useState } from "react"; + +interface ColorSwatchProps extends ComputedColor { + showVar?: boolean; +} + +const ColorSwatch: React.FC = ({ + cssVar, + label, + hex, + contrastColor, + showVar = true, +}) => { + const [copied, setCopied] = useState(false); + + const copyToClipboard = async (): Promise => { + try { + await navigator.clipboard.writeText(cssVar); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + return ( +
+
{label}
+ {showVar ?
{cssVar}
: null} +
{hex || "—"}
+ {copied ?
Copied!
: null} +
+ ); +}; + +export interface ColorGroupProps { + group?: ColorGroupProp; +} + +export const ColorGroup: React.FC = ({ group }) => { + const { colorMode } = useColorMode(); + + if (!group) { + throw new TypeError("Invalid color group name"); + } + + const computed = useMemo(() => createComputedColorGroup(group, colorMode), [group, colorMode]); + + return ( +
+ {computed.colors.map((color) => ( + + ))} +
+ ); +}; + +export interface PaletteGroupProps { + entries?: Iterable; +} + +export const PaletteGroup: React.FC = ({ entries }) => { + const { colorMode } = useColorMode(); + + if (!entries) { + throw new TypeError("Invalid utility color entries"); + } + + const swatchProps: ColorSwatchProps[] = useMemo(() => { + return Array.from(entries, ([label, partialCSSVar]) => { + const cssVar = `${Prefix.Infima}${partialCSSVar}`; + const { hex, contrastColor } = computeColor(cssVar); + + return { cssVar, label, hex, contrastColor, showVar: true, colorMode }; + }); + }, [entries, colorMode]); + + return ( +
+ {swatchProps.map((props) => ( + + ))} +
+ ); +}; diff --git a/website/docusaurus-theme/components/infima/Swatch/styles.module.css b/website/docusaurus-theme/components/infima/Swatch/styles.module.css new file mode 100644 index 0000000000..ece6d540bd --- /dev/null +++ b/website/docusaurus-theme/components/infima/Swatch/styles.module.css @@ -0,0 +1,61 @@ +.colorGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--ifm-global-spacing); +} + +.utilityGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--ifm-global-spacing); +} + +.swatch { + padding: 12px 16px; + border-radius: var(--ifm-global-radius); + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid rgba(128, 128, 128, 0.15); + position: relative; + min-height: 70px; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--ifm-global-shadow-lw); +} + +.swatch:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.swatchLabel { + font-weight: 600; + font-size: 13px; + margin-bottom: 4px; +} + +.swatchVar { + opacity: 0.8; + word-break: break-all; + font-family: var(--ifm-font-family-monospace); + font-weight: var(--ifm-font-weight-semibold); + font-size: 0.75rem; +} + +.swatchHex { + font-size: 0.875rem; + font-family: var(--ifm-font-family-monospace); +} + +.copiedToast { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.85); + color: #fff; + padding: 6px 12px; + border-radius: var(--ifm-global-radius); + font-weight: 500; +} diff --git a/website/docusaurus-theme/components/infima/constants.ts b/website/docusaurus-theme/components/infima/constants.ts new file mode 100644 index 0000000000..427730a4c6 --- /dev/null +++ b/website/docusaurus-theme/components/infima/constants.ts @@ -0,0 +1,126 @@ +import { + ColorEntry, + ColorGroupProp, + Shade, +} from "@goauthentik/docusaurus-theme/components/infima/shared.ts"; + +const shades: Shade[] = [ + ["Darkest", "-darkest"], + ["Darker", "-darker"], + ["Dark", "-dark"], + ["Default", ""], + ["Light", "-light"], + ["Lighter", "-lighter"], + ["Lightest", "-lightest"], +]; + +export const InfimaColorsMap: ReadonlyMap = new Map([ + [ + "primary", + { + label: "Primary", + cssVar: "color-primary", + shades, + }, + ], + [ + "secondary", + { + label: "Secondary", + cssVar: "color-secondary", + shades, + }, + ], + [ + "success", + { + label: "Success", + cssVar: "color-success", + shades, + }, + ], + [ + "info", + { + label: "Info", + cssVar: "color-info", + shades, + }, + ], + [ + "warning", + { + label: "Warning", + cssVar: "color-warning", + shades, + }, + ], + [ + "danger", + { + label: "Danger", + cssVar: "color-danger", + shades, + }, + ], +]); + +export const UtilityColorEntries: readonly ColorEntry[] = [ + ["Background Color", "background-color"], + ["Background Surface", "background-surface-color"], + ["Font Color Base", "font-color-base"], + ["Font Color Secondary", "font-color-secondary"], + ["Content Color", "color-content"], + ["Content Color Inverse", "color-content-inverse"], + ["Content Color Secondary", "color-content-secondary"], + ["Heading Color", "heading-color"], + ["Link Color", "link-color"], + ["Menu Color", "menu-color"], + ["Menu Color Active", "menu-color-active"], + ["Navbar Background", "navbar-background-color"], + ["Footer Background", "footer-background-color"], + ["Card Background", "card-background-color"], + ["Code Background", "code-background"], + ["Toc Border", "toc-border-color"], + ["Table Stripe", "table-stripe-background"], + ["Hover Overlay", "hover-overlay"], +]; + +export const DispositionInfoColorEntries: readonly ColorEntry[] = [ + ["Contrast Background", "color-info-contrast-background"], + ["Dark", "color-info-dark"], + ["Darker", "color-info-darker"], + ["Darkest", "color-info-darkest"], + ["Light", "color-info-light"], + ["Lighter", "color-info-lighter"], + ["Lightest", "color-info-lightest"], +]; + +export const DispositionSuccessColorEntries: readonly ColorEntry[] = [ + ["Contrast Background", "color-success-contrast-background"], + ["Dark", "color-success-dark"], + ["Darker", "color-success-darker"], + ["Darkest", "color-success-darkest"], + ["Light", "color-success-light"], + ["Lighter", "color-success-lighter"], + ["Lightest", "color-success-lightest"], +]; +export const DispositionWarningColorEntries: readonly ColorEntry[] = [ + ["Contrast Background", "color-warning-contrast-background"], + ["Dark", "color-warning-dark"], + ["Darker", "color-warning-darker"], + ["Darkest", "color-warning-darkest"], + ["Light", "color-warning-light"], + ["Lighter", "color-warning-lighter"], + ["Lightest", "color-warning-lightest"], +]; + +export const DispositionDangerColorEntries: readonly ColorEntry[] = [ + ["Contrast Background", "color-danger-contrast-background"], + ["Dark", "color-danger-dark"], + ["Darker", "color-danger-darker"], + ["Darkest", "color-danger-darkest"], + ["Light", "color-danger-light"], + ["Lighter", "color-danger-lighter"], + ["Lightest", "color-danger-lightest"], +]; diff --git a/website/docusaurus-theme/components/infima/shared.ts b/website/docusaurus-theme/components/infima/shared.ts new file mode 100644 index 0000000000..7b6667723f --- /dev/null +++ b/website/docusaurus-theme/components/infima/shared.ts @@ -0,0 +1,82 @@ +import { ColorMode } from "@docusaurus/theme-common"; + +export type Shade = [label: string, suffix: string]; + +export interface ColorGroupProp { + label: string; + cssVar: string; + shades: Shade[]; +} + +export type ColorEntry = [label: string, cssVar: string]; + +export const Prefix = { + Infima: "--ifm-", + Authentik: "--ak-", +} as const satisfies Record; + +export interface ComputedColor { + cssVar: string; + label: string; + hex: string | null; + contrastColor: string; +} + +export function getContrastColor(hexColor: string | null): string { + if (!hexColor || hexColor === "transparent" || hexColor.startsWith("rgba")) { + return "#000000"; + } + const hex = hexColor.replace("#", ""); + if (hex.length !== 6) return "#000000"; + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#1a1a1a" : "#ffffff"; +} + +export function rgbToHex(rgb: string): string | null { + if (!rgb || rgb === "transparent") return null; + if (rgb.startsWith("#")) return rgb; + const match = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!match) return null; + + const [, r = "", g = "", b = ""] = match; + + const hex = (x: string): string => parseInt(x, 10).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; +} + +export function computeColor(cssVar: string): Pick { + if (typeof document === "undefined") { + return { hex: null, contrastColor: "#000000" }; + } + const computedColor = getComputedStyle(document.documentElement) + .getPropertyValue(cssVar) + .trim(); + + const hex = rgbToHex(computedColor) || computedColor || null; + const contrastColor = getContrastColor(hex); + + return { hex, contrastColor }; +} + +export interface ComputedColorGroup { + label: string; + colors: ComputedColor[]; +} + +export function createComputedColorGroup( + group: ColorGroupProp, + _colorMode: ColorMode, +): ComputedColorGroup { + return { + label: group.label, + colors: group.shades.map(([label, suffix]) => { + const cssVar = `${Prefix.Infima}${group.cssVar}${suffix}`; + const { hex, contrastColor } = computeColor(cssVar); + + return { cssVar, label, hex, contrastColor }; + }), + }; +}