feat(spa): tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6)

Adds a runtime A/B switcher for visual tweaks: 5 accent palettes
(violet/indigo/teal/rose/amber from the prototype's tweaks-panel.jsx),
density compact/comfortable, and a sidebar collapse toggle wired to the
existing uiStore.sidebarCollapsed flag. Overrides apply via inline CSS
custom properties on <html>, so changes paint immediately and survive
theme swaps.

- src/manager/stores/tweaksStore.ts: full 50-900 palette stops per accent,
  setAccent / setDensity / togglePanel / closePanel / reset / initialize.
  Persists to localStorage under wp-tweaks-v1; new instances rehydrate
  on construction so reload keeps the user's choice.
- src/manager/components/TweaksPanel.vue: slide-in right-anchored panel
  (dialog role, backdrop click closes, Reset to defaults footer button).
  Reuses ui/Button + ui/Toggle. PrimeIcons only.
- src/manager/layout/AppTopbar.vue: pi-sliders-h wrench button next to
  the theme toggle, calls tweaks.togglePanel.
- src/manager/layout/AppLayout.vue: mounts <TweaksPanel /> alongside
  <ToastHost />.
- src/manager/main.ts: tweaksStore.initialize() runs after theme init so
  persisted overrides land before the first paint.

Tests: +19 (11 store + 8 component) — palette write to <html>, density
height swap, panel toggle, reset clears overrides + storage, persistence
round-trip across fresh store instances.

All 357 tests pass; bundle size budget unchanged.
This commit is contained in:
DumiFlex
2026-04-27 20:04:46 +03:00
parent e4ab2186e3
commit 975581e111
8 changed files with 757 additions and 2 deletions
+96
View File
@@ -0,0 +1,96 @@
import { mount } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import TweaksPanel from "../components/TweaksPanel.vue";
import { useTweaksStore, ACCENT_PALETTES } from "../stores/tweaksStore";
import { useUiStore } from "../stores/uiStore";
beforeEach(() => {
setActivePinia(createPinia());
document.documentElement.style.cssText = "";
document.documentElement.removeAttribute("data-accent");
localStorage.clear();
});
describe("TweaksPanel.vue", () => {
it("renders 5 accent swatches", () => {
const wrap = mount(TweaksPanel);
const swatches = wrap.findAll('[data-test^="tweaks-accent-"]');
expect(swatches).toHaveLength(5);
});
it("clicking the violet swatch calls setAccent('violet') and writes the palette", async () => {
const wrap = mount(TweaksPanel);
const tweaks = useTweaksStore();
// Start from a non-default accent so the click materially changes things.
tweaks.setAccent("amber");
await wrap.find('[data-test="tweaks-accent-violet"]').trigger("click");
expect(tweaks.accent).toBe("violet");
expect(document.documentElement.style.getPropertyValue("--wp-accent-500")).toBe(
ACCENT_PALETTES.violet["--wp-accent-500"],
);
});
it("each swatch updates the store accent", async () => {
const wrap = mount(TweaksPanel);
const tweaks = useTweaksStore();
for (const name of ["indigo", "teal", "rose", "amber"] as const) {
await wrap.find(`[data-test="tweaks-accent-${name}"]`).trigger("click");
expect(tweaks.accent).toBe(name);
}
});
it("density radio toggles setDensity", async () => {
const wrap = mount(TweaksPanel);
const tweaks = useTweaksStore();
expect(tweaks.density).toBe("comfortable");
await wrap.find('[data-test="tweaks-density-compact"]').trigger("change");
expect(tweaks.density).toBe("compact");
expect(document.documentElement.style.getPropertyValue("--wp-input-h")).toBe("34px");
await wrap.find('[data-test="tweaks-density-comfortable"]').trigger("change");
expect(tweaks.density).toBe("comfortable");
expect(document.documentElement.style.getPropertyValue("--wp-input-h")).toBe("38px");
});
it("close button calls closePanel", async () => {
const wrap = mount(TweaksPanel);
const tweaks = useTweaksStore();
tweaks.togglePanel();
expect(tweaks.panelOpen).toBe(true);
await wrap.find('[data-test="tweaks-close"]').trigger("click");
expect(tweaks.panelOpen).toBe(false);
});
it("reset button restores defaults", async () => {
const wrap = mount(TweaksPanel);
const tweaks = useTweaksStore();
tweaks.setAccent("rose");
tweaks.setDensity("compact");
await wrap.find('[data-test="tweaks-reset"]').trigger("click");
expect(tweaks.accent).toBe("violet");
expect(tweaks.density).toBe("comfortable");
});
it("sidebar toggle drives ui.sidebarCollapsed", async () => {
const wrap = mount(TweaksPanel);
const ui = useUiStore();
const tweaks = useTweaksStore();
expect(ui.sidebarCollapsed).toBe(false);
// Click the underlying switch button rendered by Toggle.
const switchBtn = wrap.find('[data-test="tweaks-sidebar-toggle"] .wp-toggle');
await switchBtn.trigger("click");
expect(ui.sidebarCollapsed).toBe(true);
expect(tweaks.sidebarMode).toBe("collapsed");
});
it("backdrop only renders when panel is open", async () => {
const wrap = mount(TweaksPanel);
const tweaks = useTweaksStore();
expect(wrap.find('[data-test="tweaks-backdrop"]').exists()).toBe(false);
tweaks.togglePanel();
await wrap.vm.$nextTick();
expect(wrap.find('[data-test="tweaks-backdrop"]').exists()).toBe(true);
});
});
+148
View File
@@ -0,0 +1,148 @@
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import {
useTweaksStore,
ACCENT_PALETTES,
} from "../stores/tweaksStore";
beforeEach(() => {
setActivePinia(createPinia());
// Wipe inline overrides + data-attr from prior tests so the document is
// clean. Inline styles persist across tests in jsdom otherwise.
const root = document.documentElement;
root.style.cssText = "";
root.removeAttribute("data-accent");
localStorage.clear();
});
describe("tweaksStore — accent palette", () => {
it("defaults to violet when no preference is stored", () => {
const tweaks = useTweaksStore();
expect(tweaks.accent).toBe("violet");
});
it("setAccent('indigo') writes the indigo palette to <html>", () => {
const tweaks = useTweaksStore();
tweaks.setAccent("indigo");
const root = document.documentElement;
expect(tweaks.accent).toBe("indigo");
expect(root.style.getPropertyValue("--wp-accent-500")).toBe(
ACCENT_PALETTES.indigo["--wp-accent-500"],
);
expect(root.style.getPropertyValue("--wp-accent-50")).toBe(
ACCENT_PALETTES.indigo["--wp-accent-50"],
);
expect(root.getAttribute("data-accent")).toBe("indigo");
});
it("setAccent overwrites prior palette stops", () => {
const tweaks = useTweaksStore();
tweaks.setAccent("violet");
tweaks.setAccent("rose");
const v500 = document.documentElement.style.getPropertyValue("--wp-accent-500");
expect(v500).toBe(ACCENT_PALETTES.rose["--wp-accent-500"]);
});
});
describe("tweaksStore — density", () => {
it("setDensity('compact') sets --wp-input-h to 34px", () => {
const tweaks = useTweaksStore();
tweaks.setDensity("compact");
expect(tweaks.density).toBe("compact");
expect(document.documentElement.style.getPropertyValue("--wp-input-h")).toBe("34px");
expect(document.documentElement.style.getPropertyValue("--wp-btn-h")).toBe("34px");
});
it("setDensity('comfortable') sets --wp-input-h to 38px", () => {
const tweaks = useTweaksStore();
tweaks.setDensity("comfortable");
expect(document.documentElement.style.getPropertyValue("--wp-input-h")).toBe("38px");
});
});
describe("tweaksStore — panel state", () => {
it("togglePanel flips panelOpen", () => {
const tweaks = useTweaksStore();
expect(tweaks.panelOpen).toBe(false);
tweaks.togglePanel();
expect(tweaks.panelOpen).toBe(true);
tweaks.togglePanel();
expect(tweaks.panelOpen).toBe(false);
});
it("closePanel forces panelOpen=false", () => {
const tweaks = useTweaksStore();
tweaks.togglePanel();
expect(tweaks.panelOpen).toBe(true);
tweaks.closePanel();
expect(tweaks.panelOpen).toBe(false);
});
});
describe("tweaksStore — reset", () => {
it("clears inline CSS-var overrides and localStorage entry", async () => {
const tweaks = useTweaksStore();
tweaks.setAccent("amber");
tweaks.setDensity("compact");
// Wait a microtask for the persist watcher to flush.
await Promise.resolve();
expect(localStorage.getItem("wp-tweaks-v1")).not.toBeNull();
tweaks.reset();
await Promise.resolve();
expect(tweaks.accent).toBe("violet");
expect(tweaks.density).toBe("comfortable");
expect(document.documentElement.style.getPropertyValue("--wp-accent-500")).toBe("");
expect(document.documentElement.style.getPropertyValue("--wp-input-h")).toBe("");
// Watcher writes the post-reset state, so the key may exist with the
// default snapshot; only assert it doesn't carry the prior amber accent.
const raw = localStorage.getItem("wp-tweaks-v1");
if (raw) {
const parsed = JSON.parse(raw);
expect(parsed.accent).toBe("violet");
expect(parsed.density).toBe("comfortable");
}
});
});
describe("tweaksStore — persistence", () => {
it("setAccent writes to localStorage under wp-tweaks-v1", async () => {
const tweaks = useTweaksStore();
tweaks.setAccent("teal");
// Allow the watcher to flush.
await Promise.resolve();
const raw = localStorage.getItem("wp-tweaks-v1");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.accent).toBe("teal");
});
it("a fresh store reads back persisted accent + density", () => {
localStorage.setItem(
"wp-tweaks-v1",
JSON.stringify({ accent: "rose", density: "compact", sidebarMode: "collapsed" }),
);
setActivePinia(createPinia());
const tweaks = useTweaksStore();
expect(tweaks.accent).toBe("rose");
expect(tweaks.density).toBe("compact");
expect(tweaks.sidebarMode).toBe("collapsed");
});
it("initialize() applies persisted values to <html>", () => {
localStorage.setItem(
"wp-tweaks-v1",
JSON.stringify({ accent: "amber", density: "compact" }),
);
setActivePinia(createPinia());
const tweaks = useTweaksStore();
tweaks.initialize();
const root = document.documentElement;
expect(root.style.getPropertyValue("--wp-accent-500")).toBe(
ACCENT_PALETTES.amber["--wp-accent-500"],
);
expect(root.style.getPropertyValue("--wp-input-h")).toBe("34px");
});
});
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed } from "vue";
import Button from "./ui/Button.vue";
import Toggle from "./ui/Toggle.vue";
import Icon from "./ui/Icon.vue";
import {
ACCENT_OPTIONS,
DENSITY_OPTIONS,
SWATCH_PREVIEW,
useTweaksStore,
type AccentName,
type Density,
} from "../stores/tweaksStore";
import { useUiStore } from "../stores/uiStore";
const tweaks = useTweaksStore();
const ui = useUiStore();
const sidebarCollapsed = computed<boolean>({
get: () => ui.sidebarCollapsed,
set: (v) => {
if (ui.sidebarCollapsed !== v) ui.toggleSidebar();
tweaks.setSidebarMode(v ? "collapsed" : "expanded");
},
});
function onAccent(name: AccentName) {
tweaks.setAccent(name);
}
function onDensity(d: Density) {
tweaks.setDensity(d);
}
</script>
<template>
<aside
class="wp-tweaks"
:class="{ 'wp-tweaks--open': tweaks.panelOpen }"
:aria-hidden="!tweaks.panelOpen"
role="dialog"
aria-label="Tweaks"
data-test="tweaks-panel"
>
<header class="wp-tweaks__header">
<h2>Tweaks</h2>
<button
type="button"
class="wp-tweaks__close"
aria-label="Close tweaks"
data-test="tweaks-close"
@click="tweaks.closePanel"
>
<Icon name="pi-times" />
</button>
</header>
<div class="wp-tweaks__body">
<section class="wp-tweaks__section">
<h3 class="wp-tweaks__section-title">Accent</h3>
<div class="wp-tweaks__swatches" role="radiogroup" aria-label="Accent palette">
<button
v-for="name in ACCENT_OPTIONS"
:key="name"
type="button"
role="radio"
:aria-checked="tweaks.accent === name"
class="wp-tweaks__swatch"
:class="{ 'is-active': tweaks.accent === name }"
:style="{ background: SWATCH_PREVIEW[name] }"
:aria-label="`Accent: ${name}`"
:title="name"
:data-test="`tweaks-accent-${name}`"
@click="onAccent(name)"
/>
</div>
</section>
<section class="wp-tweaks__section">
<h3 class="wp-tweaks__section-title">Density</h3>
<div class="wp-tweaks__radio" role="radiogroup" aria-label="Density">
<label
v-for="d in DENSITY_OPTIONS"
:key="d"
class="wp-tweaks__radio-item"
:class="{ 'is-active': tweaks.density === d }"
>
<input
type="radio"
name="wp-tweaks-density"
:value="d"
:checked="tweaks.density === d"
:data-test="`tweaks-density-${d}`"
@change="onDensity(d)"
/>
<span>{{ d }}</span>
</label>
</div>
</section>
<section class="wp-tweaks__section">
<h3 class="wp-tweaks__section-title">Sidebar</h3>
<Toggle
v-model="sidebarCollapsed"
label="Collapse to icons"
aria-label="Collapse sidebar to icons"
data-test="tweaks-sidebar-toggle"
/>
</section>
<footer class="wp-tweaks__footer">
<Button
variant="ghost"
icon="pi-refresh"
data-test="tweaks-reset"
@click="tweaks.reset"
>
Reset to defaults
</Button>
</footer>
</div>
</aside>
<div
v-if="tweaks.panelOpen"
class="wp-tweaks__backdrop"
data-test="tweaks-backdrop"
@click="tweaks.closePanel"
/>
</template>
<style scoped>
/* Slide-in panel anchored to the right edge of the viewport. The shell mirrors
the prototype's floating glass card but lives full-height for predictable
focus management; nothing in tokens.css owns these classes. */
.wp-tweaks {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
max-width: 90vw;
z-index: 60;
display: flex;
flex-direction: column;
background: var(--wp-bg-1);
border-left: 1px solid var(--wp-border);
box-shadow: var(--wp-shadow-lg);
transform: translateX(100%);
transition: transform 220ms cubic-bezier(0.3, 0.7, 0.4, 1);
pointer-events: none;
}
.wp-tweaks--open {
transform: translateX(0);
pointer-events: auto;
}
.wp-tweaks__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
}
.wp-tweaks__header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--wp-text);
}
.wp-tweaks__close {
appearance: none;
border: 0;
background: transparent;
color: var(--wp-text-muted);
width: 28px;
height: 28px;
border-radius: var(--wp-radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.wp-tweaks__close:hover {
background: var(--wp-bg-3);
color: var(--wp-text);
}
.wp-tweaks__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 18px;
}
.wp-tweaks__section { display: flex; flex-direction: column; gap: 8px; }
.wp-tweaks__section-title {
margin: 0;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--wp-text-dim);
}
.wp-tweaks__swatches { display: flex; gap: 8px; flex-wrap: wrap; }
.wp-tweaks__swatch {
appearance: none;
width: 32px;
height: 32px;
border-radius: 999px;
border: 2px solid var(--wp-border);
cursor: pointer;
padding: 0;
transition: border-color 120ms ease, transform 120ms ease;
}
.wp-tweaks__swatch:hover { transform: scale(1.05); }
.wp-tweaks__swatch.is-active {
border-color: var(--wp-text);
box-shadow: 0 0 0 2px var(--wp-bg-1), 0 0 0 4px var(--wp-accent-500);
}
.wp-tweaks__radio { display: flex; gap: 6px; padding: 2px; background: var(--wp-bg-3); border-radius: var(--wp-radius); }
.wp-tweaks__radio-item {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: var(--wp-radius-sm);
cursor: pointer;
font-size: 12px;
text-transform: capitalize;
color: var(--wp-text-muted);
user-select: none;
}
.wp-tweaks__radio-item input { position: absolute; opacity: 0; pointer-events: none; }
.wp-tweaks__radio-item.is-active { background: var(--wp-bg-1); color: var(--wp-text); box-shadow: var(--wp-shadow-sm); }
.wp-tweaks__footer { margin-top: auto; padding-top: 12px; border-top: 1px solid var(--wp-border); }
.wp-tweaks__backdrop {
position: fixed;
inset: 0;
z-index: 55;
background: rgba(0, 0, 0, 0.32);
/* Backdrop only renders when panel is open; no transition needed. */
}
</style>
+2
View File
@@ -3,6 +3,7 @@ import { RouterView } from "vue-router";
import AppTopbar from "./AppTopbar.vue";
import AppSidebar from "./AppSidebar.vue";
import ToastHost from "../components/ui/ToastHost.vue";
import TweaksPanel from "../components/TweaksPanel.vue";
import { useUiStore } from "../stores/uiStore";
const ui = useUiStore();
@@ -18,5 +19,6 @@ const ui = useUiStore();
</main>
</div>
<ToastHost />
<TweaksPanel />
</div>
</template>
+13
View File
@@ -5,11 +5,13 @@ import Button from "../components/ui/Button.vue";
import Icon from "../components/ui/Icon.vue";
import { useUiStore } from "../stores/uiStore";
import { useCommunityStore } from "../stores/communityStore";
import { useTweaksStore } from "../stores/tweaksStore";
const ui = useUiStore();
const router = useRouter();
const route = useRoute();
const community = useCommunityStore();
const tweaks = useTweaksStore();
const version = "1.4.0-dev";
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
@@ -117,6 +119,17 @@ onBeforeUnmount(() => {
<Icon :name="themeIcon" />
</button>
<button
type="button"
class="wp-topbar__icon-btn"
aria-label="Tweaks"
title="Tweaks"
data-test="topbar-tweaks"
@click="tweaks.togglePanel"
>
<Icon name="pi-sliders-h" />
</button>
<template v-if="onCommunity">
<div
v-if="community.currentUser"
+4
View File
@@ -7,6 +7,7 @@ import "./styles/tailwind.css";
import App from "./App.vue";
import router from "./router";
import { useUiStore } from "./stores/uiStore";
import { useTweaksStore } from "./stores/tweaksStore";
const app = createApp(App);
const pinia = createPinia();
@@ -16,4 +17,7 @@ app.use(router);
// Apply persisted theme before mount so the first paint is correct.
// `useUiStore` reads localStorage and listens to OS preference changes.
useUiStore(pinia).initializeTheme();
// Apply persisted runtime tweaks (accent palette, density) before mount so
// CSS-var overrides land before the first paint and we don't flash defaults.
useTweaksStore(pinia).initialize();
app.mount("#app");
+240
View File
@@ -0,0 +1,240 @@
import { defineStore } from "pinia";
import { ref, watch } from "vue";
/**
* Runtime tweaks store — accent palette, density, sidebar mode.
*
* Lets the user A/B different look-and-feel options without rebuilding.
* Overrides apply via inline CSS variables on `<html>` and persist to
* `localStorage` under `wp-tweaks-v1`.
*
* Mirror of the prototype's `tweaks-panel.jsx` host protocol — without the
* postMessage iframe dance, since we own the document directly.
*/
export type AccentName = "violet" | "indigo" | "teal" | "rose" | "amber";
export type Density = "compact" | "comfortable";
export type SidebarMode = "expanded" | "collapsed";
const STORAGE_KEY = "wp-tweaks-v1";
/** Accent palette presets — full 50→900 scale from prototype `tweaks-panel.jsx`.
* The prototype only persists the 300–700 stops (since the mid-range is what
* surfaces in tinted backgrounds, gradients, and focus rings) but we override
* the whole scale so cross-theme consistency holds. */
export const ACCENT_PALETTES: Record<AccentName, Record<string, string>> = {
violet: {
"--wp-accent-50": "#f5f3ff",
"--wp-accent-100": "#ede9fe",
"--wp-accent-200": "#ddd6fe",
"--wp-accent-300": "#c4b5fd",
"--wp-accent-400": "#a78bfa",
"--wp-accent-500": "#8b5cf6",
"--wp-accent-600": "#7c3aed",
"--wp-accent-700": "#6d28d9",
"--wp-accent-800": "#5b21b6",
"--wp-accent-900": "#4c1d95",
},
indigo: {
"--wp-accent-50": "#eef2ff",
"--wp-accent-100": "#e0e7ff",
"--wp-accent-200": "#c7d2fe",
"--wp-accent-300": "#a5b4fc",
"--wp-accent-400": "#818cf8",
"--wp-accent-500": "#6366f1",
"--wp-accent-600": "#4f46e5",
"--wp-accent-700": "#4338ca",
"--wp-accent-800": "#3730a3",
"--wp-accent-900": "#312e81",
},
teal: {
"--wp-accent-50": "#f0fdfa",
"--wp-accent-100": "#ccfbf1",
"--wp-accent-200": "#99f6e4",
"--wp-accent-300": "#5eead4",
"--wp-accent-400": "#2dd4bf",
"--wp-accent-500": "#14b8a6",
"--wp-accent-600": "#0d9488",
"--wp-accent-700": "#0f766e",
"--wp-accent-800": "#115e59",
"--wp-accent-900": "#134e4a",
},
rose: {
"--wp-accent-50": "#fff1f2",
"--wp-accent-100": "#ffe4e6",
"--wp-accent-200": "#fecdd3",
"--wp-accent-300": "#fda4af",
"--wp-accent-400": "#fb7185",
"--wp-accent-500": "#f43f5e",
"--wp-accent-600": "#e11d48",
"--wp-accent-700": "#be123c",
"--wp-accent-800": "#9f1239",
"--wp-accent-900": "#881337",
},
amber: {
"--wp-accent-50": "#fffbeb",
"--wp-accent-100": "#fef3c7",
"--wp-accent-200": "#fde68a",
"--wp-accent-300": "#fcd34d",
"--wp-accent-400": "#fbbf24",
"--wp-accent-500": "#f59e0b",
"--wp-accent-600": "#d97706",
"--wp-accent-700": "#b45309",
"--wp-accent-800": "#92400e",
"--wp-accent-900": "#78350f",
},
};
/** 500-stop hex used as the visible swatch color on the accent picker buttons. */
export const SWATCH_PREVIEW: Record<AccentName, string> = {
violet: ACCENT_PALETTES.violet["--wp-accent-500"],
indigo: ACCENT_PALETTES.indigo["--wp-accent-500"],
teal: ACCENT_PALETTES.teal["--wp-accent-500"],
rose: ACCENT_PALETTES.rose["--wp-accent-500"],
amber: ACCENT_PALETTES.amber["--wp-accent-500"],
};
export const ACCENT_OPTIONS: AccentName[] = ["violet", "indigo", "teal", "rose", "amber"];
export const DENSITY_OPTIONS: Density[] = ["compact", "comfortable"];
const DENSITY_HEIGHT: Record<Density, string> = {
compact: "34px",
comfortable: "38px",
};
interface PersistedShape {
accent?: AccentName;
density?: Density;
sidebarMode?: SidebarMode;
}
function readStored(): PersistedShape {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as PersistedShape;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeStored(value: PersistedShape) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
/* localStorage unavailable */
}
}
function hasDocument(): boolean {
return typeof document !== "undefined" && !!document.documentElement;
}
export const useTweaksStore = defineStore("tweaks", () => {
const stored = readStored();
const accent = ref<AccentName>(stored.accent ?? "violet");
const density = ref<Density>(stored.density ?? "comfortable");
const sidebarMode = ref<SidebarMode>(stored.sidebarMode ?? "expanded");
const panelOpen = ref<boolean>(false);
function applyAccent(name: AccentName) {
if (!hasDocument()) return;
const palette = ACCENT_PALETTES[name];
const root = document.documentElement;
for (const [key, value] of Object.entries(palette)) {
root.style.setProperty(key, value);
}
// Mirror the prototype's `data-accent="<name>"` attribute so any rules in
// tokens.css that key off it still resolve. Inline overrides win regardless.
root.setAttribute("data-accent", name);
}
function clearAccent() {
if (!hasDocument()) return;
const root = document.documentElement;
for (const key of Object.keys(ACCENT_PALETTES.violet)) {
root.style.removeProperty(key);
}
root.removeAttribute("data-accent");
}
function applyDensity(d: Density) {
if (!hasDocument()) return;
const root = document.documentElement;
const h = DENSITY_HEIGHT[d];
root.style.setProperty("--wp-input-h", h);
root.style.setProperty("--wp-btn-h", h);
}
function clearDensity() {
if (!hasDocument()) return;
const root = document.documentElement;
root.style.removeProperty("--wp-input-h");
root.style.removeProperty("--wp-btn-h");
}
function setAccent(name: AccentName) {
accent.value = name;
applyAccent(name);
}
function setDensity(d: Density) {
density.value = d;
applyDensity(d);
}
function setSidebarMode(mode: SidebarMode) {
sidebarMode.value = mode;
}
function togglePanel() {
panelOpen.value = !panelOpen.value;
}
function closePanel() {
panelOpen.value = false;
}
function reset() {
accent.value = "violet";
density.value = "comfortable";
sidebarMode.value = "expanded";
clearAccent();
clearDensity();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
}
/** Apply persisted overrides on app boot. Idempotent. */
function initialize() {
applyAccent(accent.value);
applyDensity(density.value);
}
// Persist on every change to a tracked field.
watch(
[accent, density, sidebarMode],
([a, d, s]) => {
writeStored({ accent: a, density: d, sidebarMode: s });
},
);
return {
accent,
density,
sidebarMode,
panelOpen,
setAccent,
setDensity,
setSidebarMode,
togglePanel,
closePanel,
reset,
initialize,
};
});
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-D-gQtvLK.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DyPxejbp.css">
<script type="module" crossorigin src="/wp/assets/manager-D9WXzTIi.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CxoRqKAY.css">
</head>
<body>
<div id="app"></div>