This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 0dc8cbe themes: lock palettes to design spec + port the preview-card
picker
0dc8cbe is described below
commit 0dc8cbe6f83268ceadd79da4373fda3073f9f8ca
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 19 17:21:43 2026 +0800
themes: lock palettes to design spec + port the preview-card picker
Pulled the canonical token snapshot from the design bundle's new URL
(XZBH5oSPr6e4tU-MHYOgWg) `screens/style-setup.jsx`. The prior export
predated the 5-theme picker; that's why I was working from
placeholders.
Token corrections (replacing every placeholder I had previously):
horizon accent #f97316 amber (unchanged — was already correct)
meridian accent #7a5af8 indigo (was wrong: I had #a855f7)
obsidian accent #22d3ee cyan (was wrong: I had purple, then blue)
daybreak accent #6366f1 violet (was wrong: I had muted neutral)
aurora accent #ec4899 magenta (was close; locked to exact)
Each theme block in themes.css now matches the design's full token
record byte-for-byte — bg layers, fg layers, accent + soft + line,
info/purple/ok/err/warn. Also added per-theme `--sw-font`,
`--sw-radius`, and `--sw-density-pad` tokens so future code can swap
type / corner roundness / tile padding per the design (horizon
Inter/6/compact, obsidian IBM Plex Mono/2/compact, daybreak
Inter/10/spacious, aurora Inter/12/comfortable).
AVAILABLE_THEMES in state/theme.ts now carries the full ThemeDef
record (tag, tagline, description, font, radius, density, all token
values, heroTint gradient string) — needed by the preview card,
which renders itself in its own theme via inline styles (CSS vars
only reflect the page's active theme).
New ThemePreviewCard.vue ports the design's `ThemeCard` directly:
- hero strip (110px) with theme-specific gradient + faint grid +
"SkyWalking · Name" brand chip
- mini app preview: Primary/Tonal/Ghost buttons with the theme's
radius, KPI strip (cpm/p99/err with accent/warn/err colors),
sparkline (theme accent gradient + line), density/font/radius/
mode meta pills
- description block: name + tagline + lede + "Use this theme" CTA
(or "Currently active" when this is the live theme)
GlobalDefaultsAdmin theme section now grids 5 of these cards (replaces
the old radio list). The picker reads `themeStore.active` for the
"active" badge so the operator sees what the renderer is currently
showing vs what they're selecting.
The previous correction back-and-forth (obsidian = purple? then blue?
daybreak = red? then white?) is now permanently resolved by lifting
straight from the design bundle.
---
.../admin/global-defaults/GlobalDefaultsAdmin.vue | 68 ++--
.../admin/global-defaults/ThemePreviewCard.vue | 375 +++++++++++++++++++++
apps/ui/src/state/theme.ts | 99 +++++-
packages/design-tokens/src/themes.css | 235 +++++++------
4 files changed, 612 insertions(+), 165 deletions(-)
diff --git a/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue
b/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue
index 39596bc..dc86d8a 100644
--- a/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue
+++ b/apps/ui/src/features/admin/global-defaults/GlobalDefaultsAdmin.vue
@@ -35,7 +35,13 @@ import SyncStatusBanner from
'@/features/admin/_shared/SyncStatusBanner.vue';
import TemplateStatusBadge from
'@/features/admin/_shared/TemplateStatusBadge.vue';
import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
-import { AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
+import { AVAILABLE_THEMES, useThemeStore, type ThemeId } from '@/state/theme';
+import ThemePreviewCard from './ThemePreviewCard.vue';
+
+// Theme store — for distinguishing the currently-ACTIVE theme (what the
+// renderer is showing right now, combining user override + org default
+// + bundled) from the operator's pending SELECTION in this picker.
+const themeStoreRef = useThemeStore();
// Two kinds in scope; we union by reading them both as separate sync
// calls (`useTemplateSync` is per-kind, so we call it twice).
@@ -239,26 +245,19 @@ async function onDiffReset(): Promise<void> {
</header>
<p class="gd__sec-lede">
Five bundled themes ship with Horizon. The org default is the
- starting theme every user sees on first visit. Users keep their
- own override afterwards.
+ starting theme every user sees on first visit. Each user can
+ override locally via the topbar theme chip (stored in
+ <code>localStorage['horizon:theme:user']</code>).
</p>
- <div class="gd__themes">
- <label
+ <div class="gd__theme-grid">
+ <ThemePreviewCard
v-for="t in AVAILABLE_THEMES"
:key="t.id"
- class="gd__theme"
- :class="{ active: themeDraft === t.id }"
- >
- <input
- v-model="themeDraft"
- type="radio"
- name="themeDraft"
- :value="t.id"
- :disabled="readOnly"
- />
- <span class="gd__theme-id">{{ t.label }}</span>
- <span class="gd__theme-desc">{{ t.description }}</span>
- </label>
+ :theme="t"
+ :selected="themeDraft === t.id"
+ :active="themeStoreRef.active === t.id"
+ @pick="!readOnly && (themeDraft = t.id)"
+ />
</div>
</section>
@@ -444,37 +443,10 @@ async function onDiffReset(): Promise<void> {
color: var(--sw-fg-0);
}
-.gd__themes {
+.gd__theme-grid {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 8px;
-}
-.gd__theme {
- display: grid;
- grid-template-columns: auto 1fr;
- column-gap: 8px;
- align-items: start;
- padding: 10px 12px;
- border: 1px solid var(--sw-line);
- border-radius: 4px;
- background: var(--sw-bg-2);
- cursor: pointer;
-}
-.gd__theme.active {
- border-color: var(--sw-accent-line);
- background: var(--sw-accent-soft);
-}
-.gd__theme input { grid-row: span 2; margin-top: 2px; }
-.gd__theme-id {
- font-size: 12px;
- font-weight: 600;
- color: var(--sw-fg-0);
-}
-.gd__theme-desc {
- grid-column: 2;
- font-size: 11px;
- color: var(--sw-fg-2);
- line-height: 1.45;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 12px;
}
.gd__presets {
diff --git a/apps/ui/src/features/admin/global-defaults/ThemePreviewCard.vue
b/apps/ui/src/features/admin/global-defaults/ThemePreviewCard.vue
new file mode 100644
index 0000000..1c248eb
--- /dev/null
+++ b/apps/ui/src/features/admin/global-defaults/ThemePreviewCard.vue
@@ -0,0 +1,375 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You 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
+
+ http://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.
+-->
+<!--
+ Theme preview card — ported from the design bundle's
+ `screens/style-setup.jsx > ThemeCard`. Self-themed via inline styles
+ that read the ThemeDef's token snapshot (NOT --sw-* vars), so each
+ card renders in its OWN theme regardless of the page's active theme.
+ That's the whole point of the preview: see the palette before you
+ switch.
+
+ Three stacked regions, matching the design:
+ 1. Hero strip (110px) — gradient + faint grid + mini brand chip
+ 2. Mini-app preview — Primary/Tonal/Ghost buttons, KPI strip
+ (cpm / p99 / err), sparkline, density/font/radius/mode badges
+ 3. Description block — name + tagline + desc + actions
+-->
+<script setup lang="ts">
+import type { ThemeDef } from '@/state/theme';
+
+defineProps<{
+ theme: ThemeDef;
+ /** This card represents the currently-active theme (drives ribbon +
+ * primary-button label). Distinct from `selected`: a card can be
+ * selected (radio state) without yet being applied. */
+ active: boolean;
+ /** Hovered/clicked but not yet committed via Save. */
+ selected: boolean;
+}>();
+
+defineEmits<{ pick: [] }>();
+
+function tagBadgeStyle(t: ThemeDef): Record<string, string> {
+ return {
+ background: t.bg0 + 'cc',
+ color: t.fg1,
+ border: `1px solid ${t.line}`,
+ };
+}
+function chipStyle(t: ThemeDef, kind: 'primary' | 'tonal' | 'ghost'):
Record<string, string> {
+ if (kind === 'primary') {
+ return {
+ background: t.accent,
+ color: t.appearance === 'light' ? '#fff' : '#0a0d12',
+ borderRadius: `${t.radius}px`,
+ };
+ }
+ if (kind === 'tonal') {
+ return {
+ background: t.accentSoft,
+ color: t.accent,
+ border: `1px solid ${t.accentLine}`,
+ borderRadius: `${t.radius}px`,
+ };
+ }
+ return {
+ background: t.bg2,
+ color: t.fg1,
+ border: `1px solid ${t.line}`,
+ borderRadius: `${t.radius}px`,
+ };
+}
+function kpiTileStyle(t: ThemeDef): Record<string, string> {
+ return {
+ background: t.bg2,
+ border: `1px solid ${t.line}`,
+ borderRadius: `${Math.max(2, t.radius - 2)}px`,
+ };
+}
+function metaBadgeStyle(t: ThemeDef): Record<string, string> {
+ return {
+ background: t.bg2,
+ border: `1px solid ${t.line}`,
+ };
+}
+</script>
+
+<template>
+ <div
+ class="tpc"
+ :class="{ 'tpc--selected': selected, 'tpc--active': active }"
+ :style="{
+ background: theme.bg1,
+ color: theme.fg0,
+ borderColor: selected || active ? theme.accent : theme.line,
+ boxShadow: selected || active
+ ? `0 0 0 2px ${theme.accent}55, 0 12px 32px rgba(0,0,0,0.4)`
+ : '0 4px 12px rgba(0,0,0,0.25)',
+ }"
+ @click="$emit('pick')"
+ >
+ <!-- Top-left badges: tag + 'active' chip when this is the live theme. -->
+ <div class="tpc__badges">
+ <span class="tpc__tag" :style="tagBadgeStyle(theme)">{{ theme.tag
}}</span>
+ <span
+ v-if="active"
+ class="tpc__active"
+ :style="{ background: theme.accent, color: '#0a0d12' }"
+ >active</span>
+ </div>
+
+ <!-- Hero strip — theme-specific gradient + faint grid + brand chip. -->
+ <div class="tpc__hero" :style="{ background: theme.heroTint }">
+ <div
+ class="tpc__hero-grid"
+ :style="{
+ opacity: theme.appearance === 'light' ? 0.06 : 0.12,
+ backgroundImage:
+ `linear-gradient(${theme.line} 1px, transparent 1px), ` +
+ `linear-gradient(90deg, ${theme.line} 1px, transparent 1px)`,
+ }"
+ />
+ <div
+ class="tpc__brand"
+ :style="{ color: theme.appearance === 'light' ? '#1a1d24' : '#fff' }"
+ >
+ <span
+ class="tpc__brand-sigil"
+ :style="{ background: `linear-gradient(135deg, ${theme.accent},
${theme.purple})` }"
+ />
+ SkyWalking · {{ theme.label }}
+ </div>
+ </div>
+
+ <!-- Mini-app preview. -->
+ <div class="tpc__body" :style="{ background: theme.bg0, color: theme.fg1,
fontFamily: theme.font }">
+ <div class="tpc__buttons">
+ <span class="tpc__chip" :style="chipStyle(theme,
'primary')">Primary</span>
+ <span class="tpc__chip" :style="chipStyle(theme, 'tonal')">Tonal</span>
+ <span class="tpc__chip" :style="chipStyle(theme, 'ghost')">Ghost</span>
+ </div>
+ <div class="tpc__kpis">
+ <div class="tpc__kpi" :style="kpiTileStyle(theme)">
+ <div class="tpc__kpi-k" :style="{ color: theme.fg2 }">cpm</div>
+ <div class="tpc__kpi-v" :style="{ color: theme.accent }">284k</div>
+ </div>
+ <div class="tpc__kpi" :style="kpiTileStyle(theme)">
+ <div class="tpc__kpi-k" :style="{ color: theme.fg2 }">p99</div>
+ <div class="tpc__kpi-v" :style="{ color: theme.warn }">412ms</div>
+ </div>
+ <div class="tpc__kpi" :style="kpiTileStyle(theme)">
+ <div class="tpc__kpi-k" :style="{ color: theme.fg2 }">err</div>
+ <div class="tpc__kpi-v" :style="{ color: theme.err }">0.4%</div>
+ </div>
+ </div>
+ <div
+ class="tpc__chart"
+ :style="{
+ background: theme.bg2,
+ border: `1px solid ${theme.line}`,
+ borderRadius: `${Math.max(2, theme.radius - 2)}px`,
+ }"
+ >
+ <svg viewBox="0 0 100 28" preserveAspectRatio="none">
+ <defs>
+ <linearGradient :id="`tpc-g-${theme.id}`" x1="0" x2="0" y1="0"
y2="1">
+ <stop offset="0%" :stop-color="theme.accent"
stop-opacity="0.35" />
+ <stop offset="100%" :stop-color="theme.accent" stop-opacity="0"
/>
+ </linearGradient>
+ </defs>
+ <path
+ d="M0 22 L10 18 L20 14 L30 16 L40 9 L50 13 L60 7 L70 11 L80 6 L90
9 L100 4 L100 28 L0 28 Z"
+ :fill="`url(#tpc-g-${theme.id})`"
+ />
+ <path
+ d="M0 22 L10 18 L20 14 L30 16 L40 9 L50 13 L60 7 L70 11 L80 6 L90
9 L100 4"
+ fill="none"
+ :stroke="theme.accent"
+ stroke-width="1.4"
+ />
+ </svg>
+ </div>
+ <div class="tpc__meta">
+ <span class="tpc__meta-pill" :style="metaBadgeStyle(theme)">
+ <span :style="{ color: theme.fg3 }">font</span>
+ <span :style="{ color: theme.fg1 }">{{ theme.font }}</span>
+ </span>
+ <span class="tpc__meta-pill" :style="metaBadgeStyle(theme)">
+ <span :style="{ color: theme.fg3 }">r</span>
+ <span :style="{ color: theme.fg1 }">{{ theme.radius }}px</span>
+ </span>
+ <span class="tpc__meta-pill" :style="metaBadgeStyle(theme)">
+ <span :style="{ color: theme.fg3 }">density</span>
+ <span :style="{ color: theme.fg1 }">{{ theme.density.toLowerCase()
}}</span>
+ </span>
+ <span class="tpc__meta-pill" :style="metaBadgeStyle(theme)">
+ <span :style="{ color: theme.fg3 }">mode</span>
+ <span :style="{ color: theme.fg1 }">{{ theme.appearance }}</span>
+ </span>
+ </div>
+ </div>
+
+ <!-- Description block. -->
+ <div
+ class="tpc__desc"
+ :style="{
+ background: theme.bg1,
+ color: theme.fg1,
+ borderTop: `1px solid ${theme.line}`,
+ }"
+ >
+ <div class="tpc__name" :style="{ color: theme.fg0, fontFamily:
theme.font }">{{ theme.label }}</div>
+ <div class="tpc__tagline" :style="{ color: theme.fg2 }">{{ theme.tagline
}}</div>
+ <div class="tpc__lede" :style="{ color: theme.fg2 }">{{
theme.description }}</div>
+ <div class="tpc__cta">
+ <span
+ class="tpc__use"
+ :style="{
+ background: active ? theme.bg2 : theme.accent,
+ color: active ? theme.fg1 : (theme.appearance === 'light' ? '#fff'
: '#0a0d12'),
+ border: `1px solid ${active ? theme.line : theme.accent}`,
+ }"
+ >{{ active ? 'Currently active' : (selected ? 'Selected' : 'Use this
theme') }}</span>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.tpc {
+ border: 1px solid;
+ border-radius: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ cursor: pointer;
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
+}
+.tpc:hover { transform: translateY(-1px); }
+
+.tpc__badges {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.tpc__tag, .tpc__active {
+ padding: 1px 7px;
+ border-radius: 999px;
+ font-size: 9.5px;
+ font-weight: 700;
+ backdrop-filter: blur(6px);
+}
+
+.tpc__hero {
+ position: relative;
+ height: 110px;
+}
+.tpc__hero-grid {
+ position: absolute;
+ inset: 0;
+ background-size: 56px 56px, 56px 56px;
+ background-position: 0 0, 0 0;
+ pointer-events: none;
+}
+.tpc__brand {
+ position: absolute;
+ bottom: 10px;
+ left: 12px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: -0.2px;
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
+}
+.tpc__brand-sigil {
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+ display: inline-block;
+}
+
+.tpc__body { padding: 10px; }
+.tpc__buttons {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+}
+.tpc__chip {
+ padding: 3px 8px;
+ font-size: 10px;
+ font-weight: 700;
+ font-family: inherit;
+}
+.tpc__kpis {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 5px;
+}
+.tpc__kpi { padding: 5px 6px; }
+.tpc__kpi-k {
+ font-size: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+}
+.tpc__kpi-v {
+ font-size: 12px;
+ font-weight: 700;
+ font-family: ui-monospace, monospace;
+}
+.tpc__chart {
+ margin-top: 8px;
+ height: 36px;
+ padding: 4px;
+}
+.tpc__chart svg {
+ width: 100%;
+ height: 100%;
+}
+.tpc__meta {
+ margin-top: 8px;
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ font-size: 9px;
+}
+.tpc__meta-pill {
+ padding: 1px 6px;
+ border-radius: 999px;
+ display: inline-flex;
+ gap: 4px;
+}
+
+.tpc__desc {
+ padding: 8px 10px 10px;
+ margin-top: auto;
+}
+.tpc__name {
+ font-size: 13px;
+ font-weight: 700;
+}
+.tpc__tagline {
+ font-size: 10px;
+ margin-top: 2px;
+}
+.tpc__lede {
+ font-size: 10.5px;
+ line-height: 1.5;
+ margin-top: 6px;
+}
+.tpc__cta {
+ margin-top: 8px;
+}
+.tpc__use {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 26px;
+ padding: 0 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 700;
+}
+</style>
diff --git a/apps/ui/src/state/theme.ts b/apps/ui/src/state/theme.ts
index c36e62d..79918bb 100644
--- a/apps/ui/src/state/theme.ts
+++ b/apps/ui/src/state/theme.ts
@@ -38,26 +38,91 @@ import { useConfigBundle } from '@/controls/configBundle';
import { debug } from '@/utils/debug';
import type { TemplateBadge } from '@/api/scopes/configs';
-export type ThemeId = 'horizon' | 'obsidian' | 'aurora' | 'meridian' |
'daybreak';
+export type ThemeId = 'horizon' | 'meridian' | 'obsidian' | 'daybreak' |
'aurora';
-/** The five bundled themes (design-specified names). Matches the
- * `[data-theme="..."]` selectors in
- * `packages/design-tokens/src/themes.css`. The first three are dark;
- * the last two are light. */
-export const AVAILABLE_THEMES: ReadonlyArray<{
+/** Full per-theme metadata — lifted from the design bundle's
+ * `screens/style-setup.jsx`. The token values here are duplicated
+ * with the CSS in `packages/design-tokens/src/themes.css` BECAUSE
+ * the theme picker preview cards need inline-style access to the
+ * swatches (canvas / SVG can't read `var(--…)` synchronously).
+ * Runtime rendering still goes through the CSS — this table is for
+ * preview / preview-only consumption. */
+export interface ThemeDef {
id: ThemeId;
label: string;
- description: string;
- /** `dark` themes use the white SkyWalking logo; `light` themes use
- * the blue (`#1368B3`) variant. Read by the brand-logo CSS to know
- * which inline SVG to show. */
- appearance: 'dark' | 'light';
-}> = [
- { id: 'horizon', label: 'Horizon', description: 'Flagship dark — canyon
orange accent on deep blue-grey. Default.', appearance: 'dark' },
- { id: 'obsidian', label: 'Obsidian', description: 'Dark, blue accent.',
appearance: 'dark' },
- { id: 'aurora', label: 'Aurora', description: 'Dark, pink accent.',
appearance: 'dark' },
- { id: 'meridian', label: 'Meridian', description: 'Dark, purple accent.',
appearance: 'dark' },
- { id: 'daybreak', label: 'Daybreak', description: 'White light theme.',
appearance: 'light' },
+ tag: string; // small chip text (e.g. "default", "high-contrast")
+ tagline: string; // one-line palette description
+ description: string; // operator-facing sentence
+ appearance: 'dark' | 'light'; // drives logo + light-bg-specific code paths
+ font: string;
+ radius: number;
+ density: 'Compact' | 'Spacious' | 'Comfortable';
+ // Token snapshot (mirrors themes.css per id).
+ bg0: string; bg1: string; bg2: string; line: string;
+ fg0: string; fg1: string; fg2: string; fg3: string;
+ accent: string; accentSoft: string; accentLine: string;
+ info: string; purple: string; ok: string; err: string; warn: string;
+ /** Hero background CSS for the preview card hero strip. Mirrors
+ * the design's `heroTint` plus the optional photo. */
+ heroTint: string;
+}
+
+export const AVAILABLE_THEMES: readonly ThemeDef[] = [
+ {
+ id: 'horizon', label: 'Horizon', tag: 'default',
+ tagline: 'Dark · amber accent · canyon hero',
+ description: 'The shipped SkyWalking NG look. Dense, warm,
observability-first.',
+ appearance: 'dark', font: 'Inter', radius: 6, density: 'Compact',
+ bg0: '#0a0d12', bg1: '#0f131a', bg2: '#151a23', line: '#232a37',
+ fg0: '#e8ecf3', fg1: '#b6bdcc', fg2: '#818a9c', fg3: '#5b6373',
+ accent: '#f97316', accentSoft: 'rgba(249,115,22,0.14)', accentLine:
'rgba(249,115,22,0.4)',
+ info: '#38bdf8', purple: '#a855f7', ok: '#22c55e', err: '#ef4444', warn:
'#eab308',
+ heroTint: 'linear-gradient(180deg, rgba(10,13,18,0.10) 0%,
rgba(10,13,18,0.85) 100%), radial-gradient(700px 460px at 20% 30%,
rgba(249,115,22,0.22), transparent 60%)',
+ },
+ {
+ id: 'meridian', label: 'Meridian', tag: 'dense',
+ tagline: 'Navy ground · indigo accent',
+ description: 'Cooler navy palette with an indigo accent. Tightly packed
for SREs who live in tables.',
+ appearance: 'dark', font: 'Inter', radius: 4, density: 'Compact',
+ bg0: '#0b0f1a', bg1: '#101421', bg2: '#171c2e', line: '#232a3c',
+ fg0: '#eef0f7', fg1: '#aeb4c7', fg2: '#7c8295', fg3: '#525a73',
+ accent: '#7a5af8', accentSoft: 'rgba(122,90,248,0.16)', accentLine:
'rgba(122,90,248,0.4)',
+ info: '#60a5fa', purple: '#c084fc', ok: '#34d399', err: '#f87171', warn:
'#fbbf24',
+ heroTint: 'radial-gradient(700px 500px at 50% 35%, rgba(122,90,248,0.20),
transparent 60%), linear-gradient(180deg, #11142a, #0a0d1c)',
+ },
+ {
+ id: 'obsidian', label: 'Obsidian', tag: 'high-contrast',
+ tagline: 'True-black · cyan accent · monospaced',
+ description: 'True-black backdrop and a cyan punch. Pixel-precise
readouts; comfortable on OLED.',
+ appearance: 'dark', font: 'IBM Plex Mono', radius: 2, density: 'Compact',
+ bg0: '#000000', bg1: '#0a0a0a', bg2: '#141414', line: '#222222',
+ fg0: '#f4f4f5', fg1: '#c4c4c4', fg2: '#888888', fg3: '#5a5a5a',
+ accent: '#22d3ee', accentSoft: 'rgba(34,211,238,0.15)', accentLine:
'rgba(34,211,238,0.45)',
+ info: '#7dd3fc', purple: '#d946ef', ok: '#84cc16', err: '#f43f5e', warn:
'#facc15',
+ heroTint: 'linear-gradient(180deg, #000 0%, #060606 100%),
radial-gradient(500px 350px at 50% 50%, rgba(34,211,238,0.12), transparent
60%)',
+ },
+ {
+ id: 'daybreak', label: 'Daybreak', tag: 'light',
+ tagline: 'Light ground · violet accent · airy',
+ description: 'Daytime palette with generous spacing and soft shadows. For
shared screens and printouts.',
+ appearance: 'light', font: 'Inter', radius: 10, density: 'Spacious',
+ bg0: '#f7f7fa', bg1: '#ffffff', bg2: '#f0f1f5', line: '#e3e4ec',
+ fg0: '#0a0d12', fg1: '#3a3f4c', fg2: '#6e7382', fg3: '#9ba0af',
+ accent: '#6366f1', accentSoft: 'rgba(99,102,241,0.10)', accentLine:
'rgba(99,102,241,0.32)',
+ info: '#0ea5e9', purple: '#a855f7', ok: '#16a34a', err: '#dc2626', warn:
'#d97706',
+ heroTint: 'linear-gradient(180deg, #eef0fa 0%, #f7f7fa 100%),
radial-gradient(700px 460px at 70% 30%, rgba(99,102,241,0.18), transparent
60%)',
+ },
+ {
+ id: 'aurora', label: 'Aurora', tag: 'showcase',
+ tagline: 'Glass chrome · magenta/cyan gradient',
+ description: 'Glass-morphic chrome with a magenta-to-cyan gradient accent.
Made for demos and product tours.',
+ appearance: 'dark', font: 'Inter', radius: 12, density: 'Comfortable',
+ bg0: '#0b0d18', bg1: '#11142a', bg2: '#181b35', line: '#262a48',
+ fg0: '#f1f3ff', fg1: '#b9bee0', fg2: '#828abe', fg3: '#525a8a',
+ accent: '#ec4899', accentSoft: 'rgba(236,72,153,0.16)', accentLine:
'rgba(236,72,153,0.4)',
+ info: '#22d3ee', purple: '#a855f7', ok: '#22d3ee', err: '#f43f5e', warn:
'#fbbf24',
+ heroTint: 'radial-gradient(600px 420px at 25% 30%, rgba(236,72,153,0.30),
transparent 60%), radial-gradient(600px 420px at 75% 70%,
rgba(34,211,238,0.25), transparent 60%), linear-gradient(180deg, #0b0d18,
#131534)',
+ },
];
const USER_KEY = 'horizon:theme:user';
diff --git a/packages/design-tokens/src/themes.css
b/packages/design-tokens/src/themes.css
index 3604fab..ea2706c 100644
--- a/packages/design-tokens/src/themes.css
+++ b/packages/design-tokens/src/themes.css
@@ -17,122 +17,157 @@
/* ── Horizon NG theme variants ────────────────────────────────────────
*
- * The `:root` block in `tokens.css` is the **Horizon** flagship dark
- * palette. Each other theme is a `[data-theme="<id>"]` block that
- * overrides only the tokens that change; the `--rr-*` aliases pick up
- * new `--sw-*` values via `var()` automatically.
+ * Token values are LIFTED VERBATIM from the design bundle's
+ * `screens/style-setup.jsx`. The 5 bundled themes are:
*
- * Five bundled themes (design-specified names + dominant hue):
- * horizon — flagship dark, canyon orange accent (the :root default)
- * obsidian — dark, BLUE accent
- * aurora — dark, PINK accent
- * meridian — dark, PURPLE accent
- * daybreak — WHITE light theme, muted accent
+ * horizon default · dark · amber accent · canyon hero
+ * meridian dense · dark · indigo accent · navy ground
+ * obsidian high-contrast · dark · cyan accent · true-black, monospaced
+ * daybreak light · violet accent · airy
+ * aurora showcase · dark · magenta accent · glass chrome
*
- * IMPORTANT — the specific hex values below are PLACEHOLDERS pulled
- * from typical hues in each color family. They are NOT the design
- * spec's exact tokens (which I do not have visibility into). Replace
- * each `--sw-accent` / `--sw-accent-*` value with the design's
- * canonical hex once provided.
+ * The `:root` block in `tokens.css` is the horizon palette (the
+ * shipped default); each other theme is a `[data-theme="<id>"]`
+ * selector that overrides only what changes. The `--rr-*` aliases
+ * pick up new `--sw-*` values via `var()` automatically.
*
- * Switched at runtime by setting `data-theme="<id>"` on `<html>`.
- * Adding a theme means appending another block here + listing the id
- * in `themeStore.AVAILABLE_THEMES`.
+ * Beyond colors, each theme also picks a `--sw-radius` (corner
+ * roundness), `--sw-density-pad` (per-tile padding), and `--sw-font`
+ * (UI font). Code that consumes these still uses var() reads; the
+ * theme store sets `<html data-theme>` and CSS does the rest.
*/
-/* ── horizon (default, no-op override; declared for completeness) ── */
+/* ── horizon (default — declared explicitly for parity with the others) ── */
[data-theme="horizon"] {
- /* Mirrors :root in tokens.css. */
+ --sw-bg-0: #0a0d12;
+ --sw-bg-1: #0f131a;
+ --sw-bg-2: #151a23;
+ --sw-line: #232a37;
+
+ --sw-fg-0: #e8ecf3;
+ --sw-fg-1: #b6bdcc;
+ --sw-fg-2: #818a9c;
+ --sw-fg-3: #5b6373;
+
+ --sw-accent: #f97316;
+ --sw-accent-soft: rgba(249, 115, 22, 0.14);
+ --sw-accent-line: rgba(249, 115, 22, 0.4);
+
+ --sw-info: #38bdf8;
+ --sw-purple: #a855f7;
+ --sw-ok: #22c55e;
+ --sw-err: #ef4444;
+ --sw-warn: #eab308;
+
+ --sw-font: "Inter", system-ui, sans-serif;
+ --sw-radius: 6px;
+ --sw-density-pad: 6px;
+}
+
+/* ── meridian — navy + indigo, dense ─────────────────────────────── */
+[data-theme="meridian"] {
+ --sw-bg-0: #0b0f1a;
+ --sw-bg-1: #101421;
+ --sw-bg-2: #171c2e;
+ --sw-line: #232a3c;
+
+ --sw-fg-0: #eef0f7;
+ --sw-fg-1: #aeb4c7;
+ --sw-fg-2: #7c8295;
+ --sw-fg-3: #525a73;
+
+ --sw-accent: #7a5af8;
+ --sw-accent-soft: rgba(122, 90, 248, 0.16);
+ --sw-accent-line: rgba(122, 90, 248, 0.4);
+
+ --sw-info: #60a5fa;
+ --sw-purple: #c084fc;
+ --sw-ok: #34d399;
+ --sw-err: #f87171;
+ --sw-warn: #fbbf24;
+
+ --sw-font: "Inter", system-ui, sans-serif;
+ --sw-radius: 4px;
+ --sw-density-pad: 6px;
}
-/* ── obsidian — dark, BLUE accent ─────────────────────────────────── */
+/* ── obsidian — true-black + cyan, monospaced ────────────────────── */
[data-theme="obsidian"] {
--sw-bg-0: #000000;
--sw-bg-1: #0a0a0a;
--sw-bg-2: #141414;
- --sw-bg-3: #1c1c1c;
- --sw-bg-4: #262626;
- --sw-line: #2a2f38;
- --sw-line-2: #3a4252;
- --sw-line-3: #4a5468;
-
- --sw-fg-0: #ffffff;
- --sw-fg-1: #ededed;
- --sw-fg-2: #c8c8c8;
- --sw-fg-3: #989898;
-
- /* TODO: replace with design-spec blue */
- --sw-accent: #3a8ed0;
- --sw-accent-2: #5aa3e0;
- --sw-accent-soft: rgba(58, 142, 208, 0.16);
- --sw-accent-line: rgba(58, 142, 208, 0.5);
+ --sw-line: #222222;
+
+ --sw-fg-0: #f4f4f5;
+ --sw-fg-1: #c4c4c4;
+ --sw-fg-2: #888888;
+ --sw-fg-3: #5a5a5a;
+
+ --sw-accent: #22d3ee;
+ --sw-accent-soft: rgba(34, 211, 238, 0.15);
+ --sw-accent-line: rgba(34, 211, 238, 0.45);
+
+ --sw-info: #7dd3fc;
+ --sw-purple: #d946ef;
+ --sw-ok: #84cc16;
+ --sw-err: #f43f5e;
+ --sw-warn: #facc15;
+
+ --sw-font: "IBM Plex Mono", ui-monospace, monospace;
+ --sw-radius: 2px;
+ --sw-density-pad: 6px;
+}
+
+/* ── daybreak — light + violet, airy ─────────────────────────────── */
+[data-theme="daybreak"] {
+ --sw-bg-0: #f7f7fa;
+ --sw-bg-1: #ffffff;
+ --sw-bg-2: #f0f1f5;
+ --sw-line: #e3e4ec;
+
+ --sw-fg-0: #0a0d12;
+ --sw-fg-1: #3a3f4c;
+ --sw-fg-2: #6e7382;
+ --sw-fg-3: #9ba0af;
+
+ --sw-accent: #6366f1;
+ --sw-accent-soft: rgba(99, 102, 241, 0.10);
+ --sw-accent-line: rgba(99, 102, 241, 0.32);
+
+ --sw-info: #0ea5e9;
+ --sw-purple: #a855f7;
+ --sw-ok: #16a34a;
+ --sw-err: #dc2626;
+ --sw-warn: #d97706;
+
+ --sw-font: "Inter", system-ui, sans-serif;
+ --sw-radius: 10px;
+ --sw-density-pad: 10px;
}
-/* ── aurora — dark, PINK accent ───────────────────────────────────── */
+/* ── aurora — glass chrome + magenta/cyan gradient, showcase ─────── */
[data-theme="aurora"] {
- --sw-bg-0: #0e0a14;
- --sw-bg-1: #181020;
- --sw-bg-2: #221830;
- --sw-bg-3: #2a1f3c;
- --sw-bg-4: #34294a;
- --sw-line: #2e2240;
- --sw-line-2: #3e2f56;
- --sw-line-3: #4e3f6c;
-
- --sw-fg-0: #f5edf5;
- --sw-fg-1: #d8cce0;
- --sw-fg-2: #a89cb8;
- --sw-fg-3: #7e7090;
-
- /* TODO: replace with design-spec pink */
- --sw-accent: #ec4899;
- --sw-accent-2: #f472b6;
+ --sw-bg-0: #0b0d18;
+ --sw-bg-1: #11142a;
+ --sw-bg-2: #181b35;
+ --sw-line: #262a48;
+
+ --sw-fg-0: #f1f3ff;
+ --sw-fg-1: #b9bee0;
+ --sw-fg-2: #828abe;
+ --sw-fg-3: #525a8a;
+
+ --sw-accent: #ec4899;
--sw-accent-soft: rgba(236, 72, 153, 0.16);
- --sw-accent-line: rgba(236, 72, 153, 0.45);
-}
+ --sw-accent-line: rgba(236, 72, 153, 0.4);
-/* ── meridian — dark, PURPLE accent ───────────────────────────────── */
-[data-theme="meridian"] {
- --sw-bg-0: #0d0a1a;
- --sw-bg-1: #15102a;
- --sw-bg-2: #1d1838;
- --sw-bg-3: #261f48;
- --sw-bg-4: #2f285a;
- --sw-line: #2a2548;
- --sw-line-2: #3a345e;
- --sw-line-3: #4c4476;
-
- --sw-fg-0: #efedf7;
- --sw-fg-1: #d4cfe6;
- --sw-fg-2: #a59fc0;
- --sw-fg-3: #7b7398;
-
- /* TODO: replace with design-spec purple */
- --sw-accent: #a855f7;
- --sw-accent-2: #c084fc;
- --sw-accent-soft: rgba(168, 85, 247, 0.16);
- --sw-accent-line: rgba(168, 85, 247, 0.5);
-}
+ --sw-info: #22d3ee;
+ --sw-purple: #a855f7;
+ --sw-ok: #22d3ee;
+ --sw-err: #f43f5e;
+ --sw-warn: #fbbf24;
-/* ── daybreak — WHITE light theme ─────────────────────────────────── */
-[data-theme="daybreak"] {
- --sw-bg-0: #ffffff;
- --sw-bg-1: #fbfbfd;
- --sw-bg-2: #f4f5f8;
- --sw-bg-3: #ecedf2;
- --sw-bg-4: #e2e4eb;
- --sw-line: #e3e5ec;
- --sw-line-2: #d3d6e0;
- --sw-line-3: #b8bcc8;
-
- --sw-fg-0: #14181f;
- --sw-fg-1: #2e3640;
- --sw-fg-2: #5b6373;
- --sw-fg-3: #8a93a0;
-
- /* TODO: replace with design-spec daybreak accent (muted is a guess) */
- --sw-accent: #4b5563;
- --sw-accent-2: #6b7280;
- --sw-accent-soft: rgba(75, 85, 99, 0.10);
- --sw-accent-line: rgba(75, 85, 99, 0.35);
+ --sw-font: "Inter", system-ui, sans-serif;
+ --sw-radius: 12px;
+ --sw-density-pad: 12px;
}