diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d146f14..8e23afb3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.25", + "version": "0.8.26", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 20afc7c2..da8da609 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.24", + "version": "0.8.25", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 57e77b4b..95a4f61c 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.8.10", + "version": "0.8.11", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts index c5d2e42a..b226591e 100644 --- a/packages/styles/src/theme.ts +++ b/packages/styles/src/theme.ts @@ -98,6 +98,19 @@ export interface ThemeAccentConfig { default?: ThemePaletteName | false; /** Runtime-selectable accent names. Defaults to every built-in THEME_PALETTE. */ options?: ThemePaletteName[]; + /** Cookie scope used to persist the selected accent across related applications. */ + cookie?: ThemeCookieConfig; +} + +export interface ThemeCookieConfig { + /** Parent domain shared by related applications, for example `localhost` or `.example.com`. */ + domain?: string; + /** Cookie path. Defaults to `/`. */ + path?: string; + /** SameSite policy. Defaults to `Lax`. */ + sameSite?: "Strict" | "Lax" | "None"; + /** Force or disable Secure. When omitted it follows the current protocol. */ + secure?: boolean; } export interface ThemeConfig { @@ -117,6 +130,7 @@ export interface ResolvedTheme { themes: Record; defaultAccent?: ThemePaletteName; accentNames: ThemePaletteName[]; + accentCookie?: ThemeCookieConfig; } /** Cookies used by the SSR renderer and client runtime. */ @@ -494,6 +508,7 @@ export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme { themes, defaultAccent, accentNames, + accentCookie: config?.accent?.cookie, }; } @@ -622,6 +637,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { const accentNames = JSON.stringify(theme.accentNames); const defaultAccent = JSON.stringify(theme.defaultAccent ?? null); const cssPrefix = JSON.stringify(THEME_CSS_PREFIX); + const accentCookie = JSON.stringify(theme.accentCookie ?? {}); return `(function(){ var THEME_COOKIE=${JSON.stringify(THEME_COOKIE)}; @@ -631,6 +647,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { var DEFAULT_THEME=${JSON.stringify(theme.default)}; var DEFAULT_ACCENT=${defaultAccent}; var THEME_CSS_PREFIX=${cssPrefix}; + var ACCENT_COOKIE_OPTIONS=${accentCookie}; var MAX_AGE=31536000; var el=document.documentElement; @@ -646,15 +663,22 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { return null; } - function writeCookie(name,value){ - var secure=location.protocol==="https:"?";secure":""; - document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+ - ";path=/;max-age="+MAX_AGE+";samesite=lax"+secure; + function cookieOptions(options){ + options=options||{}; + var path=options.path||"/"; + var sameSite=options.sameSite||"Lax"; + var domain=options.domain?";domain="+options.domain:""; + var secure=options.secure===true||(options.secure!==false&&location.protocol==="https:")?";secure":""; + return ";path="+path+";samesite="+sameSite+domain+secure; } - function deleteCookie(name){ - var secure=location.protocol==="https:"?";secure":""; - document.cookie=encodeURIComponent(name)+"=;path=/;max-age=0;samesite=lax"+secure; + function writeCookie(name,value,options){ + document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+ + ";max-age="+MAX_AGE+cookieOptions(options); + } + + function deleteCookie(name,options){ + document.cookie=encodeURIComponent(name)+"=;max-age=0"+cookieOptions(options); } function syncStylesheet(themeName,accentName){ @@ -705,7 +729,8 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { function setAccent(name){ if(ACCENTS.indexOf(name)<0)return; el.setAttribute("data-accent",name); - writeCookie(ACCENT_COOKIE,name); + deleteCookie(ACCENT_COOKIE); + writeCookie(ACCENT_COOKIE,name,ACCENT_COOKIE_OPTIONS); syncStylesheet(getTheme(),name); syncAccentButtons(document); window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:name}})); @@ -714,6 +739,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { function clearAccent(){ el.removeAttribute("data-accent"); deleteCookie(ACCENT_COOKIE); + deleteCookie(ACCENT_COOKIE,ACCENT_COOKIE_OPTIONS); syncStylesheet(getTheme(),null); syncAccentButtons(document); window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:null}})); diff --git a/packages/styles/test/theme-accent.test.ts b/packages/styles/test/theme-accent.test.ts index 310942fc..d78b9a74 100644 --- a/packages/styles/test/theme-accent.test.ts +++ b/packages/styles/test/theme-accent.test.ts @@ -46,6 +46,19 @@ describe("theme accents", () => { expect(runtime).not.toContain("localStorage"); expect(runtime).not.toContain("style.setProperty"); }); + + test("runtime scopes accent cookies to a configured parent domain", () => { + const theme = resolveThemeConfig({ + palette: "rose", + accent: { cookie: { domain: "localhost", path: "/", sameSite: "Lax", secure: false } }, + }); + const runtime = renderThemeRuntime(theme); + + expect(theme.accentCookie?.domain).toBe("localhost"); + expect(runtime).toContain('"domain":"localhost"'); + expect(runtime).toContain("deleteCookie(ACCENT_COOKIE);"); + expect(runtime).toContain("writeCookie(ACCENT_COOKIE,name,ACCENT_COOKIE_OPTIONS)"); + }); }); test("live document accent has priority over cookie and default", () => { const runtime = renderThemeRuntime(