Skip to content

fix(theme): keep colors reactive after switching theme#208

Closed
J-Sek wants to merge 1 commit into
masterfrom
fix/theme-upsert-not-reactive
Closed

fix(theme): keep colors reactive after switching theme#208
J-Sek wants to merge 1 commit into
masterfrom
fix/theme-upsert-not-reactive

Conversation

@J-Sek
Copy link
Copy Markdown
Contributor

@J-Sek J-Sek commented Apr 23, 2026

Reproduction:

  1. run the dev/src/Playground.vue
  2. change primary color (works fine)
  3. switch theme
  4. change primary color for the different theme (ignores input)

Fixed by using Object.assign(...). Cuts a bit deep, not sure why the issue did not surface earlier.

Playground

<script setup lang="ts">
  import { computed } from 'vue'
  import { useTheme } from '@vuetify/v0'

  const theme = useTheme()

  const currentColors = computed(() => {
    const id = theme.selectedId.value
    return id != null ? (theme.colors.value[String(id)] ?? {}) : {}
  })

  function applyPrimary(hex: string) {
    const id = theme.selectedId.value
    if (!id) return
    theme.upsert(id, { value: { ...currentColors.value, primary: hex } })
  }

  function updatePrimary(e: Event) {
    applyPrimary((e.target as HTMLInputElement).value)
  }

  function hexToHsl(hex: string): [number, number, number] {
    const r = parseInt(hex.slice(1, 3), 16) / 255
    const g = parseInt(hex.slice(3, 5), 16) / 255
    const b = parseInt(hex.slice(5, 7), 16) / 255
    const max = Math.max(r, g, b), min = Math.min(r, g, b)
    const l = (max + min) / 2
    if (max === min) return [0, 0, l * 100]
    const d = max - min
    const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    const h = max === r ? ((g - b) / d + (g < b ? 6 : 0)) / 6
            : max === g ? ((b - r) / d + 2) / 6
            :             ((r - g) / d + 4) / 6
    return [h * 360, s * 100, l * 100]
  }

  function hslToHex(h: number, s: number, l: number): string {
    h /= 360; s /= 100; l /= 100
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s
    const p = 2 * l - q
    const hue = (t: number) => {
      if (t < 0) t += 1
      if (t > 1) t -= 1
      if (t < 1 / 6) return p + (q - p) * 6 * t
      if (t < 1 / 2) return q
      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
      return p
    }
    return s === 0
      ? '#' + Math.round(l * 255).toString(16).padStart(2, '0').repeat(3)
      : '#' + [h + 1 / 3, h, h - 1 / 3].map(t => Math.round(hue(t) * 255).toString(16).padStart(2, '0')).join('')
  }

  const primaryVariants = computed(() => {
    const hex = currentColors.value['primary']
    if (!hex?.startsWith('#')) return null
    const [h, s, l] = hexToHsl(hex)
    return [
      hslToHex(h, s, Math.max(0, l - 10)),
      hslToHex(h, s, Math.min(100, l + 10)),
    ]
  })

  // Class names written as literals so UnoCSS picks them up statically
  const swatches = [
    { key: 'secondary',       cls: 'bg-secondary text-on-secondary' },
    { key: 'surface',         cls: 'bg-surface text-on-surface' },
    { key: 'surface-variant', cls: 'bg-surface-variant text-on-surface-variant' },
    { key: 'background',      cls: 'bg-background text-on-background' },
    { key: 'error',           cls: 'bg-error text-on-error' },
  ]
</script>

<template>
  <div class="min-h-screen bg-background text-on-background flex flex-col items-center justify-center gap-8 p-8">
    <!-- Theme switcher -->
    <div class="flex gap-2 flex-wrap justify-center">
      <button
        v-for="name in theme.keys()"
        :key="String(name)"
        :data-active="theme.selectedId.value === name"
        class="
          px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-all
          bg-primary text-on-primary
          data-[active=true]:ring-2 data-[active=true]:ring-on-background
        "
        :style="{ '--v0-primary': theme.colors.value[name].primary }"
        @click="theme.select(name)"
      >
        {{ name }}
      </button>
    </div>

    <!-- Color swatches -->
    <div class="flex gap-6 flex-wrap justify-center items-start">
      <div class="flex flex-col items-center gap-2">
        <div class="flex gap-1 items-center">
          <label class="relative w-16 h-16 rounded-full [corner-shape:squircle] shadow-md bg-primary block overflow-hidden cursor-pointer flex items-center justify-center">
            <input
              type="color"
              :value="currentColors['primary']"
              class="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
              @input="updatePrimary"
            />
            <span class="text-on-primary text-2xl pointer-events-none select-none absolute inset-0 grid place-items-center">✎</span>
          </label>
          <div v-if="primaryVariants" class="flex flex-col gap-1">
            <button
              v-for="(hex, i) in primaryVariants"
              :key="i"
              class="w-7 h-7 rounded-full [corner-shape:squircle] cursor-pointer text-contrast flex items-center justify-center text-[11px] font-bold"
              :style="{ background: hex, '--c': hex }"
              @click="applyPrimary(hex)"
            >{{ i === 0 ? '↑' : '↓' }}</button>
          </div>
        </div>
        <span class="text-xs opacity-60">primary</span>
      </div>

      <!-- rest — read-only -->
      <div
        v-for="s in swatches"
        :key="s.key"
        class="flex flex-col items-center gap-2"
      >
        <div class="w-16 h-16 rounded-full [corner-shape:squircle] shadow-md border border-gray-400/20" :class="s.cls" />
        <span class="text-xs opacity-60">{{ s.key }}</span>
      </div>
    </div>

    <!-- State summary -->
    <p class="text-sm opacity-50">
      theme: <strong>{{ theme.selectedId.value }}</strong> · dark: {{ theme.isDark.value }}
    </p>
  </div>
