Pārlūkot izejas kodu

feat(theme): add CSS-variable theme system with preset support

Add a theme system to the editor that maps colors to --ui-* and
--code-* CSS variables scoped to the editor shell element.

- EditorTheme type with ThemeColors and ThemeSyntax interfaces
- Built-in `kanagawa` preset with full color + syntax palette
- `resolveTheme()` for preset lookup and config validation
- `themeToCSSVars()` for CSS variable mapping
- `theme` prop on `<Editor>` accepting preset name or config object
- Dev demo page at /themes with selectable presets

Tests: 10 new test cases for resolve, mapping, and preset integrity.
Zander Hawke 5 dienas atpakaļ
vecāks
revīzija
58753fd634

+ 1 - 0
apps/dev/src/App.vue

@@ -19,6 +19,7 @@ const navigationItems = [
   { label: "Properties & Dates", icon: "i-lucide-list", to: "/properties" },
   { label: "Math", icon: "i-lucide-sigma", to: "/math" },
   { label: "Toolbar", icon: "i-lucide-rows-3", to: "/toolbar" },
+  { label: "Themes", icon: "i-lucide-palette", to: "/themes" },
 ]
 
 const variants: {

+ 2 - 0
apps/dev/src/main.ts

@@ -13,6 +13,7 @@ import MathPage from "~/pages/math.vue"
 import Properties from "~/pages/properties.vue"
 import RefsTags from "~/pages/refs-tags.vue"
 import Tasks from "~/pages/tasks.vue"
+import ThemesPage from "~/pages/themes.vue"
 import ToolbarPage from "~/pages/toolbar.vue"
 import "~/style.css"
 
@@ -28,6 +29,7 @@ const routes = [
   { path: "/code-blocks", component: CodeBlocks },
   { path: "/properties", component: Properties },
   { path: "/math", component: MathPage },
+  { path: "/themes", component: ThemesPage },
   { path: "/toolbar", component: ToolbarPage },
 ]
 

+ 95 - 0
apps/dev/src/pages/themes.vue

@@ -0,0 +1,95 @@
+<script setup lang="ts">
+import type { ThemeInput } from "@enesis/editor"
+import { Editor } from "@enesis/editor"
+import { computed, ref } from "vue"
+
+const content = ref(`* TODO Build the Editor shell
+  * Phase 1: Block parser
+  * Phase 2: Test edge cases
+* DOING Theme system [#A]
+  * Define EditorTheme type
+  * Create kanagawa preset
+  * Wire CSS vars to shell
+* LATER Add more presets
+* DONE Task state support
+  * Implement task cycling
+  * Add priority display`)
+
+const currentTheme = ref<string>("default")
+const customPrimary = ref("#ff6b6b")
+
+const themeInput = computed<ThemeInput>(() => {
+  if (currentTheme.value === "custom") {
+    return { colors: { primary: customPrimary.value } }
+  }
+  return currentTheme.value
+})
+
+const isKanagawa = computed(() => currentTheme.value === "kanagawa")
+const isCustom = computed(() => currentTheme.value === "custom")
+</script>
+
+<template>
+  <UPage>
+    <UPageHeader
+      title="Themes"
+      description="Test the CSS-variable theme system with built-in presets and custom overrides."
+    />
+
+    <UPageBody>
+      <div class="space-y-8">
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Theme Switcher</h2>
+          <p class="text-sm text-muted leading-relaxed">
+            Select a preset or use a custom primary color. The editor's
+            <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">theme</code>
+            prop maps colors to
+            <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">--ui-*</code>
+            CSS variables scoped to the shell element.
+          </p>
+
+          <div class="flex items-center gap-2">
+            <UButton
+              :variant="currentTheme === 'default' ? 'solid' : 'ghost'"
+              size="sm"
+              label="Default"
+              @click="currentTheme = 'default'"
+            />
+            <UButton
+              :variant="isKanagawa ? 'solid' : 'ghost'"
+              size="sm"
+              label="Kanagawa"
+              @click="currentTheme = 'kanagawa'"
+            />
+            <UButton
+              :variant="isCustom ? 'solid' : 'ghost'"
+              size="sm"
+              label="Custom"
+              @click="currentTheme = 'custom'"
+            />
+            <div
+              v-if="isCustom"
+              class="ml-2 w-4 h-4 rounded"
+              :style="{ backgroundColor: customPrimary }"
+            />
+          </div>
+        </section>
+
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Editor Preview</h2>
+
+          <div class="rounded-lg border border-default bg-elevated overflow-hidden">
+            <div class="p-4">
+              <Editor
+                v-model:content="content"
+                :focused="true"
+                marker-mode="always-visible"
+                :theme="themeInput"
+              />
+            </div>
+          </div>
+        </section>
+      </div>
+    </UPageBody>
+  </UPage>
+</template>

+ 9 - 2
packages/editor/src/components/Editor.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { EditorView } from "prosemirror-view"
-import { nextTick, ref, useSlots, watch } from "vue"
+import { computed, nextTick, ref, useSlots, watch } from "vue"
 import Block from "@/components/Block.vue"
 import InsertionZone from "@/components/InsertionZone.vue"
 import {
@@ -21,6 +21,7 @@ import {
   type EditorOperation,
   type OperationHistory,
 } from "@/lib/operation-history"
+import { resolveTheme, type ThemeInput, themeToCSSVars } from "@/lib/theme"
 
 const content = defineModel<string>("content", { required: false })
 
@@ -35,12 +36,18 @@ const props = withDefaults(
     focused?: boolean
     markerMode?: MarkerVisibilityMode
     debug?: string
+    theme?: ThemeInput
   }>(),
   {
     focused: false,
   },
 )
 
+const themeCSSVars = computed(() => {
+  const resolved = resolveTheme(props.theme)
+  return themeToCSSVars(resolved)
+})
+
 // Whether the last update came from inside (block edit) vs outside (parent set).
 let updatingInternally = false
 
@@ -470,7 +477,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
 </script>
 
 <template>
-  <div ref="editorShellRef" class="editor-shell" @keydown="onShellKeydown">
+  <div ref="editorShellRef" class="editor-shell" :style="themeCSSVars" @keydown="onShellKeydown">
     <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
       <slot
         name="toolbar"

+ 7 - 0
packages/editor/src/index.ts

@@ -31,6 +31,13 @@ export {
   createOperationHistory,
   setTimeSource,
 } from "@/lib/operation-history"
+export type {
+  EditorTheme,
+  ThemeColors,
+  ThemeInput,
+  ThemeSyntax,
+} from "@/lib/theme"
+export { resolveTheme, themePresets, themeToCSSVars } from "@/lib/theme"
 export { Block, Editor, EditorToolbar }
 
 export default {

+ 106 - 0
packages/editor/src/lib/__tests__/theme.test.ts

@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from "vitest"
+import { resolveTheme, themePresets, themeToCSSVars } from "@/lib/theme"
+
+describe("resolveTheme", () => {
+  it("returns default for undefined", () => {
+    const t = resolveTheme(undefined)
+    expect(t.name).toBe("default")
+  })
+
+  it("returns default for 'default'", () => {
+    const t = resolveTheme("default")
+    expect(t.name).toBe("default")
+  })
+
+  it("returns kanagawa preset by name", () => {
+    const t = resolveTheme("kanagawa")
+    expect(t.name).toBe("kanagawa")
+    expect(t.colors?.primary).toBe("#7fb4ca")
+  })
+
+  it("returns unknown preset with warning", () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
+    const t = resolveTheme("nonexistent")
+    expect(t.name).toBe("default")
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining("nonexistent"))
+    warn.mockRestore()
+  })
+
+  it("passes through config object", () => {
+    const config = { name: "custom", colors: { primary: "#ff0" } }
+    const t = resolveTheme(config)
+    expect(t).toBe(config)
+  })
+})
+
+describe("themeToCSSVars", () => {
+  it("returns empty for theme without colors", () => {
+    const vars = themeToCSSVars({ name: "minimal" })
+    expect(vars).toEqual({})
+  })
+
+  it("maps colors to --ui-* vars", () => {
+    const vars = themeToCSSVars({
+      name: "test",
+      colors: {
+        primary: "#111",
+        success: "#222",
+        error: "#333",
+        text: { main: "#aaa", muted: "#bbb", highlight: "#ccc" },
+        background: { elevated: "#000" },
+        border: "#444",
+      },
+    })
+    expect(vars).toEqual({
+      "--ui-primary": "#111",
+      "--ui-success": "#222",
+      "--ui-error": "#333",
+      "--ui-text-main": "#aaa",
+      "--ui-text-muted": "#bbb",
+      "--ui-text-highlight": "#ccc",
+      "--ui-bg-elevated": "#000",
+      "--ui-border": "#444",
+    })
+  })
+
+  it("maps syntax colors to --code-* vars", () => {
+    const vars = themeToCSSVars({
+      name: "code-test",
+      colors: {},
+      syntax: {
+        keyword: "#a",
+        string: "#b",
+        comment: "#c",
+        number: "#d",
+        function: "#e",
+        type: "#f",
+      },
+    })
+    expect(vars).toMatchInlineSnapshot(`
+      {
+        "--code-comment": "#c",
+        "--code-function": "#e",
+        "--code-keyword": "#a",
+        "--code-number": "#d",
+        "--code-string": "#b",
+        "--code-type": "#f",
+      }
+    `)
+  })
+})
+
+describe("themePresets", () => {
+  it("has default and kanagawa", () => {
+    expect(themePresets.default).toBeDefined()
+    expect(themePresets.kanagawa).toBeDefined()
+  })
+
+  it("kanagawa has all major sections", () => {
+    const k = themePresets.kanagawa
+    expect(k?.colors?.primary).toBeTruthy()
+    expect(k?.colors?.text?.main).toBeTruthy()
+    expect(k?.colors?.background?.elevated).toBeTruthy()
+    expect(k?.syntax?.keyword).toBeTruthy()
+    expect(k?.syntax?.string).toBeTruthy()
+  })
+})

