diff --git a/app/assets/images/monitor.svg b/app/assets/images/monitor.svg new file mode 100644 index 000000000..a1140a2a8 --- /dev/null +++ b/app/assets/images/monitor.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/moon.svg b/app/assets/images/moon.svg new file mode 100644 index 000000000..8db6c1b09 --- /dev/null +++ b/app/assets/images/moon.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/sun.svg b/app/assets/images/sun.svg new file mode 100644 index 000000000..6dd6e54c8 --- /dev/null +++ b/app/assets/images/sun.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 52aa53805..9e4a9f8bf 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -255,8 +255,117 @@ --color-gradient-2: oklch(var(--lch-pink-lighter)); --color-gradient-3: oklch(var(--lch-purple-lighter)); --color-gradient-4: var(--color-canvas); +} - @media (prefers-color-scheme: dark) { +/* Dark mode - explicit theme choice overrides system preference */ +html[data-theme="dark"] { + --lch-canvas: 20% 0.0195 232.58; + --lch-ink-inverted: var(--lch-black); + + --lch-ink-darkest: 96.02% 0.0034 260; + --lch-ink-darker: 86% 0.0061 260; + --lch-ink-dark: 73.97% 0.009 260; + --lch-ink-medium: 62% 0.0122 260; + --lch-ink-light: 40% 0.0148 260; + --lch-ink-lighter: 30% 0.0178 260; + --lch-ink-lightest: 25% 0.0204 260; + + --lch-uncolor-darkest: 96.09% 0.0076 100; + --lch-uncolor-darker: 86% 0.021 90; + --lch-uncolor-dark: 73.93% 0.041 80; + --lch-uncolor-medium: 62% 0.0552 70; + --lch-uncolor-light: 40% 0.0387 60; + --lch-uncolor-lighter: 30% 0.012 50; + --lch-uncolor-lightest: 25% 0.0017 40; + + --lch-red-darkest: 95.85% 0.0218 46; + --lch-red-darker: 86% 0.086 44; + --lch-red-dark: 73.95% 0.139 42; + --lch-red-medium: 62% 0.154 40; + --lch-red-light: 40% 0.088 38; + --lch-red-lighter: 30% 0.032 36; + --lch-red-lightest: 25% 0.011 34; + + --lch-yellow-darkest: 96% 0.056 100; + --lch-yellow-darker: 86% 0.103 90; + --lch-yellow-dark: 74.06% 0.136 80; + --lch-yellow-medium: 62.1% 0.146 70; + --lch-yellow-light: 40% 0.0736 60; + --lch-yellow-lighter: 30% 0.026 50; + --lch-yellow-lightest: 25% 0.01 40; + + --lch-lime-darkest: 96.04% 0.066 115; + --lch-lime-darker: 86% 0.098 114; + --lch-lime-dark: 73.97% 0.121 113; + --lch-lime-medium: 62% 0.128 112; + --lch-lime-light: 40% 0.0637 111; + --lch-lime-lighter: 30% 0.024 110; + --lch-lime-lightest: 25% 0.012 109; + + --lch-green-darkest: 96.12% 0.035 143; + --lch-green-darker: 86% 0.082 144; + --lch-green-dark: 73.99% 0.117 145; + --lch-green-medium: 62% 0.1261 146; + --lch-green-light: 40% 0.065 147; + --lch-green-lighter: 30% 0.03 148; + --lch-green-lightest: 25% 0.018 149; + + --lch-aqua-darkest: 96.15% 0.0244 202; + --lch-aqua-darker: 86% 0.06 204; + --lch-aqua-dark: 73.92% 0.095 206; + --lch-aqua-medium: 62% 0.106 208; + --lch-aqua-light: 40% 0.0594 210; + --lch-aqua-lighter: 30% 0.028 212; + --lch-aqua-lightest: 25% 0.017 214; + + --lch-blue-darkest: 95.93% 0.0217 252; + --lch-blue-darker: 86% 0.068 254; + --lch-blue-dark: 74% 0.1293 256; + --lch-blue-medium: 62% 0.159 258; + --lch-blue-light: 40% 0.094 260; + --lch-blue-lighter: 30% 0.0452 262; + --lch-blue-lightest: 25% 0.0318 264; + + --lch-violet-darkest: 95.97% 0.019 280; + --lch-violet-darker: 86% 0.068 282; + --lch-violet-dark: 74.08% 0.142 284; + --lch-violet-medium: 62% 0.184 286; + --lch-violet-light: 40% 0.108 288; + --lch-violet-lighter: 30% 0.048 290; + --lch-violet-lightest: 25% 0.025 292; + + --lch-purple-darkest: 95.99% 0.0217 302; + --lch-purple-darker: 86% 0.068 304; + --lch-purple-dark: 73.98% 0.141 306; + --lch-purple-medium: 62% 0.177 308; + --lch-purple-light: 40% 0.099 310; + --lch-purple-lighter: 30% 0.04 312; + --lch-purple-lightest: 25% 0.017 314; + + --lch-pink-darkest: 95.84% 0.0308 336; + --lch-pink-darker: 86% 0.074 338; + --lch-pink-dark: 74.04% 0.1294 340; + --lch-pink-medium: 62% 0.166 342; + --lch-pink-light: 40% 0.085 344; + --lch-pink-lighter: 30% 0.03 346; + --lch-pink-lightest: 25% 0.011 348; + + --color-terminal-bg: var(--color-canvas); + --color-terminal-text-light: oklch(var(--lch-green-dark)); + --color-golden: oklch(var(--lch-blue-medium)); + --color-highlight: oklch(var(--lch-blue-lighter)); + + --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), + 0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), + 0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7), + 0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8), + 0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9), + 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1); +} + +/* Fallback to system preference when no explicit theme is set */ +@media (prefers-color-scheme: dark) { + html:not([data-theme]) { --lch-canvas: 20% 0.0195 232.58; --lch-ink-inverted: var(--lch-black); diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css index 86183ccb0..e13badb7b 100644 --- a/app/assets/stylesheets/bar.css +++ b/app/assets/stylesheets/bar.css @@ -19,10 +19,16 @@ view-transition-name: bar; z-index: var(--z-bar); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { border-block: 1px solid var(--color-ink-lighter); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + border-block: 1px solid var(--color-ink-lighter); + } + } + &:has(.bar__placeholder[hidden]) { padding-inline: 1ch; } diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 31c4180e1..05d2b016e 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -55,9 +55,15 @@ ::selection { background: var(--color-selected); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { background-color: var(--color-selected-dark); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + background-color: var(--color-selected-dark); + } + } } :where(ul, ol):where([role="list"]) { diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 274d80ceb..4ee5f199e 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -27,10 +27,16 @@ } } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --btn-hover-brightness: 1.25; } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --btn-hover-brightness: 1.25; + } + } + &[disabled], &:has([disabled]), [disabled] &[type=submit], diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index ac690ac30..19084f65c 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -179,9 +179,15 @@ outline: var(--focus-ring-size) solid var(--color-selected-dark); outline-offset: var(--focus-ring-offset); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { outline-color: oklch(var(--lch-blue-medium)); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + outline-color: oklch(var(--lch-blue-medium)); + } + } } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 9fe9c6601..3fb0a7f4d 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -27,10 +27,16 @@ text-align: start; z-index: 1; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + .popup { inline-size: 260px; } diff --git a/app/assets/stylesheets/circled-text.css b/app/assets/stylesheets/circled-text.css index 9c3e87f48..dcb971d51 100644 --- a/app/assets/stylesheets/circled-text.css +++ b/app/assets/stylesheets/circled-text.css @@ -12,9 +12,15 @@ opacity: 0.5; mix-blend-mode: multiply; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { mix-blend-mode: screen; } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + mix-blend-mode: screen; + } + } } span::before, diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 8e9f19e89..2e269b9d0 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -72,6 +72,8 @@ .icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); } .icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); } .icon--minus { --svg: url("minus.svg "); } + .icon--monitor { --svg: url("monitor.svg "); } + .icon--moon { --svg: url("moon.svg "); } .icon--move { --svg: url("move.svg "); } .icon--notification-bell-access-only { --svg: url("bell.svg "); } .icon--notification-bell-watching { --svg: url("bell-off.svg "); } @@ -96,6 +98,7 @@ .icon--settings { --svg: url("settings.svg "); } .icon--share { --svg: url("share.svg "); } .icon--sliders { --svg: url("sliders.svg "); } + .icon--sun { --svg: url("sun.svg "); } .icon--switch { --svg: url("switch.svg "); } .icon--tag { --svg: url("tag.svg "); } .icon--tag-outline { --svg: url("tag-outline.svg "); } diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 109bfbb26..252ef2835 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -104,17 +104,25 @@ .input--select { --input-border-radius: 2em; --input-padding: 0.5em 1.8em 0.5em 1.2em; + --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); + --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); -webkit-appearance: none; appearance: none; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); + background-image: var(--caret-icon); background-size: 0.5em; background-position: center right 0.9em; background-repeat: no-repeat; text-align: start; + html[data-theme="dark"] & { + --caret-icon: var(--caret-icon-dark); + } + @media (prefers-color-scheme: dark) { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + html:not([data-theme]) & { + --caret-icon: var(--caret-icon-dark); + } } option { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index f89028f02..c90d5f6b9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -55,10 +55,13 @@ } .lexxy-code-language-picker { + --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); + --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + -webkit-appearance: none; appearance: none; background-color: var(--color-canvas); - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); + background-image: var(--caret-icon); background-position: center right 0.9em; background-repeat: no-repeat; background-size: 0.5em; @@ -75,8 +78,14 @@ padding-inline: 1.5ch 1.8em; text-align: start; + html[data-theme="dark"] & { + --caret-icon: var(--caret-icon-dark); + } + @media (prefers-color-scheme: dark) { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + html:not([data-theme]) & { + --caret-icon: var(--caret-icon-dark); + } } option { diff --git a/app/assets/stylesheets/markdown.css b/app/assets/stylesheets/markdown.css index 5dfac832c..f6aef0e39 100644 --- a/app/assets/stylesheets/markdown.css +++ b/app/assets/stylesheets/markdown.css @@ -24,8 +24,15 @@ } } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { filter: invert(1); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + filter: invert(1); + } + } + } } diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index b50189366..bd56c45e3 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -23,9 +23,15 @@ background: var(--color-ink-lighter); } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --input-background: var(--color-ink-lighter); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --input-background: var(--color-ink-lighter); + } + } } } diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index ed6187bae..797e9a7cb 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -95,9 +95,16 @@ &:not(.expanded):hover { filter: brightness(var(--reaction-hover-brightness)); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --reaction-hover-brightness: 1.25; } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --reaction-hover-brightness: 1.25; + } + } + } } } diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/syntax.css index 7ddc2cf42..7da03d67b 100644 --- a/app/assets/stylesheets/syntax.css +++ b/app/assets/stylesheets/syntax.css @@ -14,7 +14,7 @@ --markup-deleted: lch(39.64 68.17 31.45); /* Redefine named color values for dark mode */ - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] { --keyword: lch(67.63 58.99 30.64); --entity: lch(75.13 46.73 306.74); --constant: lch(74.9 39.71 255.53); @@ -28,6 +28,22 @@ --markup-deleted: lch(73.8% 65 29.18); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) { + --keyword: lch(67.63 58.99 30.64); + --entity: lch(75.13 46.73 306.74); + --constant: lch(74.9 39.71 255.53); + --string: lch(74.9 39.71 255.53); + --variable: lch(76.17 61.1 61.97); + --comment: lch(60.83 6.66 254.46); + --entity-tag: lch(83.65 59.31 141.61); + --markup-heading: lch(47.93 71.67 280.72); + --markup-list: lch(83.84 57.9 85.03); + --markup-inserted: lch(83.65 59.31 141.61); + --markup-deleted: lch(73.8% 65 29.18); + } + } + color: var(--color-ink); .w { diff --git a/app/assets/stylesheets/theme-switcher.css b/app/assets/stylesheets/theme-switcher.css new file mode 100644 index 000000000..4b3b1bae2 --- /dev/null +++ b/app/assets/stylesheets/theme-switcher.css @@ -0,0 +1,33 @@ +@layer components { + .theme-switcher { + @media (max-width: 479px) { + --row-gap: 1ch; + + flex-direction: column; + } + } + + .theme-switcher__btn { + --btn-background: var(--color-ink-lightest); + --btn-border-radius: 0.4em; + --btn-border-size: 0; + --btn-gap: 0.1lh; + --btn-padding: 1em; + --icon-size: 2em; + + column-gap: var(--inline-space); + flex: 1; + flex-direction: column; + position: relative; + white-space: nowrap; + + &:has(input:checked) { + --btn-background: var(--color-selected); + --btn-color: var(--color-ink); + } + + @media (max-width: 479px) { + flex-direction: row; + } + } +} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 46c4b74b7..3cefc1ecc 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -300,9 +300,16 @@ border-radius: 0; } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + } .card__background { diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 000000000..154bf4fc0 --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["lightButton", "darkButton", "autoButton"] + + connect() { + this.#applyStoredTheme() + } + + setLight() { + this.#theme = "light" + } + + setDark() { + this.#theme = "dark" + } + + setAuto() { + this.#theme = "auto" + } + + get #storedTheme() { + return localStorage.getItem("theme") || "auto" + } + + set #theme(theme) { + localStorage.setItem("theme", theme) + + if (theme === "auto") { + document.documentElement.removeAttribute("data-theme") + } else { + document.documentElement.setAttribute("data-theme", theme) + } + + this.#updateButtons() + } + + #applyStoredTheme() { + this.#theme = this.#storedTheme + } + + #updateButtons() { + const storedTheme = this.#storedTheme + + if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") } + if (this.hasDarkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") } + if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") } + } +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d2af3e959..6aef1f1bc 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -2,7 +2,7 @@ <%= render "layouts/shared/head" %> -
+Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
+Use this link to sign-in on another device
-