</template>

<style>
  @import 'https://fonts.bunny.net/css?family=recursive:300,400,500,700';
  body * {
    font-family: 'Recursive', sans-serif !important;
    letter-spacing: 0.02em;
  }

  .text-contrast { color: oklch(from var(--c) round(1.21 - L) 0 0) }
  .text-on-primary { color: oklch(from var(--v0-primary) round(1.21 - L) 0 0) !important }
  .bg-background { --v0-on-background: oklch(from var(--v0-background) round(1.21 - L) 0 0) }
</style>

@J-Sek J-Sek self-assigned this Apr 23, 2026
@J-Sek J-Sek added bug Something isn't working E: useTheme E: createRegistry Composable labels Apr 23, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

commit: b2e8029

johnleider added a commit that referenced this pull request Apr 23, 2026
When `reactive: true`, `values()`/`keys()`/`entries()` now iterate the
underlying collection on every call instead of serving a cached array.
The cache was silently dropping Vue's iteration dependency on warm
re-runs, so a computed that iterated a reactive registry would lose its
Map-iteration dep after any re-run that hit the cache — subsequent
mutations then failed to propagate.

Refs #208
johnleider added a commit that referenced this pull request Apr 23, 2026
…-run

Three regression tests for values()/keys()/entries() that would have
caught #208. Each wires a computed that first runs through a
non-iteration dep (ref bump), then mutates the registry and asserts
the computed picks up the change.

Verified to fail without the cache skip in the reactive path.
johnleider added a commit that referenced this pull request Apr 23, 2026
…e fix

The tip on create-registry.md steered users away from `reactive: true`
toward `useProxyRegistry` because `values()` caching broke iteration
deps. That bug is fixed (see PR #209) — rewrite the tip to recommend
`reactive: true` for template/computed iteration and position
`useProxyRegistry` as the tool for event-driven snapshots, deep
tracking, or cases where wrapping tickets isn't desired.

Qualify the \"What's NOT Reactive\" table in the reactivity guide with
a pointer to the reactive-option pattern. Simplify Pattern 1 so the
example calls the public `registry.values()` / `registry.size` rather
than poking at `registry.collection` directly, now that those are
reactive too.

Refs #208 #209
johnleider added a commit that referenced this pull request Apr 23, 2026
- `.claude/rules/composables.md`: new section "Plugins and Reactive
  Defaults" covering the primitive/plugin tiers, when to pass
  `reactive: true` to an internal registry, the two-registry
  architecture, and how `reactive: true` and `useProxyRegistry`
  complement each other.
- `PHILOSOPHY.md §4.4`: rewrite from "the reactive: true footgun" to
  "Registry reactivity". The footgun described (values() cache dropping
  Vue dep tracking) was closed in #209 — that section was actively
  teaching the wrong mental model through #210, #211, and #212. New
  text positions `reactive: true` and `useProxyRegistry` as two valid
  complementary options.
- `PHILOSOPHY.md §6.7`: revise the closing warning that said "do not
  substitute reactive: true on the registry itself" — same reason.

Refs #208 #209 #210 #211 #212
johnleider added a commit that referenced this pull request Apr 23, 2026
#213)

- `.claude/rules/composables.md`: new section "Plugins and Reactive
  Defaults" covering the primitive/plugin tiers, when to pass
  `reactive: true` to an internal registry, the two-registry
  architecture, and how `reactive: true` and `useProxyRegistry`
  complement each other.
- `PHILOSOPHY.md §4.4`: rewrite from "the reactive: true footgun" to
  "Registry reactivity". The footgun described (values() cache dropping
  Vue dep tracking) was closed in #209 — that section was actively
  teaching the wrong mental model through #210, #211, and #212. New
  text positions `reactive: true` and `useProxyRegistry` as two valid
  complementary options.
- `PHILOSOPHY.md §6.7`: revise the closing warning that said "do not
  substitute reactive: true on the registry itself" — same reason.

Refs #208 #209 #210 #211 #212
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working E: createRegistry Composable E: useTheme

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant