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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user