-
!
+
+ !
Under Development
diff --git a/ecosystem-explorer/src/features/not-found/not-found-page.tsx b/ecosystem-explorer/src/features/not-found/not-found-page.tsx
index 451ba067..efc699ae 100644
--- a/ecosystem-explorer/src/features/not-found/not-found-page.tsx
+++ b/ecosystem-explorer/src/features/not-found/not-found-page.tsx
@@ -31,7 +31,7 @@ export function NotFoundPage() {
Return to Home
diff --git a/ecosystem-explorer/src/index.css b/ecosystem-explorer/src/index.css
deleted file mode 100644
index 13c74895..00000000
--- a/ecosystem-explorer/src/index.css
+++ /dev/null
@@ -1,103 +0,0 @@
-@import "tailwindcss";
-
-/**
- * Theme System
- *
- * 1. HSL triplet values stored with -hsl suffix (e.g., --primary-hsl)
- * 2. ThemeProvider dynamically overwrites these from themes.ts
- * 3. @theme block wraps triplets in hsl() to create Tailwind utility classes
- *
- * Source of truth: src/themes.ts
- */
-
-@layer theme {
- :root {
- --primary-hsl: 185 85% 70%;
- --secondary-hsl: 185 85% 50%;
- --background-hsl: 0 0% 4%;
- --foreground-hsl: 0 0% 98%;
- --card-hsl: 0 0% 8%;
- --card-secondary-hsl: 0 0% 12%;
- --muted-hsl: 0 0% 6%;
- --muted-foreground-hsl: 0 0% 55%;
- --border-hsl: 0 0% 20%;
- --syntax-comment-hsl: 220 18% 58%;
- --syntax-key-hsl: 28 95% 65%;
- --syntax-string-hsl: 95 60% 65%;
- --syntax-number-hsl: 330 80% 72%;
- --syntax-keyword-hsl: 265 70% 78%;
- --syntax-punct-hsl: 220 22% 55%;
- }
-}
-
-@theme {
- --color-primary: hsl(var(--primary-hsl));
- --color-secondary: hsl(var(--secondary-hsl));
- --color-background: hsl(var(--background-hsl));
- --color-foreground: hsl(var(--foreground-hsl));
- --color-card: hsl(var(--card-hsl));
- --color-card-secondary: hsl(var(--card-secondary-hsl));
- --color-muted: hsl(var(--muted-hsl));
- --color-muted-foreground: hsl(var(--muted-foreground-hsl));
- --color-border: hsl(var(--border-hsl));
-}
-
-* {
- border-color: hsl(var(--border-hsl));
-}
-
-body {
- margin: 0;
- padding: 0;
- font-family:
- system-ui,
- -apple-system,
- sans-serif;
- background-color: hsl(var(--background-hsl));
- color: hsl(var(--foreground-hsl));
-}
-
-#root {
- min-height: 100vh;
-}
-
-/* Tab trigger border color override */
-[role="tab"] {
- border-top-color: transparent !important;
-}
-
-[role="tab"][data-state="active"] {
- border-top-color: hsl(var(--primary-hsl)) !important;
-}
-
-html {
- /* Root font size affects overall rem calculations for all components. */
- font-size: 14px;
- /* Reserve space for the fixed header so anchor / scrollIntoView lands below it. */
- scroll-padding-top: 5rem;
-}
-
-/* YAML syntax highlighting tokens */
-.y-comment {
- color: hsl(var(--syntax-comment-hsl));
- font-style: italic;
-}
-.y-key {
- color: hsl(var(--syntax-key-hsl));
-}
-.y-string {
- color: hsl(var(--syntax-string-hsl));
-}
-.y-number {
- color: hsl(var(--syntax-number-hsl));
-}
-.y-keyword {
- color: hsl(var(--syntax-keyword-hsl));
- font-style: italic;
-}
-.y-punct {
- color: hsl(var(--syntax-punct-hsl));
-}
-.y-plain {
- color: hsl(var(--foreground-hsl));
-}
diff --git a/ecosystem-explorer/src/main.tsx b/ecosystem-explorer/src/main.tsx
index 882f52fb..755fe45d 100644
--- a/ecosystem-explorer/src/main.tsx
+++ b/ecosystem-explorer/src/main.tsx
@@ -16,7 +16,7 @@
import "./faro";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import "./index.css";
+import "./styles/index.css";
import App from "./App.tsx";
import { ThemeProvider } from "./theme-context";
diff --git a/ecosystem-explorer/src/styles/base.css b/ecosystem-explorer/src/styles/base.css
new file mode 100644
index 00000000..4bad8e8c
--- /dev/null
+++ b/ecosystem-explorer/src/styles/base.css
@@ -0,0 +1,36 @@
+/* Global element resets and defaults. */
+
+* {
+ border-color: hsl(var(--border-hsl));
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background-color: hsl(var(--background-hsl));
+ color: hsl(var(--foreground-hsl));
+}
+
+#root {
+ min-height: 100vh;
+}
+
+html {
+ /* Root font size affects overall rem calculations for all components. */
+ font-size: 14px;
+ /* Reserve space for the fixed header so anchor / scrollIntoView lands below it. */
+ scroll-padding-top: 5rem;
+}
+
+/* Tab trigger border color override */
+[role="tab"] {
+ border-top-color: transparent !important;
+}
+
+[role="tab"][data-state="active"] {
+ border-top-color: hsl(var(--primary-hsl)) !important;
+}
diff --git a/ecosystem-explorer/src/styles/index.css b/ecosystem-explorer/src/styles/index.css
new file mode 100644
index 00000000..c36584a1
--- /dev/null
+++ b/ecosystem-explorer/src/styles/index.css
@@ -0,0 +1,49 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Styles entry point.
+ *
+ * - tokens.css — design-token CSS custom properties (per-theme HSL triplets)
+ * - base.css — global element resets
+ * - syntax.css — YAML / config syntax-highlight token classes
+ *
+ * Tailwind v4 bundles @import rules via Lightning CSS at build time, so each
+ * partial is inlined into the final stylesheet — no extra network requests.
+ */
+@import "tailwindcss";
+
+@import "./tokens.css";
+@import "./base.css";
+@import "./syntax.css";
+
+/**
+ * Tailwind theme — maps --*-hsl triplets to hsl() utility classes.
+ * Must live in the same file that imports "tailwindcss".
+ */
+@theme {
+ --color-otel-blue: hsl(var(--otel-blue-hsl));
+ --color-otel-orange: hsl(var(--otel-orange-hsl));
+ --color-primary: hsl(var(--primary-hsl));
+ --color-secondary: hsl(var(--secondary-hsl));
+ --color-background: hsl(var(--background-hsl));
+ --color-foreground: hsl(var(--foreground-hsl));
+ --color-card: hsl(var(--card-hsl));
+ --color-card-secondary: hsl(var(--card-secondary-hsl));
+ --color-muted: hsl(var(--muted-hsl));
+ --color-muted-foreground: hsl(var(--muted-foreground-hsl));
+ --color-border: hsl(var(--border-hsl));
+}
diff --git a/ecosystem-explorer/src/styles/syntax.css b/ecosystem-explorer/src/styles/syntax.css
new file mode 100644
index 00000000..a8083932
--- /dev/null
+++ b/ecosystem-explorer/src/styles/syntax.css
@@ -0,0 +1,31 @@
+/* YAML / config syntax highlighting token classes. */
+
+.y-comment {
+ color: hsl(var(--syntax-comment-hsl));
+ font-style: italic;
+}
+
+.y-key {
+ color: hsl(var(--syntax-key-hsl));
+}
+
+.y-string {
+ color: hsl(var(--syntax-string-hsl));
+}
+
+.y-number {
+ color: hsl(var(--syntax-number-hsl));
+}
+
+.y-keyword {
+ color: hsl(var(--syntax-keyword-hsl));
+ font-style: italic;
+}
+
+.y-punct {
+ color: hsl(var(--syntax-punct-hsl));
+}
+
+.y-plain {
+ color: hsl(var(--foreground-hsl));
+}
diff --git a/ecosystem-explorer/src/styles/tokens.css b/ecosystem-explorer/src/styles/tokens.css
new file mode 100644
index 00000000..57ff988f
--- /dev/null
+++ b/ecosystem-explorer/src/styles/tokens.css
@@ -0,0 +1,88 @@
+/*
+ * Design token — CSS custom properties.
+ *
+ * Brand hues are canonical from opentelemetry.io's _vars.scss:
+ * $primary = blue hsl(228, 37%, 49%)
+ * $secondary = orange hsl(41, 100%, 48%)
+ *
+ * Two-tier brand system:
+ * --otel-blue-hsl / --otel-orange-hsl — named primitives, never swap these
+ * --primary-hsl / --secondary-hsl — semantic role tokens; re-point only
+ * here to change which hue plays a structural vs accent role
+ *
+ * SVG illustrations and gradient ornaments reference --otel-*-hsl directly so
+ * they express literal hue intent and stay correct across any role reassignment.
+ * Interactive UI chrome (focus rings, hover borders, CTA buttons) uses
+ * --primary-hsl / --secondary-hsl to track whichever hue plays that role.
+ *
+ * Surface tokens differ per theme; syntax tokens reuse the dark palette in
+ * light mode for now (revisit when a light-mode syntax consumer lands).
+ *
+ * Note: dark-theme surface tokens are applied globally (no V1_REDESIGN gate).
+ * V1_REDESIGN gates UI components and layout; the base palette ships on merge.
+ */
+
+@layer theme {
+ :root,
+ [data-theme="dark"] {
+ /* Brand primitives — stable, never reassigned */
+ --otel-blue-hsl: 228 37% 49%;
+ --otel-orange-hsl: 41 100% 48%;
+
+ /* Semantic role tokens */
+ --primary-hsl: var(--otel-blue-hsl); /* structural / navbar / buttons */
+ --secondary-hsl: var(--otel-orange-hsl); /* accents / hover / CTAs */
+
+ /* Ornament accents — flip per theme (compass, hero glow, ambient radial) */
+ --hero-accent-hsl: var(--otel-orange-hsl);
+ --hero-accent-alt-hsl: var(--otel-blue-hsl);
+
+ /* Surfaces — navy palette */
+ --background-hsl: 232 38% 15%;
+ --foreground-hsl: 210 17% 98%;
+ --card-hsl: 232 35% 19%;
+ --card-secondary-hsl: 232 32% 23%;
+ --muted-hsl: 232 28% 22%;
+ --muted-foreground-hsl: 220 14% 65%;
+ --border-hsl: 232 22% 28%;
+
+ /* Syntax */
+ --syntax-comment-hsl: 220 18% 58%;
+ --syntax-key-hsl: 28 95% 65%;
+ --syntax-string-hsl: 95 60% 65%;
+ --syntax-number-hsl: 330 80% 72%;
+ --syntax-keyword-hsl: 265 70% 78%;
+ --syntax-punct-hsl: 220 22% 55%;
+ }
+
+ [data-theme="light"] {
+ /* Brand primitives — identical across themes */
+ --otel-blue-hsl: 228 37% 49%;
+ --otel-orange-hsl: 41 100% 48%;
+
+ /* Semantic role tokens */
+ --primary-hsl: var(--otel-blue-hsl);
+ --secondary-hsl: var(--otel-orange-hsl);
+
+ /* Ornament accents — flip per theme (compass, hero glow, ambient radial) */
+ --hero-accent-hsl: var(--otel-blue-hsl);
+ --hero-accent-alt-hsl: var(--otel-orange-hsl);
+
+ /* Surfaces — light palette */
+ --background-hsl: 0 0% 100%;
+ --foreground-hsl: 220 13% 15%;
+ --card-hsl: 210 17% 98%;
+ --card-secondary-hsl: 210 14% 95%;
+ --muted-hsl: 210 14% 92%;
+ --muted-foreground-hsl: 220 9% 40%;
+ --border-hsl: 210 14% 88%;
+
+ /* Syntax — reuse dark values until a light-mode consumer needs distinct tokens */
+ --syntax-comment-hsl: 220 18% 58%;
+ --syntax-key-hsl: 28 95% 65%;
+ --syntax-string-hsl: 95 60% 65%;
+ --syntax-number-hsl: 330 80% 72%;
+ --syntax-keyword-hsl: 265 70% 78%;
+ --syntax-punct-hsl: 220 22% 55%;
+ }
+}
diff --git a/ecosystem-explorer/src/theme-context.test.tsx b/ecosystem-explorer/src/theme-context.test.tsx
index e7eb4592..877d3d89 100644
--- a/ecosystem-explorer/src/theme-context.test.tsx
+++ b/ecosystem-explorer/src/theme-context.test.tsx
@@ -13,66 +13,114 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { render, renderHook } from "@testing-library/react";
-import { describe, it, expect, beforeEach } from "vitest";
+import { render, renderHook, act } from "@testing-library/react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
import { ThemeProvider, useTheme } from "./theme-context";
-import { themes, DEFAULT_THEME } from "./themes";
+import { DEFAULT_THEME } from "./themes";
+
+function mockMatchMedia(prefersDark: boolean) {
+ const listeners: ((e: { matches: boolean }) => void)[] = [];
+ const mql = {
+ matches: prefersDark,
+ addEventListener: (_: string, fn: (e: { matches: boolean }) => void) => listeners.push(fn),
+ removeEventListener: (_: string, fn: (e: { matches: boolean }) => void) => {
+ const idx = listeners.indexOf(fn);
+ if (idx !== -1) listeners.splice(idx, 1);
+ },
+ fire: (matches: boolean) => {
+ mql.matches = matches;
+ listeners.forEach((fn) => fn({ matches }));
+ },
+ };
+ vi.stubGlobal("matchMedia", () => mql);
+ return mql;
+}
describe("ThemeProvider", () => {
beforeEach(() => {
- // Clear any theme attributes from previous tests
document.documentElement.removeAttribute("data-theme");
+ localStorage.clear();
+ vi.unstubAllGlobals();
});
- it("applies default theme CSS variables to document root", () => {
+ it("applies default resolved theme (dark) to data-theme when no stored value", () => {
+ mockMatchMedia(true);
render(
- Content
+
);
+ expect(document.documentElement.dataset.theme).toBe(DEFAULT_THEME);
+ });
- const theme = themes[DEFAULT_THEME];
- const root = document.documentElement;
-
- expect(root.style.getPropertyValue("--primary-hsl")).toBe(theme.colors.primary);
- expect(root.style.getPropertyValue("--secondary-hsl")).toBe(theme.colors.secondary);
- expect(root.style.getPropertyValue("--background-hsl")).toBe(theme.colors.background);
- expect(root.style.getPropertyValue("--foreground-hsl")).toBe(theme.colors.foreground);
- expect(root.style.getPropertyValue("--card-hsl")).toBe(theme.colors.card);
- expect(root.style.getPropertyValue("--card-secondary-hsl")).toBe(theme.colors.cardSecondary);
- expect(root.style.getPropertyValue("--border-hsl")).toBe(theme.colors.border);
- expect(root.style.getPropertyValue("--muted-hsl")).toBe(theme.colors.muted);
- expect(root.style.getPropertyValue("--muted-foreground-hsl")).toBe(
- theme.colors.mutedForeground
+ it("reads stored light preference on mount", () => {
+ mockMatchMedia(false);
+ localStorage.setItem("td-color-theme", "light");
+ render(
+
+
+
);
- expect(root.style.getPropertyValue("--syntax-key-hsl")).toBe(theme.colors.syntax.key);
- expect(root.style.getPropertyValue("--syntax-string-hsl")).toBe(theme.colors.syntax.string);
+ expect(document.documentElement.dataset.theme).toBe("light");
});
- it("sets data-theme attribute on document root", () => {
+ it("reads stored dark preference on mount", () => {
+ mockMatchMedia(false);
+ localStorage.setItem("td-color-theme", "dark");
render(
- Content
+
);
+ expect(document.documentElement.dataset.theme).toBe("dark");
+ });
+
+ it("persists mode to localStorage when setMode is called", () => {
+ mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
+ act(() => result.current.setMode("light"));
+ expect(localStorage.getItem("td-color-theme")).toBe("light");
+ });
+
+ it("auto mode with prefers-dark resolves to dark", () => {
+ mockMatchMedia(true);
+ const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
+ act(() => result.current.setMode("auto"));
+ expect(document.documentElement.dataset.theme).toBe("dark");
+ });
+
+ it("auto mode with prefers-light resolves to light", () => {
+ mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
+ act(() => result.current.setMode("auto"));
+ expect(document.documentElement.dataset.theme).toBe("light");
+ });
+
+ it("auto mode responds to matchMedia change without updating localStorage", () => {
+ const mql = mockMatchMedia(false);
+ const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
+ act(() => result.current.setMode("auto"));
+ expect(document.documentElement.dataset.theme).toBe("light");
- expect(document.documentElement.getAttribute("data-theme")).toBe(DEFAULT_THEME);
+ act(() => mql.fire(true));
+ expect(document.documentElement.dataset.theme).toBe("dark");
+ expect(result.current.resolved).toBe("dark");
+ expect(localStorage.getItem("td-color-theme")).toBe("auto");
});
});
describe("useTheme", () => {
- it("throws error when used outside ThemeProvider", () => {
- expect(() => {
- renderHook(() => useTheme());
- }).toThrow("useTheme must be used within a ThemeProvider");
+ it("throws when used outside ThemeProvider", () => {
+ expect(() => renderHook(() => useTheme())).toThrow(
+ "useTheme must be used within a ThemeProvider"
+ );
});
- it("returns themeId and setThemeId", () => {
- const { result } = renderHook(() => useTheme(), {
- wrapper: ThemeProvider,
- });
-
- expect(result.current.themeId).toBe(DEFAULT_THEME);
- expect(typeof result.current.setThemeId).toBe("function");
+ it("returns mode, resolved, and setMode", () => {
+ mockMatchMedia(true);
+ const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
+ expect(result.current.mode).toBe("auto");
+ expect(result.current.resolved).toBe("dark");
+ expect(typeof result.current.setMode).toBe("function");
});
});
diff --git a/ecosystem-explorer/src/theme-context.tsx b/ecosystem-explorer/src/theme-context.tsx
index f918b625..a0c3dfdd 100644
--- a/ecosystem-explorer/src/theme-context.tsx
+++ b/ecosystem-explorer/src/theme-context.tsx
@@ -13,47 +13,84 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
-import { type ThemeId, themes, DEFAULT_THEME } from "./themes";
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from "react";
+import { type ResolvedThemeId } from "./themes";
-interface ThemeContextType {
- themeId: ThemeId;
- setThemeId: (themeId: ThemeId) => void;
+export type ThemeMode = "light" | "dark" | "auto";
+
+interface ThemeContextValue {
+ /** The user's stored preference. */
+ mode: ThemeMode;
+ /** The resolved theme actually applied to the document. */
+ resolved: ResolvedThemeId;
+ setMode: (mode: ThemeMode) => void;
+}
+
+const STORAGE_KEY = "td-color-theme";
+const VALID_MODES: ThemeMode[] = ["light", "dark", "auto"];
+
+function isValidMode(value: string | null): value is ThemeMode {
+ return VALID_MODES.includes(value as ThemeMode);
+}
+
+function subscribeSystemTheme(callback: () => void): () => void {
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
+ mql.addEventListener("change", callback);
+ return () => mql.removeEventListener("change", callback);
+}
+
+function getSystemThemeSnapshot(): ResolvedThemeId {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
-const ThemeContext = createContext(undefined);
+function getServerSystemTheme(): ResolvedThemeId {
+ return "dark";
+}
+
+const ThemeContext = createContext(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
- const [themeId, setThemeId] = useState(DEFAULT_THEME);
+ const [mode, setModeState] = useState(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return isValidMode(stored) ? stored : "auto";
+ } catch {
+ return "auto";
+ }
+ });
+
+ const systemTheme = useSyncExternalStore(
+ subscribeSystemTheme,
+ getSystemThemeSnapshot,
+ getServerSystemTheme
+ );
+
+ const resolved: ResolvedThemeId = mode === "auto" ? systemTheme : mode;
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = resolved;
+ }, [resolved]);
useEffect(() => {
- const theme = themes[themeId];
- const root = document.documentElement;
-
- const flat: Record = {
- primary: theme.colors.primary,
- secondary: theme.colors.secondary,
- background: theme.colors.background,
- foreground: theme.colors.foreground,
- card: theme.colors.card,
- "card-secondary": theme.colors.cardSecondary,
- muted: theme.colors.muted,
- "muted-foreground": theme.colors.mutedForeground,
- border: theme.colors.border,
- "syntax-comment": theme.colors.syntax.comment,
- "syntax-key": theme.colors.syntax.key,
- "syntax-string": theme.colors.syntax.string,
- "syntax-number": theme.colors.syntax.number,
- "syntax-keyword": theme.colors.syntax.keyword,
- "syntax-punct": theme.colors.syntax.punct,
- };
- for (const [name, value] of Object.entries(flat)) {
- root.style.setProperty(`--${name}-hsl`, value);
+ try {
+ localStorage.setItem(STORAGE_KEY, mode);
+ } catch {
+ // localStorage unavailable (private mode quota, etc.)
}
- root.setAttribute("data-theme", themeId);
- }, [themeId]);
+ }, [mode]);
+
+ const setMode = (next: ThemeMode) => setModeState(next);
- return {children};
+ return (
+ {children}
+ );
}
// eslint-disable-next-line react-refresh/only-export-components
diff --git a/ecosystem-explorer/src/themes.test.ts b/ecosystem-explorer/src/themes.test.ts
new file mode 100644
index 00000000..b8754d6f
--- /dev/null
+++ b/ecosystem-explorer/src/themes.test.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, it, expect } from "vitest";
+import { themes, DEFAULT_THEME } from "./themes";
+
+const COLOR_KEYS = [
+ "primary",
+ "secondary",
+ "background",
+ "foreground",
+ "card",
+ "cardSecondary",
+ "muted",
+ "mutedForeground",
+ "border",
+] as const;
+
+describe("themes", () => {
+ it("exports both light and dark theme records", () => {
+ expect(themes.light).toBeDefined();
+ expect(themes.dark).toBeDefined();
+ });
+
+ it.each(["light", "dark"] as const)("%s theme has all required color keys non-empty", (id) => {
+ const theme = themes[id];
+ for (const key of COLOR_KEYS) {
+ expect(theme.colors[key], `${id}.colors.${key}`).toBeTruthy();
+ }
+ expect(theme.colors.syntax.comment).toBeTruthy();
+ expect(theme.colors.syntax.key).toBeTruthy();
+ });
+
+ it("DEFAULT_THEME is dark", () => {
+ expect(DEFAULT_THEME).toBe("dark");
+ });
+});
diff --git a/ecosystem-explorer/src/themes.ts b/ecosystem-explorer/src/themes.ts
index 7fe48d5d..ccda76cb 100644
--- a/ecosystem-explorer/src/themes.ts
+++ b/ecosystem-explorer/src/themes.ts
@@ -14,10 +14,22 @@
* limitations under the License.
*/
-export type ThemeId = "dark-blue";
+/**
+ * Theme metadata — typed canonical reference for the HSL triplets in tokens.css.
+ *
+ * Naming mirrors opentelemetry.io's _vars.scss:
+ * $primary = blue (228 37% 49%)
+ * $secondary = orange (41 100% 48%)
+ *
+ * JS does NOT inject these values at runtime; CSS owns them via [data-theme="..."] selectors
+ * in src/styles/tokens.css. This file exists for type safety and as a single source of truth
+ * that documents which values the CSS emits.
+ */
+
+export type ResolvedThemeId = "light" | "dark";
export interface Theme {
- id: ThemeId;
+ id: ResolvedThemeId;
name: string;
description: string;
colors: {
@@ -41,31 +53,56 @@ export interface Theme {
};
}
-export const themes: Record = {
- "dark-blue": {
- id: "dark-blue",
- name: "OTel Vibrant",
- description: "Dark blue theme",
- colors: {
- primary: "38 95% 52%", // Vibrant orange
- secondary: "228 60% 55%", // Brighter blue
- background: "232 38% 15%", // Deep navy
- foreground: "210 45% 99%", // Bright white with blue hint
- card: "232 35% 19%", // Card background
- cardSecondary: "232 32% 23%", // Hover state
- muted: "232 30% 17%", // Darker background for code/badges
- mutedForeground: "220 22% 65%", // Muted text
- border: "232 28% 26%", // Borders
- syntax: {
- comment: "220 18% 58%", // Italic gray-blue
- key: "28 95% 65%", // Peach orange
- string: "95 60% 65%", // Soft green
- number: "330 80% 72%", // Pink
- keyword: "265 70% 78%", // Lavender (true/false/null)
- punct: "220 22% 55%", // Muted (`-`, `:`, `,`, etc.)
- },
+const dark: Theme = {
+ id: "dark",
+ name: "OTel Vibrant",
+ description: "Default OpenTelemetry-aligned dark theme.",
+ colors: {
+ primary: "228 37% 49%",
+ secondary: "41 100% 48%",
+ background: "232 38% 15%",
+ foreground: "210 17% 98%",
+ card: "232 35% 19%",
+ cardSecondary: "232 32% 23%",
+ muted: "232 28% 22%",
+ mutedForeground: "220 14% 65%",
+ border: "232 22% 28%",
+ syntax: {
+ comment: "220 18% 58%",
+ key: "28 95% 65%",
+ string: "95 60% 65%",
+ number: "330 80% 72%",
+ keyword: "265 70% 78%",
+ punct: "220 22% 55%",
},
},
};
-export const DEFAULT_THEME: ThemeId = "dark-blue";
+const light: Theme = {
+ id: "light",
+ name: "OTel Light",
+ description: "Light theme aligned with opentelemetry.io.",
+ colors: {
+ primary: "228 37% 49%",
+ secondary: "41 100% 48%",
+ background: "0 0% 100%",
+ foreground: "220 13% 15%",
+ card: "210 17% 98%",
+ cardSecondary: "210 14% 95%",
+ muted: "210 14% 92%",
+ mutedForeground: "220 9% 40%",
+ border: "210 14% 88%",
+ syntax: {
+ comment: "220 18% 58%",
+ key: "28 95% 65%",
+ string: "95 60% 65%",
+ number: "330 80% 72%",
+ keyword: "265 70% 78%",
+ punct: "220 22% 55%",
+ },
+ },
+};
+
+export const themes: Record = { light, dark };
+
+export const DEFAULT_THEME: ResolvedThemeId = "dark";
diff --git a/projects/84-ui-ux-design/00-foundation-audit.md b/projects/84-ui-ux-design/00-foundation-audit.md
index 448ed039..226b9a53 100644
--- a/projects/84-ui-ux-design/00-foundation-audit.md
+++ b/projects/84-ui-ux-design/00-foundation-audit.md
@@ -447,11 +447,10 @@ These were carried over from `00-foundation.md`.
migrate in a follow-up cleanup PR after Phase 1.
- [x] Pin the stability-terminology mapping (Q5) — locked: six-level OTel collector spec
(development / alpha / beta / stable / deprecated / unmaintained).
-- [x] Add `V1_REDESIGN` to `FEATURE_FLAGS` (one-line PR — can ship first as a no-op). The
- `netlify.toml` build command already pattern-matches `feat/84-*` to enable the flag, so
- previews on those branches will pick it up immediately once the flag exists in
- `lib/feature-flags.ts`.
-- [ ] Reconcile color drift: rewrite `index.css` defaults to match the actual default theme.
+- [x] Add `V1_REDESIGN` to `FEATURE_FLAGS` — already done on `main` (added alongside
+ `JAVA_RELEASE_COMPARISON` in a prior commit). `netlify.toml` was also already wired. Flag is a
+ no-op until PR 2 wires it into `App.tsx`.
+- [ ] Reconcile color drift: rewrite `index.css` defaults to match the actual default theme. (PR 1)
Once these are done, the first PR is **"Add light + dark + auto theme system + theme toggle in
navbar"** — small, visible, low-risk, gated by `V1_REDESIGN` so it can land without disrupting
diff --git a/projects/84-ui-ux-design/NEXT-STEPS.md b/projects/84-ui-ux-design/NEXT-STEPS.md
index 824e26fe..2c5fc288 100644
--- a/projects/84-ui-ux-design/NEXT-STEPS.md
+++ b/projects/84-ui-ux-design/NEXT-STEPS.md
@@ -4,7 +4,7 @@ issue: 84
type: roadmap
phase: meta
status: planning
-last_updated: "2026-05-06"
+last_updated: "2026-05-07"
---
## Next steps
@@ -35,11 +35,22 @@ This is a _living_ document. Update it as decisions land and PRs ship. Cross-ref
- The audit confirmed the codebase is in better shape than the placeholder screenshots suggested:
feature flag system exists, theme provider exists, layout components exist, several UI primitives
we'd planned to build already exist.
-- A draft reply is ready to post on issue #84 in response to Jay's "stages vs. big-bang PR"
- question.
- DESIGN.md was reverted to its original form by the maintainer; the alignment-focused version lives
in `ecosystem-explorer-v1-design-brief.md` for now.
-- Nothing is committed yet — Vitor will commit and push.
+- **PR 0 is effectively done.** `V1_REDESIGN` is already in `src/lib/feature-flags.ts` (added
+ alongside `JAVA_RELEASE_COMPARISON` in a prior main commit). `netlify.toml` already
+ pattern-matches `feat/84-*` branches to enable the flag for previews. The flag is a no-op until PR
+ 2 wires it into `App.tsx`.
+- **Brand realignment (`primary = blue`, `secondary = orange`) is already on `main`.** The canonical
+ hues (`228 37% 49%` blue, `41 100% 48%` orange) are already in `src/index.css` from a prior merge.
+ PR 1 does not need to flip these — only the five remaining surface tokens (card/muted/border) need
+ reconciling.
+- **PR 1 code is complete on `feat/84-pr1-theme-system`.** Implemented: `src/styles/` folder with
+ modular CSS partials (`tokens.css`, `base.css`, `syntax.css`), light theme block added, remaining
+ dark surfaces reconciled to navy, `theme-context.tsx` rewritten (`mode`/`resolved`/`setMode` API,
+ `localStorage` persistence, `matchMedia` auto mode), no-flash init script in `index.html`,
+ `` component authored (not yet rendered — PR 2 slots it into NavBar). All 781 tests
+ pass; `tsc -b` clean. Pending: Vitor's commits + opening the PR.
---
@@ -47,7 +58,14 @@ This is a _living_ document. Update it as decisions land and PRs ship. Cross-ref
In order:
-- [ ]
+- [ ] Commit and open PR from `feat/84-pr1-theme-system` — code is complete, tests green, typecheck
+ clean. See commit messages in the session log. No visible UI changes; verification via
+ DevTools.
+- [ ] Merge `feat/84-layout-mockups` (planning artifacts) as its own PR so the docs are on `main`.
+ Can happen in parallel with PR 1 review.
+- [ ] Start PR 2 (`feat/84-pr2-navbar`) off `feat/84-layout-mockups` (or `main` once it merges):
+ `NavBar` component, always-dark, renders `` from PR 1, wires `V1_REDESIGN` into
+ `App.tsx` for the first time.
---
@@ -56,17 +74,17 @@ In order:
The audit recommends 9 PRs for Phase 1. Each is small, gated by `V1_REDESIGN` so it lands without
disrupting `main`, and reviewable by one person.
-| # | PR | Scope | Blocks |
-| --- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
-| 0 | Add `V1_REDESIGN` flag | One line in `src/lib/feature-flags.ts`. Ships as a no-op. | All other foundation PRs |
-| 1 | Theme system | Extend `themes.ts` to light + dark + auto. No-flash init script in `index.html`. Reconcile `index.css` color drift. Persist to `localStorage["td-color-theme"]`. | PR 2 (toggle lives in NavBar) |
-| 2 | NavBar v1 | Replace `Header` behind the flag. opentelemetry.io-style: logo + Docs · Ecosystem · Status · Community · Training · Blog · Explorer · search · language · theme toggle. | PR 3 (SubNav sits beneath it) |
-| 3 | SubNav | Breadcrumb component + optional right-side actions slot. Used by inner pages. | Phases 2-5 |
-| 4 | StatusPill + GlowBadge `secondary` + `error` variants | Add `` covering all six OTel stability levels (development / alpha / beta / stable / deprecated / unmaintained). Leave legacy `StabilityBadge` untouched; migrate in a follow-up cleanup PR. | Phases 3, 4 |
-| 5 | TypeStripe + Card primitive update | 4px left-edge stripe primitive (5 colors) + extend existing `Card` / `NavigationCard` with the stripe slot. | Phases 3, 4 |
-| 6 | FooterV1 + CncfCallout | Two-cluster Docsy-style footer + CNCF callout above it. Inline SVGs for Bluesky / Mastodon / Stack Overflow icons (assuming we go with the recommended icon strategy). | — |
-| 7 | Playwright visual regression baseline | Configure Playwright. Snapshot each primitive in light + dark. Add `axe-core` for a11y. | Phase 1 cleanup |
-| 8 | Cleanup | Remove `V1_REDESIGN` flag. Delete legacy `Header` / `Footer`. Update `DESIGN.md` to reflect as-built. | Phase 2 |
+| # | PR | Scope | Blocks | Status |
+| --- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | --------------------------- |
+| 0 | Add `V1_REDESIGN` flag | One line in `src/lib/feature-flags.ts`. Ships as a no-op. | All other foundation PRs | ✅ Done (on `main`) |
+| 1 | Theme system | `src/styles/` folder (modular CSS partials). Light + dark + auto switching. No-flash init script in `index.html`. Reconcile dark surface tokens. Persist to `localStorage["td-color-theme"]`. `` authored (not rendered). **Note:** dark-surface reconciliation is global (no `V1_REDESIGN` gate); light theme, persistence, and toggle are additive with no consumer until PR 2. | PR 2 (toggle lives in NavBar) | 🚧 Implemented — pending PR |
+| 2 | NavBar v1 | Replace `Header` behind the flag. opentelemetry.io-style: logo + Docs · Ecosystem · Status · Community · Training · Blog · Explorer · search · language · theme toggle. | PR 3 (SubNav sits beneath it) | — |
+| 3 | SubNav | Breadcrumb component + optional right-side actions slot. Used by inner pages. | Phases 2-5 | — |
+| 4 | StatusPill + GlowBadge `secondary` + `error` variants | Add `` covering all six OTel stability levels (development / alpha / beta / stable / deprecated / unmaintained). Leave legacy `StabilityBadge` untouched; migrate in a follow-up cleanup PR. | Phases 3, 4 | — |
+| 5 | TypeStripe + Card primitive update | 4px left-edge stripe primitive (5 colors) + extend `DetailCard` with the stripe slot (not `NavigationCard` — too specialized). | Phases 3, 4 | — |
+| 6 | FooterV1 + CncfCallout | Two-cluster Docsy-style footer + CNCF callout above it. Inline SVGs for Bluesky / Mastodon / Stack Overflow icons (assuming we go with the recommended icon strategy). | — | — |
+| 7 | Playwright visual regression baseline | Configure Playwright. Snapshot each primitive in light + dark. Add `axe-core` for a11y. | Phase 1 cleanup | — |
+| 8 | Cleanup | Remove `V1_REDESIGN` flag. Delete legacy `Header` / `Footer`. Update `DESIGN.md` to reflect as-built. | Phase 2 | — |
PR 8 is the **go-live** moment — once it merges, Phase 1 is done and the new chrome ships to
production.
@@ -178,19 +196,28 @@ Surface early so it's not blocking when PR 04b is ready.
## Decision log
-| Date | Decision | Notes |
-| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| 2026-04-30 | Direction: "The Catalog" with borrowed elements from Atlas + Dashboard | See design brief. v1 spine: searchable, comparable, version-aware database. |
-| 2026-04-30 | Align visual chrome with opentelemetry.io | Same nav, footer, hero, stats, theme system, brand colors. Sub-product, not a separate microsite. |
-| 2026-05-05 | Stage Phase 1 into 9 PRs gated by `V1_REDESIGN` flag | See "Phase 1 PR sequence" above. Avoids one big-bang PR. |
-| 2026-05-05 | Reuse existing feature-flag system at `src/lib/feature-flags.ts` | Already in production use; no new infrastructure. |
-| 2026-05-05 | Migration strategy: feature-flagged side-by-side, swap in cleanup PR | Per Vitor's choice in our sync. |
-| 2026-05-05 | Set up `CLAUDE.local.md` (gitignored) with handling rules for `projects/` during the refactor | Personal session context for Claude; not shared with the project. Ensures continuity across sessions. |
-| 2026-05-06 | Netlify previews enable `V1_REDESIGN` automatically for `feat/84-*` branches via build-command pattern matching in `netlify.toml` | Reviewers see the flag-on view per PR with no manual env-var setup. Production stays off. |
-| 2026-05-06 | Keep `data-theme` (not `data-bs-theme`) on `` for the theme attribute | Foundation audit Q1. opentelemetry.io uses `data-bs-theme` because Hugo Docsy is Bootstrap-based; the explorer is on Tailwind v4 with no Bootstrap. Visual alignment is driven by colors / layout / patterns, not the attribute name. Smaller PR diff and more honest naming. |
-| 2026-05-06 | Stick with the local `OtelLogo` component for the navbar lockup | Foundation audit Q2. Self-contained, no extra fetch dependency, already used elsewhere in the codebase. |
-| 2026-05-06 | Footer icons: inline SVGs for missing brand marks (Bluesky, Mastodon, Stack Overflow); Lucide for everything else | Foundation audit Q3 — option (c). Avoids adding ~75kb of Font Awesome for a handful of icons; keeps the bundle lean. |
-| 2026-05-06 | `` ships in PR 4 alongside ``; migrate the configuration builder to `` in a follow-up cleanup PR after Phase 1 | Foundation audit Q4. `` is narrow (one state, specific to the Java config builder). Building `` without churning the configuration builder keeps PR 4 small and decouples the visual-decision risk. |
-| 2026-05-06 | Status terminology follows the [OTel collector stability spec](https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/component-stability.md) — six levels: development / alpha / beta / stable / deprecated / unmaintained | Foundation audit Q5. Color mapping: development=secondary (gray), alpha=warning (orange), beta=info (blue), stable=success (green), deprecated=danger (red), unmaintained=danger (red). Mirrors the collector's vocabulary so anyone reading both sources sees the same terms. |
+| Date | Decision | Notes |
+| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 2026-04-30 | Direction: "The Catalog" with borrowed elements from Atlas + Dashboard | See design brief. v1 spine: searchable, comparable, version-aware database. |
+| 2026-04-30 | Align visual chrome with opentelemetry.io | Same nav, footer, hero, stats, theme system, brand colors. Sub-product, not a separate microsite. |
+| 2026-05-05 | Stage Phase 1 into 9 PRs gated by `V1_REDESIGN` flag | See "Phase 1 PR sequence" above. Avoids one big-bang PR. |
+| 2026-05-05 | Reuse existing feature-flag system at `src/lib/feature-flags.ts` | Already in production use; no new infrastructure. |
+| 2026-05-05 | Migration strategy: feature-flagged side-by-side, swap in cleanup PR | Per Vitor's choice in our sync. |
+| 2026-05-05 | Set up `CLAUDE.local.md` (gitignored) with handling rules for `projects/` during the refactor | Personal session context for Claude; not shared with the project. Ensures continuity across sessions. |
+| 2026-05-06 | Netlify previews enable `V1_REDESIGN` automatically for `feat/84-*` branches via build-command pattern matching in `netlify.toml` | Reviewers see the flag-on view per PR with no manual env-var setup. Production stays off. |
+| 2026-05-06 | Keep `data-theme` (not `data-bs-theme`) on `` for the theme attribute | Foundation audit Q1. opentelemetry.io uses `data-bs-theme` because Hugo Docsy is Bootstrap-based; the explorer is on Tailwind v4 with no Bootstrap. Visual alignment is driven by colors / layout / patterns, not the attribute name. Smaller PR diff and more honest naming. |
+| 2026-05-06 | Stick with the local `OtelLogo` component for the navbar lockup | Foundation audit Q2. Self-contained, no extra fetch dependency, already used elsewhere in the codebase. |
+| 2026-05-06 | Footer icons: inline SVGs for missing brand marks (Bluesky, Mastodon, Stack Overflow); Lucide for everything else | Foundation audit Q3 — option (c). Avoids adding ~75kb of Font Awesome for a handful of icons; keeps the bundle lean. |
+| 2026-05-06 | `` ships in PR 4 alongside ``; migrate the configuration builder to `` in a follow-up cleanup PR after Phase 1 | Foundation audit Q4. `` is narrow (one state, specific to the Java config builder). Building `` without churning the configuration builder keeps PR 4 small and decouples the visual-decision risk. |
+| 2026-05-06 | Status terminology follows the [OTel collector stability spec](https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/component-stability.md) — six levels: development / alpha / beta / stable / deprecated / unmaintained | Foundation audit Q5. Color mapping: development=secondary (gray), alpha=warning (orange), beta=info (blue), stable=success (green), deprecated=danger (red), unmaintained=danger (red). Mirrors the collector's vocabulary so anyone reading both sources sees the same terms. |
+| 2026-05-06 | PR 0 confirmed done — `V1_REDESIGN` is already in `src/lib/feature-flags.ts` on `main` | Added alongside `JAVA_RELEASE_COMPARISON` in a prior merge. `netlify.toml` was also already wired for `feat/84-*` previews. Flag is a no-op until PR 2 wires it into `App.tsx`. First real work is PR 1. |
+| 2026-05-06 | PR 5 will extend `DetailCard`, not `NavigationCard` | Verified by reading both components. `DetailCard` is a general-purpose wrapper (`children` + `className` passthrough) — clean fit for a `typeStripe` prop. `NavigationCard` is specialized (hardcoded icon box, arrow, corner accent); restructuring it for the stripe slot is not worth the churn. |
+| 2026-05-06 | Branch `feat/84-pr1-theme-system` created from `feat/84-layout-mockups` | PR 1 stacks on the planning branch so planning artifacts and first code PR can ship together (separate reviews). |
+| 2026-05-06 | No SCSS — use pure CSS partials in `src/styles/` (Tailwind v4 is the preprocessor) | Tailwind v4 [explicitly recommends against SCSS/Less/Stylus](https://tailwindcss.com/docs/compatibility#sass-less-and-stylus); it ships nesting, `@import` bundling, and vendor prefixing via Lightning CSS. All architecture goals (declared variables, folder structure, separation of concerns) achieved with plain CSS. |
+| 2026-05-06 | Brand realignment (`primary = blue`, `secondary = orange`) confirmed already on `main`; PR 1 does not flip these values | Re-reading `src/index.css` during the session revealed the canonical hues are already in place. PR 1 only reconciles the five remaining dark surface tokens (card/muted/border) and adds the light theme block. |
+| 2026-05-06 | PR 1 `theme-context.tsx` API: `{ mode, resolved, setMode }` — `mode` is the user preference (`light`/`dark`/`auto`), `resolved` is what's applied to the document | Cleaner than the old `{ themeId, setThemeId }` API. CSS owns values via `[data-theme]`; JS only sets the attribute. `useTheme` has zero non-test production consumers so the API reshaping is free. |
+| 2026-05-07 | PR 1 dark-surface reconciliation lands globally on `main`; `V1_REDESIGN` gates UI components/layout, not the base palette | Brand hues already on `main`; only the secondary surface tokens shift, and only for dark-theme users. Keeps PR 1 small and avoids a CSS indirection that PR 8 cleanup would have to unwind. |
+| 2026-05-07 | Drop `dark-blue` alias from `themes.ts` — the prior theme code never wrote to `localStorage`, so the alias was dead code with no real users behind it | The `td-color-theme` storage key is brand-new in PR 1; no migration needed. |
+| 2026-05-11 | Hero restored to orange-dominant after primary/secondary swap | Compass glow + ambient radial use `--otel-orange-hsl`; text gradient runs blue→orange. Addresses Jay's PR review feedback that "all the oranges turned blue except for the compass center dot." |
Add a row whenever a decision lands. Keeps the doc honest.