diff --git a/bun.lock b/bun.lock index 67c02f66..036efa1d 100644 --- a/bun.lock +++ b/bun.lock @@ -372,7 +372,7 @@ }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.41", + "version": "0.8.42", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -717,7 +717,7 @@ }, "packages/styles": { "name": "@wrnexus/styles", - "version": "0.8.23", + "version": "0.8.26", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -749,7 +749,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.84", + "version": "0.8.86", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/packages/csr/package.json b/packages/csr/package.json index 69f992ab..a7f9fe53 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.41", + "version": "0.8.42", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index ed00582f..4b9d1243 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -5542,7 +5542,11 @@ export const REACTIVE_RUNTIME = String.raw` function parsePostfix() { var node = parsePrimary(); for (;;) { - if (match(".")) { + var optional = match("?."); + if (optional && !is("[") && !is("(")) { + var optionalProp = next().value; + node = { value: node.value == null ? undefined : node.value[optionalProp], obj: node.value }; + } else if (match(".")) { var prop = next().value; node = { value: node.value == null ? undefined : node.value[prop], obj: node.value }; } else if (match("[")) { @@ -5617,6 +5621,10 @@ export const REACTIVE_RUNTIME = String.raw` } function parseTernary() { var c = parseOr(); + while (match("??")) { + var fallback = parseOr(); + c = c == null ? fallback : c; + } if (match("?")) { var a = parseTernary(); expect(":"); var b = parseTernary(); return c ? a : b; } return c; } @@ -5782,6 +5790,12 @@ export const REACTIVE_RUNTIME = String.raw` var two = input.slice(i, i + 2); + if (two === "?." || two === "??") { + tokens.push({ type: "operator", value: two }); + i += 2; + continue; + } + if ( two === "==" || two === "!=" || diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index df2e2f24..eb4d41fb 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -37,6 +37,14 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"]; +test("optional properties and nullish fallbacks hydrate without crashing", () => { + const win = mount(`
`); + expect(win.document.querySelector("b")?.textContent).toBe("0"); + expect(win.document.querySelector("i")?.textContent).toBe("0"); + expect(win.document.querySelector("em")?.textContent).toBe("ok"); + expect(win.document.querySelector("u")?.textContent).toBe("true"); +}); + restoreGlobalsAfterAll(REPLACED_GLOBALS); beforeEach(() => { diff --git a/packages/styles/package.json b/packages/styles/package.json index ffc88fbf..d8468f66 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.8.23", + "version": "0.8.26", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts index 338f679b..3efcd071 100644 --- a/packages/styles/src/theme.ts +++ b/packages/styles/src/theme.ts @@ -457,89 +457,106 @@ function resolveDefaultAccent( /** Built-in themes so components have tokens out of the box. */ export const DEFAULT_THEMES: Record = { + /* + * "Instrument panel" neutrals: cool graphite greys with a faint blue cast so + * every accent palette sits on them without muddying. Elevation is layered + * (a hairline contact shadow plus a soft ambient one) instead of one blur. + */ light: { "color-scheme": "light", - "color-bg": "#f7f8fa", - "color-background": "#f7f8fa", - "color-foreground": "#20232b", + "color-bg": "#f2f3f6", + "color-background": "#f2f3f6", + "color-foreground": "#14161b", "color-surface-raised": "#ffffff", - "color-surface-muted": "#eef0f4", - "color-surface-soft": "#f6f7f9", - "color-text-muted": "#606776", - "color-text-subtle": "#6b7280", - "color-border-strong": "#b9c0cc", - "color-code-background": "#20232b", - "color-code-surface": "#141a30", - "color-code-text": "#e7ecff", - "color-code-muted": "#9aa6d0", - "color-code-border": "#27304d", - "shadow-sm": "0 1px 2px rgba(16,24,40,0.06)", - "shadow-md": "0 8px 24px rgba(16,24,40,0.10)", - "shadow-lg": "0 20px 50px rgba(16,24,40,0.14)", + "color-surface-muted": "#eaecf0", + "color-surface-soft": "#f7f8fa", + "color-text-muted": "#565d6b", + "color-text-subtle": "#6c7381", + "color-border-strong": "#c2c7d0", + "color-code-background": "#14161b", + "color-code-surface": "#0f1116", + "color-code-text": "#e6e9f0", + "color-code-muted": "#8e96a6", + "color-code-border": "#262a33", + "shadow-sm": "0 1px 0 rgba(20,22,27,0.03),0 1px 2px rgba(20,22,27,0.06)", + "shadow-md": "0 1px 2px rgba(20,22,27,0.05),0 8px 24px -6px rgba(20,22,27,0.12)", + "shadow-lg": "0 2px 6px rgba(20,22,27,0.06),0 24px 56px -12px rgba(20,22,27,0.22)", "space-section": "5rem", "space-section-sm": "3.5rem", "container-max": "80rem", "color-surface": "#ffffff", - "color-surface-2": "#eef0f4", - "color-text": "#20232b", - "color-muted": "#606776", - "color-border": "#e1e4ea", + "color-surface-2": "#eaecf0", + "color-text": "#14161b", + "color-muted": "#565d6b", + "color-border": "#dde0e6", ...paletteTokens(THEME_PALETTES.blue, "light"), - radius: "10px", - "radius-sm": "6px", + radius: "12px", + "radius-sm": "8px", "radius-md": "10px", - "radius-lg": "12px", + "radius-lg": "16px", "control-height-sm": "2rem", "control-height": "2.5rem", - "control-height-lg": "2.75rem", + "control-height-lg": "2.875rem", "space-panel": "1.25rem", "font-size-ui": "0.875rem", "font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif', - "shadow-1": "0 1px 3px rgba(16,24,40,0.05)", - "shadow-2": "0 8px 24px rgba(16,24,40,0.10)", - "shadow-3": "0 20px 50px rgba(16,24,40,0.14)", + "font-mono": '"JetBrains Mono","SFMono-Regular",ui-monospace,Menlo,Consolas,monospace', + "shadow-1": "0 1px 0 rgba(20,22,27,0.03),0 1px 2px rgba(20,22,27,0.06)", + "shadow-2": "0 1px 2px rgba(20,22,27,0.05),0 8px 24px -6px rgba(20,22,27,0.12)", + "shadow-3": "0 2px 6px rgba(20,22,27,0.06),0 24px 56px -12px rgba(20,22,27,0.22)", + // Signature: the machined top edge on solid controls, and a two-ring focus + // halo that separates the ring from whatever surface the control sits on. + "highlight-edge": "inset 0 1px 0 rgba(255,255,255,0.22)", + "shadow-focus": + "0 0 0 2px var(--wrn-color-bg),0 0 0 4px color-mix(in srgb,var(--wrn-color-focus) 72%,transparent)", + "color-selection": "color-mix(in srgb,var(--wrn-color-primary) 22%,transparent)", }, dark: { "color-scheme": "dark", - "color-bg": "#101114", - "color-background": "#101114", - "color-foreground": "#f0f1f4", - "color-surface-raised": "#22252d", - "color-surface-muted": "#242730", - "color-surface-soft": "#1c1f26", - "color-text-muted": "#b5bac5", - "color-text-subtle": "#959cab", - "color-border-strong": "#555d6c", - "color-code-background": "#050505", - "color-code-surface": "#101010", - "color-code-text": "#f0f1f4", - "color-code-muted": "#a7aeba", - "color-code-border": "#ffffff24", - "shadow-sm": "0 1px 2px rgba(0,0,0,0.28)", - "shadow-md": "0 10px 30px rgba(0,0,0,0.35)", - "shadow-lg": "0 24px 64px rgba(0,0,0,0.45)", + "color-bg": "#0c0d10", + "color-background": "#0c0d10", + "color-foreground": "#edeef1", + "color-surface-raised": "#1f2229", + "color-surface-muted": "#1c1f25", + "color-surface-soft": "#121418", + "color-text-muted": "#a9afba", + "color-text-subtle": "#8a919e", + "color-border-strong": "#3d434e", + "color-code-background": "#08090b", + "color-code-surface": "#0e1013", + "color-code-text": "#edeef1", + "color-code-muted": "#8e96a6", + "color-code-border": "#ffffff1f", + "shadow-sm": "0 1px 0 rgba(0,0,0,0.3),0 1px 2px rgba(0,0,0,0.3)", + "shadow-md": "0 1px 2px rgba(0,0,0,0.3),0 10px 30px -6px rgba(0,0,0,0.5)", + "shadow-lg": "0 2px 6px rgba(0,0,0,0.3),0 28px 64px -12px rgba(0,0,0,0.65)", "space-section": "5rem", "space-section-sm": "3.5rem", "container-max": "80rem", - "color-surface": "#181a20", - "color-surface-2": "#20232b", - "color-text": "#f0f1f4", - "color-muted": "#a7aeba", - "color-border": "#30343e", + "color-surface": "#15171c", + "color-surface-2": "#1c1f25", + "color-text": "#edeef1", + "color-muted": "#a3a9b5", + "color-border": "#272b33", ...paletteTokens(THEME_PALETTES.blue, "dark"), - radius: "10px", - "radius-sm": "6px", + radius: "12px", + "radius-sm": "8px", "radius-md": "10px", - "radius-lg": "12px", + "radius-lg": "16px", "control-height-sm": "2rem", "control-height": "2.5rem", - "control-height-lg": "2.75rem", + "control-height-lg": "2.875rem", "space-panel": "1.25rem", "font-size-ui": "0.875rem", "font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif', - "shadow-1": "0 1px 3px rgba(0,0,0,0.18)", - "shadow-2": "0 10px 30px rgba(0,0,0,0.35)", - "shadow-3": "0 24px 64px rgba(0,0,0,0.45)", + "font-mono": '"JetBrains Mono","SFMono-Regular",ui-monospace,Menlo,Consolas,monospace', + "shadow-1": "0 1px 0 rgba(0,0,0,0.3),0 1px 2px rgba(0,0,0,0.3)", + "shadow-2": "0 1px 2px rgba(0,0,0,0.3),0 10px 30px -6px rgba(0,0,0,0.5)", + "shadow-3": "0 2px 6px rgba(0,0,0,0.3),0 28px 64px -12px rgba(0,0,0,0.65)", + "highlight-edge": "inset 0 1px 0 rgba(255,255,255,0.12)", + "shadow-focus": + "0 0 0 2px var(--wrn-color-bg),0 0 0 4px color-mix(in srgb,var(--wrn-color-focus) 80%,transparent)", + "color-selection": "color-mix(in srgb,var(--wrn-color-primary) 34%,transparent)", }, }; @@ -620,6 +637,7 @@ function selectorValue(value: string): string { function globalThemeCss(): string { return ( '\nhtml,body{font-family:var(--wrn-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' + + "::selection{background:var(--wrn-color-selection);}\n" + "wrn-slot{display:contents;}\n" + "[data-for]{display:none !important;}\n" ); @@ -754,6 +772,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { function writeCookie(name,value,options){ options=options||{}; + if(options.domain && options.domain!=="localhost")deleteCookie(name,{path:options.path||"/"}); var maxAge=options.maxAge===undefined?MAX_AGE:Math.max(0,Math.floor(options.maxAge)); document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+ ";max-age="+maxAge+cookieOptions(options); @@ -783,6 +802,18 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { } }; + // Retire host-only preferences from older apps. An existing parent-domain + // value wins; otherwise preserve the user's legacy choice in the new scope. + function migrateCookie(name,options){ + if(!options.domain || options.domain==="localhost")return; + var previous=readCookie(name); + deleteCookie(name,{path:options.path||"/"}); + if(readCookie(name)===null && previous!==null)writeCookie(name,previous,options); + } + migrateCookie(THEME_COOKIE,THEME_COOKIE_OPTIONS); + migrateCookie(ACCENT_COOKIE,ACCENT_COOKIE_OPTIONS); + migrateCookie("wrn-lang",configuredCookieOptions("language")); + function syncStylesheet(themeName,accentName){ var link=document.querySelector("link[data-wrnexus-theme]"); if(!link)return; @@ -818,6 +849,9 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { if(THEMES.indexOf(name)<0)return; el.setAttribute("data-theme",name); writeCookie(THEME_COOKIE,name,THEME_COOKIE_OPTIONS); + writeCookie("wrn-theme-mode","manual",THEME_COOKIE_OPTIONS); + deleteCookie("wrn-theme-system"); + deleteCookie("wrn-theme-system",THEME_COOKIE_OPTIONS); syncStylesheet(name,getAccent()); syncThemeButtons(document); window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}})); @@ -893,6 +927,7 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { get:getTheme, set:setTheme, toggle:toggleTheme, + followSystem:function(){writeCookie("wrn-theme-mode","system",THEME_COOKIE_OPTIONS);syncSharedPreferences();}, bind:bind, themes:THEMES.slice() }; @@ -904,7 +939,39 @@ export function renderThemeRuntime(theme: ResolvedTheme): string { colors:ACCENTS.slice() }; - window.addEventListener("wrnexus:navigated",function(){bind(document);}); + function syncSharedPreferences(){ + var themeName=readCookie(THEME_COOKIE),accentName=readCookie(ACCENT_COOKIE); + if(readCookie("wrn-theme-mode")==="system" && window.matchMedia){ + themeName=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"; + if(readCookie(THEME_COOKIE)!==themeName){ + writeCookie(THEME_COOKIE,themeName,THEME_COOKIE_OPTIONS); + writeCookie("wrn-theme-system",themeName,THEME_COOKIE_OPTIONS); + } + } + var changed=false; + if(themeName && THEMES.indexOf(themeName)>=0 && themeName!==getTheme()){ + el.setAttribute("data-theme",themeName);changed=true; + window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:themeName,shared:true}})); + } + if(accentName && ACCENTS.indexOf(accentName)>=0 && accentName!==getAccent()){ + el.setAttribute("data-accent",accentName);changed=true; + window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:accentName,shared:true}})); + } + if(changed){syncStylesheet(getTheme(),getAccent());syncThemeButtons(document);syncAccentButtons(document);} + var language=readCookie("wrn-lang"),i18n=window.__wrnI18n; + if(language && i18n && i18n.langs && i18n.langs.indexOf(language)>=0 && language!==i18n.lang)location.reload(); + } + window.addEventListener("focus",syncSharedPreferences); + window.addEventListener("pageshow",syncSharedPreferences); + document.addEventListener("visibilitychange",function(){if(!document.hidden)syncSharedPreferences();}); + if(window.cookieStore)window.cookieStore.addEventListener("change",syncSharedPreferences); + if(window.matchMedia)window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",syncSharedPreferences); + // Cookie Store is not available in every supported browser. Poll only visible + // pages, without rewriting cookies or emitting a cross-tab feedback loop. + window.setInterval(function(){if(!document.hidden)syncSharedPreferences();},1500); + syncSharedPreferences(); + + window.addEventListener("wrnexus:navigated",function(){bind(document);syncSharedPreferences();}); if(document.readyState==="loading"){ document.addEventListener("DOMContentLoaded",function(){bind(document);}); }else{ diff --git a/packages/styles/test/shared-preferences.test.ts b/packages/styles/test/shared-preferences.test.ts new file mode 100644 index 00000000..f6627923 --- /dev/null +++ b/packages/styles/test/shared-preferences.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { Browser } from "happy-dom"; +import { renderThemeRuntime, resolveThemeConfig } from "../src/theme.ts"; + +test("shared preferences replace stale host cookies and update another open app", async () => { + const browser = new Browser({ settings: { disableCSSFileLoading: true } }); + try { + const first = browser.newPage(); + first.url = "https://account.example.com/"; + const second = browser.newPage(); + second.url = "https://link.example.com/"; + const a = first.mainFrame.window; + const b = second.mainFrame.window; + // Happy DOM coalesces equal-name host/domain cookies. Model their distinct + // browser storage keys so the legacy-shadow migration is exercised. + const jar = new Map(); + function cookies(win: typeof a) { + Object.defineProperty(win.document, "cookie", { + get() { return [...jar.values()].filter(c => c.hostOnly ? c.domain === win.location.hostname : win.location.hostname === c.domain || win.location.hostname.endsWith("." + c.domain)).map(c => `${c.name}=${c.value}`).join("; "); }, + set(raw: string) { + const [pair, ...options] = raw.split(";"); + const split = pair!.indexOf("="); + const name = pair!.slice(0, split), value = pair!.slice(split + 1); + const domainOption = options.find(v => v.trim().toLowerCase().startsWith("domain=")); + const domain = domainOption ? domainOption.trim().slice(7).replace(/^\./, "") : win.location.hostname; + const key = `${name}|${domain}`; + if(options.some(v => v.trim().toLowerCase() === "max-age=0")) jar.delete(key); + else jar.set(key, { name, value, domain, hostOnly: !domainOption }); + }, + }); + } + cookies(a); cookies(b); + a.document.cookie = "wrn-theme=light;path=/"; + b.document.cookie = "wrn-theme=light;path=/"; + a.document.cookie = "wrn-theme=dark;path=/;domain=.example.com;secure"; + a.document.cookie = "wrn-accent=rose;path=/;domain=.example.com;secure"; + const js = renderThemeRuntime(resolveThemeConfig({ palette: "emerald" }, { defaults: { domain: ".example.com", secure: true } })); + for (const win of [a, b]) { + win.document.documentElement.setAttribute("data-theme", "light"); + win.document.documentElement.setAttribute("data-accent", "emerald"); + win.eval(js); + expect(win.document.documentElement.getAttribute("data-theme")).toBe("dark"); + expect(win.document.cookie.match(/wrn-theme=/g)?.length).toBe(1); + } + a.eval('wrnTheme.set("light");wrnAccent.set("violet");wrnCookies.set("wrn-lang","hi","language")'); + expect(b.document.cookie).toContain("wrn-theme-mode=manual"); + b.dispatchEvent(new b.Event("focus")); + expect(b.document.documentElement.getAttribute("data-theme")).toBe("light"); + expect(b.document.documentElement.getAttribute("data-accent")).toBe("violet"); + expect(b.document.cookie).toContain("wrn-lang=hi"); + a.eval('wrnTheme.followSystem()'); + expect(b.document.cookie).toContain("wrn-theme-mode=system"); + const outsider = browser.newPage(); + outsider.url = "https://unrelated.example.net/"; + cookies(outsider.mainFrame.window); + expect(outsider.mainFrame.window.document.cookie).toBe(""); + } finally { await browser.close(); } +}); diff --git a/packages/styles/test/theme.test.ts b/packages/styles/test/theme.test.ts index cbd36696..b55c6311 100644 --- a/packages/styles/test/theme.test.ts +++ b/packages/styles/test/theme.test.ts @@ -62,9 +62,9 @@ test("built-in themes use Plus Jakarta Sans and layered charcoal dark surfaces", '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif', ); expect(t.themes.dark["font-sans"]).toBe('"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif'); - expect(t.themes.dark["color-bg"]).toBe("#101114"); - expect(t.themes.dark["color-surface"]).toBe("#181a20"); - expect(t.themes.dark["color-surface-2"]).toBe("#20232b"); + expect(t.themes.dark["color-bg"]).toBe("#0c0d10"); + expect(t.themes.dark["color-surface"]).toBe("#15171c"); + expect(t.themes.dark["color-surface-2"]).toBe("#1c1f25"); }); test("resolveThemeName validates against configured names", () => { diff --git a/packages/ui/components/BackToTop.wrn b/packages/ui/components/BackToTop.wrn index 1e0a9550..a4f3382e 100644 --- a/packages/ui/components/BackToTop.wrn +++ b/packages/ui/components/BackToTop.wrn @@ -36,6 +36,7 @@ component BackToTop { data-show-label='{showLabel ? "true" : "false"}' data-progress='{showProgress ? "true" : "false"}' data-show='{alwaysVisible || visible}' + hidden='{!alwaysVisible && !visible}' class='wrn-back-to-top {class}' style='--wrn-back-to-top-progress: {progress}%;' @window:scroll='visible = window.scrollY >= threshold; progress = Math.min(100, Math.max(0, Math.round((window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)) * 100)))' @@ -58,6 +59,10 @@ component BackToTop { } style { + .wrn-back-to-top[hidden] { + display: none; + } + .wrn-back-to-top { --back-accent: var(--wrn-color-primary); --back-accent-hover: var(--wrn-color-primary-hover); diff --git a/packages/ui/components/Card.wrn b/packages/ui/components/Card.wrn index abc5d6c8..f3016045 100644 --- a/packages/ui/components/Card.wrn +++ b/packages/ui/components/Card.wrn @@ -377,9 +377,9 @@ component Card { width: 100%; min-width: 0; border: 1px solid var(--wrn-color-border); - border-radius: var(--wrn-radius-md); + border-radius: var(--wrn-radius); background: var(--wrn-color-surface); - box-shadow: none; + box-shadow: var(--wrn-shadow-1); transition: transform var(--wrn-motion-base) var(--wrn-ease-emphasized), border-color var(--wrn-motion-base) ease, @@ -404,7 +404,7 @@ component Card { align-items: center; justify-content: space-between; padding: 0.85rem var(--wrn-card-padding); - background: var(--wrn-color-surface-2); + background: var(--wrn-color-surface-soft, var(--wrn-color-surface-2)); color: var(--wrn-color-muted); font-size: 0.875rem; } diff --git a/packages/ui/components/Tabs.wrn b/packages/ui/components/Tabs.wrn index c600192f..43b47475 100644 --- a/packages/ui/components/Tabs.wrn +++ b/packages/ui/components/Tabs.wrn @@ -223,7 +223,9 @@ component Tabs { padding: 0.25rem; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-md); - background: var(--wrn-color-surface-soft); + background: var(--wrn-color-surface-muted, var(--wrn-color-surface-soft)); + /* Recessed track; the selected tab sits proud of it like a keycap. */ + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.05); /* A long tablist scrolls rather than wrapping into an unusable stack. */ overflow-x: auto; } @@ -279,7 +281,9 @@ component Tabs { background: var(--wrn-color-surface); /* Small text on the accent alone misses 4.5:1, so it blends toward the text colour. */ color: color-mix(in srgb, var(--tabs-accent) 72%, var(--wrn-color-text)); - box-shadow: var(--wrn-shadow-1); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--wrn-color-border) 80%, transparent), + var(--wrn-shadow-1); } .wrn-tabs__tab[aria-disabled="true"] { diff --git a/packages/ui/components/button.wrn b/packages/ui/components/button.wrn index d85c4b07..9547f0f4 100644 --- a/packages/ui/components/button.wrn +++ b/packages/ui/components/button.wrn @@ -205,9 +205,10 @@ label: string = "Button" font-size: 0.875rem; font-weight: 600; line-height: 1; + letter-spacing: -0.006em; border: 1px solid transparent; border-radius: var(--wrn-radius-sm); - padding: 0 0.75rem; + padding: 0 0.875rem; cursor: pointer; text-decoration: none; min-height: var(--wrn-control-height, 2.5rem); @@ -219,10 +220,10 @@ label: string = "Button" transform var(--wrn-motion-base) var(--wrn-ease-emphasized); } .wrn-btn:active { - transform: scale(0.98); + transform: translateY(1px); } .wrn-btn:focus-visible { - outline: 2px solid var(--wrn-color-primary); + outline: 2px solid color-mix(in srgb, var(--wrn-btn-color) 80%, transparent); outline-offset: 2px; } .wrn-btn--color-primary { @@ -254,23 +255,48 @@ label: string = "Button" .wrn-btn--default, .wrn-btn--solid, .wrn-btn--primary { + /* Keycap: a lit top edge and a weighted bottom border give solid actions + a physical press; :active drops the key 1px and flattens the edge. */ color: var(--wrn-btn-contrast); - background: var(--wrn-btn-color); - box-shadow: var(--wrn-shadow-sm); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--wrn-btn-color) 92%, white), + var(--wrn-btn-color) + ); + border-color: color-mix(in srgb, var(--wrn-btn-color) 82%, black); + border-bottom-color: color-mix(in srgb, var(--wrn-btn-color) 64%, black); + box-shadow: + var(--wrn-highlight-edge, inset 0 1px 0 rgb(255 255 255 / 0.2)), + 0 1px 2px color-mix(in srgb, var(--wrn-btn-color) 30%, transparent); } .wrn-btn--variant-default:hover, .wrn-btn--variant-solid:hover, .wrn-btn--default:hover, .wrn-btn--solid:hover, .wrn-btn--primary:hover { - background: color-mix(in srgb, var(--wrn-btn-color) 84%, black); - box-shadow: var(--wrn-shadow-sm); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--wrn-btn-color) 94%, white), + color-mix(in srgb, var(--wrn-btn-color) 88%, black) + ); + box-shadow: + var(--wrn-highlight-edge, inset 0 1px 0 rgb(255 255 255 / 0.2)), + 0 2px 6px -1px color-mix(in srgb, var(--wrn-btn-color) 38%, transparent); + } + .wrn-btn--variant-default:active, + .wrn-btn--variant-solid:active, + .wrn-btn--default:active, + .wrn-btn--solid:active, + .wrn-btn--primary:active { + background: color-mix(in srgb, var(--wrn-btn-color) 86%, black); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.18); } .wrn-btn--variant-outline, .wrn-btn--outline { color: var(--wrn-btn-color); - background: transparent; - border-color: color-mix(in srgb, var(--wrn-btn-color) 55%, var(--wrn-color-border)); + background: var(--wrn-color-surface); + border-color: color-mix(in srgb, var(--wrn-btn-color) 40%, var(--wrn-color-border)); + box-shadow: var(--wrn-shadow-1); } .wrn-btn--variant-outline:hover, .wrn-btn--outline:hover { @@ -304,9 +330,15 @@ label: string = "Button" } .wrn-btn--variant-secondary, .wrn-btn--secondary { + /* Neutral keycap: surface body with the same machined edge. */ color: var(--wrn-color-text); - background: color-mix(in srgb, var(--wrn-btn-color) 10%, var(--wrn-color-surface)); - border-color: color-mix(in srgb, var(--wrn-btn-color) 24%, var(--wrn-color-border)); + background: linear-gradient( + 180deg, + var(--wrn-color-surface), + color-mix(in srgb, var(--wrn-btn-color) 5%, var(--wrn-color-surface-soft, var(--wrn-color-surface))) + ); + border-color: var(--wrn-color-border); + border-bottom-color: var(--wrn-color-border-strong, var(--wrn-color-border)); box-shadow: var(--wrn-shadow-1); } .wrn-btn--variant-secondary:hover, diff --git a/packages/ui/components/input.wrn b/packages/ui/components/input.wrn index 8a9385b9..c5e673b4 100644 --- a/packages/ui/components/input.wrn +++ b/packages/ui/components/input.wrn @@ -295,9 +295,14 @@ component Input { border-radius: var(--wrn-radius-sm, 6px); box-shadow: none; } + .wrn-next--field:not([data-invalid="true"]) :where(input, textarea, select):hover:not(:disabled):not(:focus-visible) { + border-color: var(--wrn-color-input-border-hover, var(--wrn-color-border-strong)); + } .wrn-next--field :where(input, textarea, select):focus-visible { border-color: var(--wrn-color-primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 16%, transparent); + box-shadow: + inset 0 1px 2px rgb(0 0 0 / 0.04), + 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 18%, transparent); } .wrn-next--field textarea { resize: vertical; @@ -349,12 +354,17 @@ component Input { min-width: 0; } .wrn-next__field-control > :is(input, textarea, select) { + /* Recessed well: the inverse of the raised keycap button. */ padding-inline: 0.85rem; - border-color: var(--wrn-color-border); + border-color: var(--wrn-color-input-border, var(--wrn-color-border)); border-radius: var(--wrn-radius-sm); - background: var(--wrn-color-surface); + background: var(--wrn-color-input-background, var(--wrn-color-surface)); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.04); font-size: 0.8125rem; } + .wrn-next__field-control > :is(input, textarea)::placeholder { + color: var(--wrn-color-input-placeholder, var(--wrn-color-muted)); + } .wrn-next__field-control[data-has-icon="true"][data-icon-position="start"] > :is(input, textarea, select) { padding-inline-start: 2.5rem; } diff --git a/packages/ui/package.json b/packages/ui/package.json index f97c252c..c577380d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.84", + "version": "0.8.86", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/test/account-shell-regressions.test.ts b/packages/ui/test/account-shell-regressions.test.ts index 5fb7397a..1783532c 100644 --- a/packages/ui/test/account-shell-regressions.test.ts +++ b/packages/ui/test/account-shell-regressions.test.ts @@ -8,6 +8,20 @@ import { Window } from "happy-dom"; const source = (name: string) => readFileSync(uiComponentPath(name), "utf8"); describe("account shell UI regressions", () => { + test("BackToTop is hidden in server HTML unless explicitly always visible", async () => { + for (const alwaysVisible of [false, true]) { + const html = await renderComponent(source("BackToTop"), { alwaysVisible }); + const server = new Window(); + try { + server.document.body.innerHTML = html; + expect(server.document.querySelector(".wrn-back-to-top")?.hasAttribute("hidden")).toBe( + !alwaysVisible, + ); + } finally { + await server.happyDOM.close(); + } + } + }); test("Drawer has a self-contained close icon and a compact centered mobile header", async () => { const dom = mountHtml( await renderComponent(source("Drawer"), { title: "Account center", open: true, duration: 0 }), diff --git a/packages/ui/ui.css b/packages/ui/ui.css index ccb21bf3..779121bb 100644 --- a/packages/ui/ui.css +++ b/packages/ui/ui.css @@ -111,8 +111,8 @@ transition-duration: 0.01ms !important; } } -:where(button, a):focus-visible { - outline: 2px solid var(--wrn-color-primary); +:where(button, a, summary):focus-visible { + outline: 2px solid var(--wrn-color-focus); outline-offset: 2px; } /* --- Component-system foundations --------------------------------------- */ @@ -122,15 +122,15 @@ } .wrn-type--display { font-size: clamp(2.25rem, 7vw, 4.75rem); - line-height: 1.02; - font-weight: 800; - letter-spacing: -0.04em; + line-height: 1; + font-weight: 750; + letter-spacing: -0.045em; } .wrn-type--heading { font-size: clamp(1.25rem, 2vw, 1.75rem); - line-height: 1.1; - font-weight: 600; - letter-spacing: -0.025em; + line-height: 1.15; + font-weight: 650; + letter-spacing: -0.028em; } .wrn-type--lead { font-size: clamp(1.05rem, 2vw, 1.25rem); @@ -172,8 +172,8 @@ } .wrn-label { color: var(--wrn-color-text); - font-size: 0.875rem; - font-weight: 650; + font-size: 0.8125rem; + font-weight: 600; line-height: 1.4; } .wrn-required, @@ -210,23 +210,24 @@ display: grid; place-items: center; padding: 1rem; - background: rgb(0 0 0 / 0.55); - backdrop-filter: blur(4px); + background: rgb(12 13 16 / 0.52); + backdrop-filter: blur(6px); } .wrn-dialog { width: min(100%, 32rem); max-height: calc(100dvh - 2rem); overflow: auto; - padding: clamp(1.25rem, 4vw, 2rem); + padding: clamp(1.25rem, 4vw, 1.75rem); color: var(--wrn-color-text); background: var(--wrn-color-surface); border: 1px solid var(--wrn-color-border); - border-radius: var(--wrn-radius); - box-shadow: var(--wrn-shadow-1); + border-radius: var(--wrn-radius-lg); + box-shadow: var(--wrn-shadow-3); } .wrn-dialog__title { margin: 0; - font-size: 1.35rem; + font-size: 1.25rem; + font-weight: 650; line-height: 1.25; } .wrn-dialog__description { @@ -287,10 +288,11 @@ box-shadow: var(--wrn-shadow-1); } .wrn-eyebrow { - color: var(--wrn-color-primary); - font-size: 0.75rem; - font-weight: 750; - letter-spacing: 0.1em; + color: var(--wrn-color-primary-text); + font-family: var(--wrn-font-mono, monospace); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.08em; text-transform: uppercase; } .wrn-auth-card > .wrn-eyebrow { @@ -579,8 +581,8 @@ } .wrn-well, .wrn-inset-panel { - background: var(--wrn-color-surface-2); - box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.06); + background: var(--wrn-color-surface-soft); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.05); } .wrn-scroll-area { max-width: 100%;