+ 189 - 0
packages/editor/src/lib/theme.ts

@@ -0,0 +1,189 @@
+/**
+ * Theme system for the editor.
+ *
+ * Themes are CSS variable overrides scoped to the editor shell element.
+ * A theme can be a preset name (string) or a config object with color
+ * and syntax highlighting overrides.
+ *
+ * Usage:
+ *   <Editor theme="kanagawa" />
+ *   <Editor :theme="{ colors: { primary: '#3b82f6' } }" />
+ */
+
+// ── Types ──────────────────────────────────────────────────────────
+
+export interface ThemeColors {
+  primary?: string
+  success?: string
+  error?: string
+  neutral?: string
+
+  text?: {
+    main?: string
+    muted?: string
+    highlight?: string
+  }
+
+  background?: {
+    elevated?: string
+  }
+
+  border?: string
+}
+
+export interface ThemeSyntax {
+  keyword?: string
+  string?: string
+  string2?: string
+  comment?: string
+  number?: string
+  atom?: string
+  bool?: string
+  null?: string
+  type?: string
+  typeName?: string
+  className?: string
+  function?: string
+  definition?: string
+  variableName?: string
+  propertyName?: string
+  attributeName?: string
+  operator?: string
+  punctuation?: string
+  tag?: string
+  meta?: string
+}
+
+export interface EditorTheme {
+  name?: string
+  colors?: ThemeColors
+  syntax?: ThemeSyntax
+}
+
+export type ThemeInput = string | EditorTheme
+
+// ── Default theme (uses @nuxt/ui variables as-is) ──────────────────
+
+const defaultTheme: EditorTheme = {
+  name: "default",
+}
+
+// ── Built-in presets ───────────────────────────────────────────────
+
+const kanagawa: EditorTheme = {
+  name: "kanagawa",
+  colors: {
+    primary: "#7fb4ca",
+    success: "#98bb6c",
+    error: "#e46876",
+    neutral: "#727169",
+    text: {
+      main: "#dcd7ba",
+      muted: "#727169",
+      highlight: "#e6c384",
+    },
+    background: {
+      elevated: "#1f1f28",
+    },
+    border: "#363646",
+  },
+  syntax: {
+    keyword: "#e6c384",
+    string: "#98bb6c",
+    string2: "#98bb6c",
+    comment: "#727169",
+    number: "#dca561",
+    atom: "#e46876",
+    bool: "#e46876",
+    null: "#e46876",
+    type: "#7e9cd8",
+    typeName: "#7e9cd8",
+    className: "#7e9cd8",
+    function: "#7fb4ca",
+    definition: "#7fb4ca",
+    variableName: "#dcd7ba",
+    propertyName: "#dcd7ba",
+    attributeName: "#7fb4ca",
+    operator: "#dcd7ba",
+    punctuation: "#dcd7ba",
+    tag: "#7fb4ca",
+    meta: "#e6c384",
+  },
+}
+
+export const themePresets: Record<string, EditorTheme | undefined> = {
+  default: defaultTheme,
+  kanagawa,
+}
+
+// ── Resolution ─────────────────────────────────────────────────────
+
+export function resolveTheme(input: ThemeInput | undefined): EditorTheme {
+  if (!input || input === "default") return defaultTheme
+
+  if (typeof input === "string") {
+    const preset = themePresets[input]
+    if (preset) return preset
+    console.warn(
+      `[editor] unknown theme preset "${input}", falling back to default`,
+    )
+    return defaultTheme
+  }
+
+  return input
+}
+
+// ── CSS variable mapping ───────────────────────────────────────────
+
+/**
+ * Map a resolved theme to CSS variable overrides scoped to the editor
+ * shell element. Variables using `--ui-*` prefixes cascade into all
+ * child components (Block, InsertionZone, CodeBlockView) overriding
+ * the page-level @nuxt/ui values. Syntax variables override the
+ * `--code-*` values used by CM6 syntax highlighting.
+ *
+ * Because these are set inline on the editor shell, they do NOT leak
+ * to sibling or ancestor elements outside the editor.
+ */
+export function themeToCSSVars(theme: EditorTheme): Record<string, string> {
+  const vars: Record<string, string> = {}
+  const c = theme.colors
+  if (!c) return vars
+
+  // Map to --ui-* variables (overrides @nuxt/ui at editor scope)
+  if (c.primary) vars["--ui-primary"] = c.primary
+  if (c.success) vars["--ui-success"] = c.success
+  if (c.error) vars["--ui-error"] = c.error
+  if (c.text?.main) vars["--ui-text-main"] = c.text.main
+  if (c.text?.muted) vars["--ui-text-muted"] = c.text.muted
+  if (c.text?.highlight) vars["--ui-text-highlight"] = c.text.highlight
+  if (c.background?.elevated) vars["--ui-bg-elevated"] = c.background.elevated
+  if (c.border) vars["--ui-border"] = c.border
+
+  // Map to --code-* variables (overrides syntax highlighting at editor scope)
+  const s = theme.syntax
+  if (s) {
+    if (s.keyword) vars["--code-keyword"] = s.keyword
+    if (s.string) vars["--code-string"] = s.string
+    if (s.string2) vars["--code-string2"] = s.string2
+    if (s.comment) vars["--code-comment"] = s.comment
+    if (s.number) vars["--code-number"] = s.number
+    if (s.atom) vars["--code-atom"] = s.atom
+    if (s.bool) vars["--code-bool"] = s.bool
+    if (s.null) vars["--code-null"] = s.null
+    if (s.type) vars["--code-type"] = s.type
+    if (s.typeName) vars["--code-typeName"] = s.typeName
+    if (s.className) vars["--code-className"] = s.className
+    if (s.function) vars["--code-function"] = s.function
+    if (s.definition) vars["--code-definition"] = s.definition
+    if (s.variableName) vars["--code-variableName"] = s.variableName
+    if (s.propertyName) vars["--code-propertyName"] = s.propertyName
+    if (s.attributeName) vars["--code-attributeName"] = s.attributeName
+    if (s.operator) vars["--code-operator"] = s.operator
+    if (s.punctuation) vars["--code-punctuation"] = s.punctuation
+    if (s.tag) vars["--code-tag"] = s.tag
+    if (s.meta) vars["--code-meta"] = s.meta
+  }
+
+  return vars
+}