diff --git a/CLAUDE.md b/CLAUDE.md index 1c6c1772..01de093a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,10 @@ src/ gramps-js.js # Entry point (registers the custom element) api.js # All API calls, auth helpers, localStorage utilities appState.js # App-wide state initialisation + color.js # Gramps internal hex format converters (tag colours etc.) config.js # Runtime config (window.grampsjsConfig); see below strings.js # i18n string keys and language list + theme.js # Colour theme: applyScheme(), applyColors(), DEFAULT_PRIMARY/SECONDARY util.js # Shared helpers components/ # Reusable components views/ # Page-level components @@ -80,7 +82,7 @@ html`` Optional attributes: `color`, `height`, `width`, `rotate`. -For action icon color (add/edit/delete buttons), use `color="var(--mdc-theme-secondary)"` — this is `#0277bd` and is consistent across light and dark mode. +For action icon color (add/edit/delete buttons), use `color="var(--mdc-theme-secondary)"` — this is derived from the active secondary seed colour and adapts to light/dark mode. ### Mixins diff --git a/global.css b/global.css index 862c0b76..b4390874 100644 --- a/global.css +++ b/global.css @@ -11,8 +11,7 @@ body { /* Web Awesome color token overrides. default.css (loaded in index.html) defines --wa-* tokens inside @layer wa-theme, so these unlayered declarations win without !important. The --md-sys-color-* - values on the right are defined in the light/dark blocks below, so the - color picker automatically adapts to the active theme. */ + values on the right are set dynamically by applyColors() in theme.js. */ --wa-color-surface-raised: var(--md-sys-color-surface-container-high); --wa-color-surface-default: var(--md-sys-color-surface); --wa-color-surface-border: var(--md-sys-color-outline-variant); @@ -29,7 +28,7 @@ body { --color-other: #ce93d8; --mdc-theme-primary: var(--md-sys-color-primary); --mdc-theme-on-primary: var(--md-sys-color-on-primary); - --mdc-theme-secondary: #0277bd; + --mdc-theme-secondary: var(--md-sys-color-secondary); --mdc-theme-on-secondary: rgba(255, 255, 255, 0.95); --mdc-theme-background: var(--md-sys-color-background); --mdc-theme-surface: var(--md-sys-color-surface); @@ -49,11 +48,27 @@ body { --grampsjs-lightbox-font-color: #fff; --grampsjs-lightbox-nav-color: #aaa; --grampsjs-lightbox-nav-hover-color: #fff; - --grampsjs-alert-background-color: rgba(109, 76, 65, 0.15); - --grampsjs-alert-border-color: rgba(109, 76, 65, 0.7); - --grampsjs-alert-error-background-color: rgba(191, 54, 12, 0.2); - --grampsjs-alert-error-border-color: rgba(191, 54, 12, 0.7); - --grampsjs-alert-error-font-color: #bf360c; + --grampsjs-alert-background-color: color-mix( + in srgb, + var(--md-sys-color-primary) 15%, + transparent + ); + --grampsjs-alert-border-color: color-mix( + in srgb, + var(--md-sys-color-primary) 70%, + transparent + ); + --grampsjs-alert-error-background-color: color-mix( + in srgb, + var(--md-sys-color-error) 20%, + transparent + ); + --grampsjs-alert-error-border-color: color-mix( + in srgb, + var(--md-sys-color-error) 70%, + transparent + ); + --grampsjs-alert-error-font-color: var(--md-sys-color-error); --grampsjs-map-marker-color: #ea4335; --grampsjs-map-filter-badge-color: #ba1b1b; --grampsjs-logout-font-color: #c62828; @@ -64,16 +79,59 @@ body { --grampsjs-task-open: #007bff; --grampsjs-task-progress: #ff9800; --grampsjs-task-done: rgba(65, 173, 73, 0.4); - --grampsjs-editable-title-hover-background-color: rgba(2, 119, 189, 0.2); - --grampsjs-delete-all-filter-chip-color: rgba(109, 76, 65, 0.32); - --grampsjs-connection-chart-border-color: rgba(137, 107, 94, 0.4); + --grampsjs-editable-title-hover-background-color: color-mix( + in srgb, + var(--mdc-theme-secondary) 20%, + transparent + ); + --grampsjs-delete-all-filter-chip-color: color-mix( + in srgb, + var(--md-sys-color-primary) 32%, + transparent + ); + --grampsjs-connection-chart-border-color: color-mix( + in srgb, + var(--md-sys-color-primary) 40%, + transparent + ); --grampsjs-connection-chart-person-box: #e6e6e6; - --grampsjs-editable-list-hover-background-color: rgba(2, 119, 189, 0.1); - --grampsjs-editable-list-active-background-color: rgba(2, 119, 189, 0.2); - --grampsjs-editable-list-selected-active-background-color: rgba(2, 119, 189, 0.3); - --grampsjs-editable-list-selected-hover-background-color: rgba(2, 119, 189, 0.4); - --grampsjs-editable-list-selected-background-color: rgba(2, 119, 189, 0.5); - --grampsjs-mobile-table-sort-menu-color: rgba(109, 76, 65, 0.3); + --grampsjs-editable-list-hover-background-color: color-mix( + in srgb, + var(--mdc-theme-secondary) 10%, + transparent + ); + --grampsjs-editable-list-active-background-color: color-mix( + in srgb, + var(--mdc-theme-secondary) 20%, + transparent + ); + --grampsjs-editable-list-selected-active-background-color: color-mix( + in srgb, + var(--mdc-theme-secondary) 30%, + transparent + ); + --grampsjs-editable-list-selected-hover-background-color: color-mix( + in srgb, + var(--mdc-theme-secondary) 40%, + transparent + ); + --grampsjs-editable-list-selected-background-color: color-mix( + in srgb, + var(--mdc-theme-secondary) 50%, + transparent + ); + --grampsjs-mobile-table-sort-menu-color: color-mix( + in srgb, + var(--md-sys-color-primary) 30%, + transparent + ); + --grampsjs-color-link-font: var(--mdc-theme-secondary); + --grampsjs-color-link-hover: color-mix( + in srgb, + var(--mdc-theme-secondary) 80%, + black + ); + --grampsjs-color-page-loading-progress: var(--mdc-theme-secondary); } :root[data-theme='light'] { @@ -116,9 +174,6 @@ body { --grampsjs-color-skeleton-background: #eee; --grampsjs-color-skeleton-base: #ececec; --grampsjs-color-skeleton-shine: #f5f5f5; - --grampsjs-color-link-font: #0d47a1; - --grampsjs-color-link-hover: #1976d2; - --grampsjs-color-page-loading-progress: #4fc3f7; --grampsjs-color-revision-deleted-background-red: #ffebe9; --grampsjs-color-revision-added-background-green: #dafbe1; --grampsjs-color-revision-moved-background-blue: #ddf4ff; @@ -131,55 +186,7 @@ body { --grampsjs-color-ytree-expand-font: #555; --grampsjs-color-ytree-default-person-icon-background: #bbb; --grampsjs-note-color: #333; - --md-sys-color-primary: rgb(109, 76, 65); - --md-sys-color-surface-tint: rgb(109, 76, 65); - --md-sys-color-on-primary: rgb(255, 255, 255); - --md-sys-color-primary-container: rgb(255, 219, 207); - --md-sys-color-on-primary-container: rgb(57, 12, 0); - --md-sys-color-secondary: rgb(49, 98, 141); - --md-sys-color-on-secondary: rgb(255, 255, 255); - --md-sys-color-secondary-container: rgb(207, 229, 255); - --md-sys-color-on-secondary-container: rgb(0, 29, 52); - --md-sys-color-tertiary: rgb(106, 94, 47); - --md-sys-color-on-tertiary: rgb(255, 255, 255); - --md-sys-color-tertiary-container: rgb(243, 226, 167); - --md-sys-color-on-tertiary-container: rgb(34, 27, 0); - --md-sys-color-error: rgb(186, 26, 26); - --md-sys-color-on-error: rgb(255, 255, 255); - --md-sys-color-error-container: rgb(255, 218, 214); - --md-sys-color-on-error-container: rgb(65, 0, 2); - --md-sys-color-background: #fff;; - --md-sys-color-on-background: var(--grampsjs-body-font-color); - --md-sys-color-surface: #fff; - --md-sys-color-on-surface: var(--grampsjs-body-font-color); - --md-sys-color-surface-variant: rgb(232, 224, 222); - --md-sys-color-on-surface-variant: rgb(83, 67, 63); - --md-sys-color-outline: rgb(133, 115, 110); - --md-sys-color-outline-variant: rgb(200, 200, 200); - --md-sys-color-shadow: rgb(0, 0, 0); - --md-sys-color-scrim: rgb(0, 0, 0); - --md-sys-color-inverse-surface: rgb(57, 46, 43); - --md-sys-color-inverse-on-surface: rgb(255, 237, 232); - --md-sys-color-inverse-primary: rgb(255, 181, 156); - --md-sys-color-primary-fixed: rgb(255, 219, 207); - --md-sys-color-on-primary-fixed: rgb(57, 12, 0); - --md-sys-color-primary-fixed-dim: rgb(255, 181, 156); - --md-sys-color-on-primary-fixed-variant: rgb(114, 53, 32); - --md-sys-color-secondary-fixed: rgb(207, 229, 255); - --md-sys-color-on-secondary-fixed: rgb(0, 29, 52); - --md-sys-color-secondary-fixed-dim: rgb(157, 203, 251); - --md-sys-color-on-secondary-fixed-variant: rgb(18, 74, 115); - --md-sys-color-tertiary-fixed: rgb(243, 226, 167); - --md-sys-color-on-tertiary-fixed: rgb(34, 27, 0); - --md-sys-color-tertiary-fixed-dim: rgb(214, 198, 141); - --md-sys-color-on-tertiary-fixed-variant: rgb(81, 70, 26); - --md-sys-color-surface-dim: rgb(232, 214, 209); - --md-sys-color-surface-bright: rgb(255, 248, 246); - --md-sys-color-surface-container-lowest: rgb(245, 245, 245); - --md-sys-color-surface-container-low: rgb(240, 240, 240); - --md-sys-color-surface-container: #fff; - --md-sys-color-surface-container-high: #fff; - --md-sys-color-surface-container-highest: whitesmoke; + /* --md-sys-color-* tokens are set dynamically by applyColors() in theme.js */ } :root[data-theme='dark'] { @@ -222,9 +229,6 @@ body { --grampsjs-color-skeleton-background: #222; --grampsjs-color-skeleton-base: #232323; --grampsjs-color-skeleton-shine: #1a1a1a; - --grampsjs-color-link-font: #5193f6; - --grampsjs-color-link-hover: #62adf8; - --grampsjs-color-page-loading-progress: #42a6d2; --grampsjs-color-revision-deleted-background-red: rgba(248, 81, 73, 0.3); --grampsjs-color-revision-added-background-green: rgba(46, 160, 67, 0.15); --grampsjs-color-revision-moved-background-blue: rgba(56, 139, 253, 0.1); @@ -238,55 +242,7 @@ body { --grampsjs-color-ytree-default-person-icon-background: var(--md-sys-color-surface-container); --grampsjs-note-color: #ccc; --grampsjs-map-marker-color: #bb1f11; - --md-sys-color-primary: #8d7168; - --md-sys-color-surface-tint: rgb(233, 189, 174); - --md-sys-color-on-primary: rgba(0, 0, 0, 0.8); - --md-sys-color-primary-container: rgb(109, 76, 65); - --md-sys-color-on-primary-container: rgb(235, 190, 176); - --md-sys-color-secondary: rgb(156, 203, 251); - --md-sys-color-on-secondary: rgb(0, 51, 84); - --md-sys-color-secondary-container: rgb(49, 98, 141); - --md-sys-color-on-secondary-container: rgb(189, 220, 255); - --md-sys-color-tertiary: rgb(214, 198, 141); - --md-sys-color-on-tertiary: rgb(57, 48, 5); - --md-sys-color-tertiary-container: rgb(106, 94, 47); - --md-sys-color-on-tertiary-container: rgb(233, 216, 158); - --md-sys-color-error: rgb(255, 180, 171); - --md-sys-color-on-error: rgb(105, 0, 4); - --md-sys-color-error-container: rgb(186, 26, 26); - --md-sys-color-on-error-container: rgb(255, 205, 199); - --md-sys-color-background: rgb(22, 19, 18); - --md-sys-color-on-background: rgb(233, 225, 222); - --md-sys-color-surface: rgb(20, 19, 19); - --md-sys-color-on-surface: rgb(229, 226, 225); - --md-sys-color-surface-variant: rgb(68, 71, 72); - --md-sys-color-on-surface-variant: rgb(196, 199, 200); - --md-sys-color-outline: rgb(142, 145, 146); - --md-sys-color-outline-variant: rgb(68, 71, 72); - --md-sys-color-shadow: rgb(0, 0, 0); - --md-sys-color-scrim: rgb(0, 0, 0); - --md-sys-color-inverse-surface: rgb(229, 226, 225); - --md-sys-color-inverse-on-surface: rgb(49, 48, 48); - --md-sys-color-inverse-primary: rgb(121, 86, 75); - --md-sys-color-primary-fixed: rgb(255, 219, 207); - --md-sys-color-on-primary-fixed: rgb(45, 21, 13); - --md-sys-color-primary-fixed-dim: rgb(233, 189, 174); - --md-sys-color-on-primary-fixed-variant: rgb(94, 63, 53); - --md-sys-color-secondary-fixed: rgb(207, 229, 255); - --md-sys-color-on-secondary-fixed: rgb(0, 29, 51); - --md-sys-color-secondary-fixed-dim: rgb(156, 203, 251); - --md-sys-color-on-secondary-fixed-variant: rgb(17, 74, 115); - --md-sys-color-tertiary-fixed: rgb(243, 226, 167); - --md-sys-color-on-tertiary-fixed: rgb(34, 27, 0); - --md-sys-color-tertiary-fixed-dim: rgb(214, 198, 141); - --md-sys-color-on-tertiary-fixed-variant: rgb(81, 70, 26); - --md-sys-color-surface-dim: rgb(20, 19, 19); - --md-sys-color-surface-bright: rgb(58, 57, 57); - --md-sys-color-surface-container-lowest: rgb(14, 14, 14); - --md-sys-color-surface-container-low: rgb(28, 27, 27); - --md-sys-color-surface-container: rgb(32, 31, 31); - --md-sys-color-surface-container-high: rgb(42, 42, 42); - --md-sys-color-surface-container-highest: rgb(53, 52, 52); + /* --md-sys-color-* tokens are set dynamically by applyColors() in theme.js */ --mdc-select-fill-color: var(--md-sys-color-surface-container-highest); --mdc-select-ink-color: var(--md-sys-color-on-surface); --mdc-select-label-ink-color: rgba(255, 255, 255, 0.5); diff --git a/package-lock.json b/package-lock.json index 7de948a7..d3680e77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@awesome.me/webawesome": "^3.4.0", "@hpcc-js/wasm": "^2.22.4", + "@material/material-color-utilities": "^0.4.0", "@material/mwc-button": "^0.27.0", "@material/mwc-circular-progress": "^0.27.0", "@material/mwc-drawer": "^0.27.0", @@ -2859,6 +2860,11 @@ "tslib": "^2.1.0" } }, + "node_modules/@material/material-color-utilities": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz", + "integrity": "sha512-dlq6VExJReb8dhjj3a/yTigr3ncNwoFmL5Iy2ENtbDX03EmNeOEdZ+vsaGrj7RTuO+mB7L58II4LCsl4NpM8uw==" + }, "node_modules/@material/menu": { "version": "14.0.0-canary.53b3cad2f.0", "resolved": "https://registry.npmjs.org/@material/menu/-/menu-14.0.0-canary.53b3cad2f.0.tgz", @@ -16642,6 +16648,11 @@ "tslib": "^2.1.0" } }, + "@material/material-color-utilities": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.4.0.tgz", + "integrity": "sha512-dlq6VExJReb8dhjj3a/yTigr3ncNwoFmL5Iy2ENtbDX03EmNeOEdZ+vsaGrj7RTuO+mB7L58II4LCsl4NpM8uw==" + }, "@material/menu": { "version": "14.0.0-canary.53b3cad2f.0", "resolved": "https://registry.npmjs.org/@material/menu/-/menu-14.0.0-canary.53b3cad2f.0.tgz", diff --git a/package.json b/package.json index ff286694..e866c236 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "dependencies": { "@awesome.me/webawesome": "^3.4.0", "@hpcc-js/wasm": "^2.22.4", + "@material/material-color-utilities": "^0.4.0", "@material/mwc-button": "^0.27.0", "@material/mwc-circular-progress": "^0.27.0", "@material/mwc-drawer": "^0.27.0", @@ -163,4 +164,4 @@ "pwa-helpers": "^0.9.1", "tippy.js": "^6.3.7" } -} \ No newline at end of file +} diff --git a/src/GrampsJs.js b/src/GrampsJs.js index edaccd72..1168b5cf 100644 --- a/src/GrampsJs.js +++ b/src/GrampsJs.js @@ -39,7 +39,7 @@ import './components/GrampsjsUndoTransaction.js' import './components/GrampsjsUpdateAvailable.js' import './components/GrampsjsUpgradeDb.js' import {sharedStyles} from './SharedStyles.js' -import {applyTheme} from './theme.js' +import {applyScheme, DEFAULT_PRIMARY, DEFAULT_SECONDARY} from './theme.js' import {handleOIDCCallback, handleOIDCComplete} from './oidc.js' const LOADING_STATE_INITIAL = 0 @@ -661,10 +661,18 @@ export class GrampsJs extends LitElement { this._loadFrontendStrings(this.appState.settings.lang) } - applyTheme(this.appState.settings.theme) + applyScheme( + DEFAULT_PRIMARY, + DEFAULT_SECONDARY, + this.appState.settings.theme + ) const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') mediaQuery.addEventListener('change', () => - applyTheme(this.appState.settings.theme) + applyScheme( + DEFAULT_PRIMARY, + DEFAULT_SECONDARY, + this.appState.settings.theme + ) ) } diff --git a/src/theme.js b/src/theme.js index f07a7885..65dddf28 100644 --- a/src/theme.js +++ b/src/theme.js @@ -1,3 +1,16 @@ +import { + DynamicScheme, + Hct, + MaterialDynamicColors, + TonalPalette, + Variant, + argbFromHex, + hexFromArgb, +} from '@material/material-color-utilities' + +export const DEFAULT_PRIMARY = '#6d4c41' +export const DEFAULT_SECONDARY = '#0277bd' + export function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' @@ -8,6 +21,100 @@ export function getCurrentTheme(theme) { return theme === undefined || theme === 'system' ? getSystemTheme() : theme } -export function applyTheme(theme) { - document.documentElement.setAttribute('data-theme', getCurrentTheme(theme)) +function toKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() +} + +export function applyColors( + primaryHex = DEFAULT_PRIMARY, + secondaryHex = DEFAULT_SECONDARY, + isDark = false +) { + const primaryHct = Hct.fromInt(argbFromHex(primaryHex)) + const secondaryHct = Hct.fromInt(argbFromHex(secondaryHex)) + const secondaryPalette = TonalPalette.fromHueAndChroma( + secondaryHct.hue, + secondaryHct.chroma + ) + + const primaryPalette = TonalPalette.fromHueAndChroma( + primaryHct.hue, + primaryHct.chroma + ) + + const scheme = new DynamicScheme({ + sourceColorHct: primaryHct, + variant: Variant.TONAL_SPOT, + isDark, + contrastLevel: 0, + // Preserve the seed's own hue+chroma rather than TONAL_SPOT's fixed values. + primaryPalette, + secondaryPalette, + // Chroma 0 → pure neutral grays; no hue tint bleeds into surfaces. + neutralPalette: TonalPalette.fromHueAndChroma(primaryHct.hue, 0), + neutralVariantPalette: TonalPalette.fromHueAndChroma(primaryHct.hue, 0), + }) + + const root = document.documentElement + for (const [name, color] of Object.entries(MaterialDynamicColors)) { + if (typeof color?.getArgb !== 'function') continue + root.style.setProperty( + `--md-sys-color-${toKebabCase(name)}`, + hexFromArgb(color.getArgb(scheme)) + ) + } + + // Primary: use seed's own tone clamped to [30, 50] so users see their chosen + // colour on the app bar. Tone 30-50 always reads white on-primary text. + const primaryTone = isDark + ? Math.min(80, Math.max(60, Math.round(primaryHct.tone))) + : Math.min(50, Math.max(30, Math.round(primaryHct.tone))) + root.style.setProperty( + '--md-sys-color-primary', + hexFromArgb(primaryPalette.tone(primaryTone)) + ) + + // M3 achromatic tones 90-98 are darker than expected for a web app. + // Override light-mode surfaces to lighter neutrals. + if (!isDark) { + root.style.setProperty('--md-sys-color-background', '#ffffff') + root.style.setProperty('--md-sys-color-surface', '#ffffff') + // lowest/low/container → menus, cards, sheets: white + root.style.setProperty('--md-sys-color-surface-container-lowest', '#ffffff') + root.style.setProperty('--md-sys-color-surface-container-low', '#ffffff') + root.style.setProperty('--md-sys-color-surface-container', '#ffffff') + // high/highest → filled input and select backgrounds: light gray + root.style.setProperty( + '--md-sys-color-surface-container-high', + 'rgb(240, 240, 240)' + ) + root.style.setProperty( + '--md-sys-color-surface-container-highest', + 'rgb(235, 235, 235)' + ) + } + + // Action color for edit/add/delete buttons: clamp tone to a readable range + // so very light or very dark seeds still contrast against their background. + const actionTone = isDark + ? Math.min(80, Math.max(60, Math.round(secondaryHct.tone))) + : Math.min(55, Math.max(30, Math.round(secondaryHct.tone))) + root.style.setProperty( + '--mdc-theme-secondary', + hexFromArgb(secondaryPalette.tone(actionTone)) + ) + root.style.setProperty( + '--mdc-theme-on-secondary', + actionTone < 60 ? 'rgba(255, 255, 255, 0.95)' : 'rgba(0, 0, 0, 0.87)' + ) +} + +export function applyScheme( + primaryHex = DEFAULT_PRIMARY, + secondaryHex = DEFAULT_SECONDARY, + theme +) { + const isDark = getCurrentTheme(theme) === 'dark' + document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light') + applyColors(primaryHex, secondaryHex, isDark) } diff --git a/src/views/GrampsjsViewSettingsUser.js b/src/views/GrampsjsViewSettingsUser.js index 8986ff83..3f66e0ca 100644 --- a/src/views/GrampsjsViewSettingsUser.js +++ b/src/views/GrampsjsViewSettingsUser.js @@ -18,7 +18,7 @@ import {GrampsjsView} from './GrampsjsView.js' import {mdiCheck, mdiContentCopy} from '@mdi/js' import {__APIHOST__} from '../api.js' import {fireEvent} from '../util.js' -import {applyTheme} from '../theme.js' +import {applyScheme, DEFAULT_PRIMARY, DEFAULT_SECONDARY} from '../theme.js' import {DEFAULT_TREE_VIEW, TREE_VIEWS} from '../treeDefaults.js' export class GrampsjsViewSettingsUser extends GrampsjsView { @@ -186,7 +186,7 @@ export class GrampsjsViewSettingsUser extends GrampsjsView { _handleThemeSelected(event) { const theme = event.target.value this.appState.updateSettings({theme}) - applyTheme(theme) + applyScheme(DEFAULT_PRIMARY, DEFAULT_SECONDARY, theme) } renderTreePreferences() { diff --git a/vitest.config.js b/vitest.config.js index af99b03d..342315f6 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -8,5 +8,12 @@ export default defineConfig({ reporter: ['text', 'lcov'], include: ['src/**/*.js'], }, + server: { + deps: { + // ESM imports in this package lack .js extensions, which Node.js + // strict ESM requires. Inline through Vite's bundler instead. + inline: ['@material/material-color-utilities'], + }, + }, }, })