kaze 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 634436bd289b2a1cd2dfbab0a0f0ae2e4c4490d26cc5e6b4156ad382471eccf4
4
- data.tar.gz: 1467cca2c81b4af1579a576f1e204c119e7220db5e08405fa0c15585545e07b7
3
+ metadata.gz: cc7bad365960dce77e78e930ea414a8bdcf3d03887393200bfde19a7c0299fe4
4
+ data.tar.gz: 7f3a5dffd4210452b1a3da62604e1d4b060c9659b9d596c10778db420b5fc034
5
5
  SHA512:
6
- metadata.gz: 52a79402f90898407a6f4a450e64fbbf19bb4980e5d5ce5fa452debafff0029226de9da94c220074a59412834b76f94554b282d721d0eba2624ad65472252f27
7
- data.tar.gz: 1b42a36de58fdc0ab68c45fae9b797900affe91097899c17710a4e3dea23fd95c56ccbf278225f714c4d49a3e2125ef1b080e895593b79337c564e9a742e2b5e
6
+ metadata.gz: cc6441a4bae8a2e94d4662a00d2c4dc302cfe9b425b15f112f7c9fdb1d0cf218d860fdcf503bf2d5f4bb92e66b0bf1b7cccf517b663606a2535a48a8a53d0853
7
+ data.tar.gz: ed22d4a753d6a7acdc48d59ca3ef0c76838129a467811ca52c608db891e9c9ecde792ebb37765bed82a635837ce51498dca10607da8a3ddf76374aef7b4bf986
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.4.0] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - An appearance preference — light, dark or system — on the profile page of all three stacks ([#26](https://github.com/cuonggt/kaze/pull/26)). The stubs already carried 315 `dark:` classes, but nothing could reach them: `dark:` followed `prefers-color-scheme`, so an application took whatever the operating system said. The choice is stored in `localStorage`, and system follows the device and tracks it live.
14
+
15
+ Each stylesheet now redefines the variant, since Tailwind v4 no longer configures dark mode in JavaScript:
16
+
17
+ ```css
18
+ @custom-variant dark (&:where(.dark, .dark *));
19
+ ```
20
+
21
+ The layouts apply the resulting `dark` class in an inline `<head>` script before first paint, so the page never flashes the wrong theme. A consequence worth knowing: the theme now depends on JavaScript, so with scripting disabled an application always renders light where it previously followed the operating system.
22
+
23
+ ### Fixed
24
+
25
+ - Dark mode gaps that were already broken for anyone whose operating system was set to dark, and which the preference control made obvious. React's `AuthenticatedLayout` carried one `dark:` class where Vue's had fifteen, leaving a white navigation bar and header above a dark page; the missing variants are ported from the Vue layout. Four wrappers lacked `dark:bg-gray-900`, leaving a light grey page behind dark cards — hotwire's application layout, React's `AuthenticatedLayout` and `GuestLayout`, and the Inertia ERB wrappers for both stacks.
26
+
9
27
  ## [1.3.1] - 2026-10-21
10
28
 
11
29
  ### Changed
@@ -108,7 +126,8 @@ Together these take a freshly scaffolded Inertia application from seven npm advi
108
126
 
109
127
  Releases before 1.0.0 are not catalogued here. See the [tags](https://github.com/cuonggt/kaze/tags) for their history.
110
128
 
111
- [Unreleased]: https://github.com/cuonggt/kaze/compare/v1.3.1...HEAD
129
+ [Unreleased]: https://github.com/cuonggt/kaze/compare/v1.4.0...HEAD
130
+ [1.4.0]: https://github.com/cuonggt/kaze/compare/v1.3.1...v1.4.0
112
131
  [1.3.1]: https://github.com/cuonggt/kaze/compare/v1.3.0...v1.3.1
113
132
  [1.3.0]: https://github.com/cuonggt/kaze/compare/v1.2.1...v1.3.0
114
133
  [1.2.1]: https://github.com/cuonggt/kaze/compare/v1.2.0...v1.2.1
data/lib/kaze/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kaze
2
- VERSION = '1.3.1'
2
+ VERSION = '1.4.0'
3
3
  end
@@ -1,5 +1,10 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* Drive `dark:` from a `dark` class rather than prefers-color-scheme, so the
4
+ appearance preference can override the operating system. The layouts apply
5
+ the class before first paint. */
6
+ @custom-variant dark (&:where(.dark, .dark *));
7
+
3
8
  @plugin "@tailwindcss/forms";
4
9
 
5
10
  @source "../../../public/*.html";
@@ -0,0 +1,18 @@
1
+ <%# Applies the stored appearance before first paint, so the page never flashes
2
+ the wrong theme. Also exposed as window.applyAppearance so the preference
3
+ control can re-apply it after a change. %>
4
+ <%= javascript_tag nonce: true do %>
5
+ window.applyAppearance = function () {
6
+ var preference = localStorage.getItem('appearance') || 'system'
7
+ var dark = preference === 'dark' ||
8
+ (preference === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
9
+
10
+ document.documentElement.classList.toggle('dark', dark)
11
+ }
12
+
13
+ window.applyAppearance()
14
+
15
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
16
+ window.applyAppearance()
17
+ })
18
+ <% end %>
@@ -8,12 +8,13 @@
8
8
  <%= csp_meta_tag %>
9
9
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": Rails.env.production? ? "reload" : "" %>
10
10
  <%= stylesheet_link_tag "application", "data-turbo-track": Rails.env.production? ? "reload" : "" %>
11
+ <%= render "layouts/appearance" %>
11
12
  <%= javascript_importmap_tags %>
12
13
  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
13
14
  <%= yield :head %>
14
15
  </head>
15
16
  <body class="font-sans antialiased">
16
- <div class="min-h-screen bg-gray-100">
17
+ <div class="min-h-screen bg-gray-100 dark:bg-gray-900">
17
18
  <%= render "layouts/navigation" %>
18
19
  <!-- Page Heading -->
19
20
  <% if content_for?(:header) %>
@@ -8,6 +8,7 @@
8
8
  <%= csp_meta_tag %>
9
9
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": Rails.env.production? ? "reload" : "" %>
10
10
  <%= stylesheet_link_tag "application", "data-turbo-track": Rails.env.production? ? "reload" : "" %>
11
+ <%= render "layouts/appearance" %>
11
12
  <%= javascript_importmap_tags %>
12
13
  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
13
14
  </head>
@@ -5,6 +5,11 @@
5
5
  <% end %>
6
6
  <div class="py-12">
7
7
  <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
8
+ <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
9
+ <div class="max-w-xl">
10
+ <%= render "profile/partials/update_appearance_form" %>
11
+ </div>
12
+ </div>
8
13
  <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
9
14
  <div class="max-w-xl">
10
15
  <%= render "profile/partials/update_profile_information_form" %>
@@ -0,0 +1,39 @@
1
+ <section
2
+ x-data="{
3
+ preference: localStorage.getItem('appearance') || 'system',
4
+ select(value) {
5
+ this.preference = value
6
+ localStorage.setItem('appearance', value)
7
+ window.applyAppearance()
8
+ }
9
+ }"
10
+ >
11
+ <header>
12
+ <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
13
+ Appearance
14
+ </h2>
15
+
16
+ <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
17
+ Choose how the application looks to you. System follows your device setting.
18
+ </p>
19
+ </header>
20
+
21
+ <div class="mt-6 inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-900">
22
+ <template x-for="option in [
23
+ { value: 'light', label: 'Light' },
24
+ { value: 'dark', label: 'Dark' },
25
+ { value: 'system', label: 'System' }
26
+ ]" :key="option.value">
27
+ <button
28
+ type="button"
29
+ @click="select(option.value)"
30
+ :aria-pressed="preference === option.value"
31
+ class="rounded-md px-4 py-1.5 text-sm font-medium transition focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
32
+ :class="preference === option.value
33
+ ? 'bg-white text-gray-900 shadow-xs dark:bg-gray-700 dark:text-gray-100'
34
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'"
35
+ x-text="option.label"
36
+ ></button>
37
+ </template>
38
+ </div>
39
+ </section>
@@ -8,6 +8,7 @@
8
8
  <%= csp_meta_tag %>
9
9
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": Rails.env.production? ? "reload" : "" %>
10
10
  <%= stylesheet_link_tag "application", "data-turbo-track": Rails.env.production? ? "reload" : "" %>
11
+ <%= render "layouts/appearance" %>
11
12
  <%= javascript_importmap_tags %>
12
13
  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
13
14
  </head>
@@ -1,5 +1,10 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* Drive `dark:` from a `dark` class rather than prefers-color-scheme, so the
4
+ appearance preference can override the operating system. The layouts apply
5
+ the class before first paint. */
6
+ @custom-variant dark (&:where(.dark, .dark *));
7
+
3
8
  @plugin "@tailwindcss/forms";
4
9
 
5
10
  @source "../../../public/*.html";
@@ -14,14 +14,14 @@ export default function AuthenticatedLayout({ header, children }: PropsWithChild
14
14
  const { pathname = '' } = typeof window !== 'undefined' ? window.location : {}
15
15
 
16
16
  return (
17
- <div className="min-h-screen bg-gray-100">
18
- <nav className="bg-white border-b border-gray-100">
17
+ <div className="min-h-screen bg-gray-100 dark:bg-gray-900">
18
+ <nav className="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
19
19
  <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
20
20
  <div className="flex justify-between h-16">
21
21
  <div className="flex">
22
22
  <div className="shrink-0 flex items-center">
23
23
  <Link href="/">
24
- <ApplicationLogo className="block h-9 w-auto fill-current text-red-800" />
24
+ <ApplicationLogo className="block h-9 w-auto fill-current text-red-800 dark:text-red-200" />
25
25
  </Link>
26
26
  </div>
27
27
 
@@ -39,7 +39,7 @@ export default function AuthenticatedLayout({ header, children }: PropsWithChild
39
39
  <span className="inline-flex rounded-md">
40
40
  <button
41
41
  type="button"
42
- className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-hidden transition ease-in-out duration-150"
42
+ className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-hidden transition ease-in-out duration-150"
43
43
  >
44
44
  {user.name}
45
45
 
@@ -72,7 +72,7 @@ export default function AuthenticatedLayout({ header, children }: PropsWithChild
72
72
  <div className="-me-2 flex items-center sm:hidden">
73
73
  <button
74
74
  onClick={() => setShowingNavigationDropdown((previousState) => !previousState)}
75
- className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out"
75
+ className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-hidden focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out"
76
76
  >
77
77
  <svg className="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
78
78
  <path
@@ -102,9 +102,9 @@ export default function AuthenticatedLayout({ header, children }: PropsWithChild
102
102
  </ResponsiveNavLink>
103
103
  </div>
104
104
 
105
- <div className="pt-4 pb-1 border-t border-gray-200">
105
+ <div className="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
106
106
  <div className="px-4">
107
- <div className="font-medium text-base text-gray-800">{user.name}</div>
107
+ <div className="font-medium text-base text-gray-800 dark:text-gray-200">{user.name}</div>
108
108
  <div className="font-medium text-sm text-gray-500">{user.email}</div>
109
109
  </div>
110
110
 
@@ -119,7 +119,7 @@ export default function AuthenticatedLayout({ header, children }: PropsWithChild
119
119
  </nav>
120
120
 
121
121
  {header && (
122
- <header className="bg-white shadow-sm">
122
+ <header className="bg-white dark:bg-gray-800 shadow-sm">
123
123
  <div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">{header}</div>
124
124
  </header>
125
125
  )}
@@ -4,7 +4,7 @@ import { PropsWithChildren } from 'react'
4
4
 
5
5
  export default function GuestLayout({ children }: PropsWithChildren) {
6
6
  return (
7
- <div className="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
7
+ <div className="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
8
8
  <div>
9
9
  <Link href="/">
10
10
  <ApplicationLogo className="w-20 h-20 fill-current text-red-500" />
@@ -1,5 +1,6 @@
1
1
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'
2
2
  import DeleteUserForm from './Partials/DeleteUserForm'
3
+ import UpdateAppearanceForm from './Partials/UpdateAppearanceForm'
3
4
  import UpdatePasswordForm from './Partials/UpdatePasswordForm'
4
5
  import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm'
5
6
  import { Head } from '@inertiajs/react'
@@ -14,6 +15,10 @@ export default function Edit({ mustVerifyEmail, status }: PageProps<{ mustVerify
14
15
 
15
16
  <div className="py-12">
16
17
  <div className="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
18
+ <div className="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
19
+ <UpdateAppearanceForm className="max-w-xl" />
20
+ </div>
21
+
17
22
  <div className="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
18
23
  <UpdateProfileInformationForm mustVerifyEmail={mustVerifyEmail} status={status} className="max-w-xl" />
19
24
  </div>
@@ -0,0 +1,47 @@
1
+ import { useState } from 'react'
2
+ import { Appearance, getAppearance, setAppearance } from '@/lib/appearance'
3
+
4
+ const options: { value: Appearance; label: string }[] = [
5
+ { value: 'light', label: 'Light' },
6
+ { value: 'dark', label: 'Dark' },
7
+ { value: 'system', label: 'System' },
8
+ ]
9
+
10
+ export default function UpdateAppearanceForm({ className = '' }: { className?: string }) {
11
+ const [appearance, setCurrentAppearance] = useState<Appearance>(getAppearance)
12
+
13
+ const select = (value: Appearance) => {
14
+ setCurrentAppearance(value)
15
+ setAppearance(value)
16
+ }
17
+
18
+ return (
19
+ <section className={className}>
20
+ <header>
21
+ <h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">Appearance</h2>
22
+
23
+ <p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
24
+ Choose how the application looks to you. System follows your device setting.
25
+ </p>
26
+ </header>
27
+
28
+ <div className="mt-6 inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-900">
29
+ {options.map((option) => (
30
+ <button
31
+ key={option.value}
32
+ type="button"
33
+ onClick={() => select(option.value)}
34
+ aria-pressed={appearance === option.value}
35
+ className={`rounded-md px-4 py-1.5 text-sm font-medium transition focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 ${
36
+ appearance === option.value
37
+ ? 'bg-white text-gray-900 shadow-xs dark:bg-gray-700 dark:text-gray-100'
38
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
39
+ }`}
40
+ >
41
+ {option.label}
42
+ </button>
43
+ ))}
44
+ </div>
45
+ </section>
46
+ )
47
+ }
@@ -0,0 +1,23 @@
1
+ export type Appearance = 'light' | 'dark' | 'system'
2
+
3
+ const STORAGE_KEY = 'appearance'
4
+
5
+ export function getAppearance(): Appearance {
6
+ const stored = localStorage.getItem(STORAGE_KEY)
7
+
8
+ return stored === 'light' || stored === 'dark' ? stored : 'system'
9
+ }
10
+
11
+ export function applyAppearance(appearance: Appearance = getAppearance()): void {
12
+ const dark =
13
+ appearance === 'dark' ||
14
+ (appearance === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
15
+
16
+ document.documentElement.classList.toggle('dark', dark)
17
+ }
18
+
19
+ export function setAppearance(appearance: Appearance): void {
20
+ localStorage.setItem(STORAGE_KEY, appearance)
21
+
22
+ applyAppearance(appearance)
23
+ }
@@ -6,12 +6,30 @@
6
6
  <title><%= content_for(:title) || "Rails" %></title>
7
7
  <%= csrf_meta_tags %>
8
8
  <%= csp_meta_tag %>
9
+ <%# Apply the stored appearance before first paint, so the page never flashes the wrong theme. %>
10
+ <%= javascript_tag nonce: true do %>
11
+ (function () {
12
+ var preference = localStorage.getItem('appearance') || 'system'
13
+ var query = window.matchMedia('(prefers-color-scheme: dark)')
14
+
15
+ document.documentElement.classList.toggle(
16
+ 'dark',
17
+ preference === 'dark' || (preference === 'system' && query.matches)
18
+ )
19
+
20
+ query.addEventListener('change', function () {
21
+ if ((localStorage.getItem('appearance') || 'system') === 'system') {
22
+ document.documentElement.classList.toggle('dark', query.matches)
23
+ }
24
+ })
25
+ })()
26
+ <% end %>
9
27
  <%= vite_client_tag %>
10
28
  <%= vite_react_refresh_tag %>
11
29
  <%= vite_typescript_tag 'application.tsx', defer: true %>
12
30
  </head>
13
31
  <body class="font-sans antialiased">
14
- <div class="min-h-screen bg-gray-100">
32
+ <div class="min-h-screen bg-gray-100 dark:bg-gray-900">
15
33
  <%= yield %>
16
34
  </div>
17
35
  </body>
@@ -1,5 +1,10 @@
1
1
  @import "tailwindcss";
2
2
 
3
+ /* Drive `dark:` from a `dark` class rather than prefers-color-scheme, so the
4
+ appearance preference can override the operating system. The layouts apply
5
+ the class before first paint. */
6
+ @custom-variant dark (&:where(.dark, .dark *));
7
+
3
8
  @plugin "@tailwindcss/forms";
4
9
 
5
10
  @source "../../../public/*.html";
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
3
3
  import DeleteUserForm from './Partials/DeleteUserForm.vue'
4
+ import UpdateAppearanceForm from './Partials/UpdateAppearanceForm.vue'
4
5
  import UpdatePasswordForm from './Partials/UpdatePasswordForm.vue'
5
6
  import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm.vue'
6
7
  import { Head } from '@inertiajs/vue3'
@@ -21,6 +22,10 @@ defineProps<{
21
22
 
22
23
  <div class="py-12">
23
24
  <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
25
+ <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
26
+ <UpdateAppearanceForm class="max-w-xl" />
27
+ </div>
28
+
24
29
  <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
25
30
  <UpdateProfileInformationForm :must-verify-email="mustVerifyEmail" :status="status" class="max-w-xl" />
26
31
  </div>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { getAppearance, setAppearance, type Appearance } from '@/lib/appearance'
4
+
5
+ const options: { value: Appearance; label: string }[] = [
6
+ { value: 'light', label: 'Light' },
7
+ { value: 'dark', label: 'Dark' },
8
+ { value: 'system', label: 'System' },
9
+ ]
10
+
11
+ const appearance = ref<Appearance>(getAppearance())
12
+
13
+ const select = (value: Appearance) => {
14
+ appearance.value = value
15
+ setAppearance(value)
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <section>
21
+ <header>
22
+ <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Appearance</h2>
23
+
24
+ <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
25
+ Choose how the application looks to you. System follows your device setting.
26
+ </p>
27
+ </header>
28
+
29
+ <div class="mt-6 inline-flex rounded-lg bg-gray-100 p-1 dark:bg-gray-900">
30
+ <button
31
+ v-for="option in options"
32
+ :key="option.value"
33
+ type="button"
34
+ :aria-pressed="appearance === option.value"
35
+ class="rounded-md px-4 py-1.5 text-sm font-medium transition focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
36
+ :class="
37
+ appearance === option.value
38
+ ? 'bg-white text-gray-900 shadow-xs dark:bg-gray-700 dark:text-gray-100'
39
+ : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
40
+ "
41
+ @click="select(option.value)"
42
+ >
43
+ {{ option.label }}
44
+ </button>
45
+ </div>
46
+ </section>
47
+ </template>
@@ -0,0 +1,23 @@
1
+ export type Appearance = 'light' | 'dark' | 'system'
2
+
3
+ const STORAGE_KEY = 'appearance'
4
+
5
+ export function getAppearance(): Appearance {
6
+ const stored = localStorage.getItem(STORAGE_KEY)
7
+
8
+ return stored === 'light' || stored === 'dark' ? stored : 'system'
9
+ }
10
+
11
+ export function applyAppearance(appearance: Appearance = getAppearance()): void {
12
+ const dark =
13
+ appearance === 'dark' ||
14
+ (appearance === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
15
+
16
+ document.documentElement.classList.toggle('dark', dark)
17
+ }
18
+
19
+ export function setAppearance(appearance: Appearance): void {
20
+ localStorage.setItem(STORAGE_KEY, appearance)
21
+
22
+ applyAppearance(appearance)
23
+ }
@@ -6,11 +6,29 @@
6
6
  <title><%= content_for(:title) || "Rails" %></title>
7
7
  <%= csrf_meta_tags %>
8
8
  <%= csp_meta_tag %>
9
+ <%# Apply the stored appearance before first paint, so the page never flashes the wrong theme. %>
10
+ <%= javascript_tag nonce: true do %>
11
+ (function () {
12
+ var preference = localStorage.getItem('appearance') || 'system'
13
+ var query = window.matchMedia('(prefers-color-scheme: dark)')
14
+
15
+ document.documentElement.classList.toggle(
16
+ 'dark',
17
+ preference === 'dark' || (preference === 'system' && query.matches)
18
+ )
19
+
20
+ query.addEventListener('change', function () {
21
+ if ((localStorage.getItem('appearance') || 'system') === 'system') {
22
+ document.documentElement.classList.toggle('dark', query.matches)
23
+ }
24
+ })
25
+ })()
26
+ <% end %>
9
27
  <%= vite_client_tag %>
10
28
  <%= vite_typescript_tag 'application.ts', defer: true %>
11
29
  </head>
12
30
  <body class="font-sans antialiased">
13
- <div class="min-h-screen bg-gray-100">
31
+ <div class="min-h-screen bg-gray-100 dark:bg-gray-900">
14
32
  <%= yield %>
15
33
  </div>
16
34
  </body>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kaze
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cuong Giang
@@ -147,6 +147,7 @@ files:
147
147
  - stubs/hotwire/app/views/auth/reset_password.html.erb
148
148
  - stubs/hotwire/app/views/auth/verify_email.html.erb
149
149
  - stubs/hotwire/app/views/dashboard/index.html.erb
150
+ - stubs/hotwire/app/views/layouts/_appearance.html.erb
150
151
  - stubs/hotwire/app/views/layouts/_navigation.html.erb
151
152
  - stubs/hotwire/app/views/layouts/application.html.erb
152
153
  - stubs/hotwire/app/views/layouts/guest.html.erb
@@ -154,6 +155,7 @@ files:
154
155
  - stubs/hotwire/app/views/profile/edit.html.erb
155
156
  - stubs/hotwire/app/views/profile/partials/_confirm_user_deletion_modal.html.erb
156
157
  - stubs/hotwire/app/views/profile/partials/_delete_user_form.html.erb
158
+ - stubs/hotwire/app/views/profile/partials/_update_appearance_form.html.erb
157
159
  - stubs/hotwire/app/views/profile/partials/_update_password_form.html.erb
158
160
  - stubs/hotwire/app/views/profile/partials/_update_profile_information_form.html.erb
159
161
  - stubs/hotwire/app/views/welcome/index.html.erb
@@ -203,11 +205,13 @@ files:
203
205
  - stubs/inertia-react-ts/app/javascript/Pages/Dashboard.tsx
204
206
  - stubs/inertia-react-ts/app/javascript/Pages/Profile/Edit.tsx
205
207
  - stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/DeleteUserForm.tsx
208
+ - stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/UpdateAppearanceForm.tsx
206
209
  - stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/UpdatePasswordForm.tsx
207
210
  - stubs/inertia-react-ts/app/javascript/Pages/Profile/Partials/UpdateProfileInformationForm.tsx
208
211
  - stubs/inertia-react-ts/app/javascript/Pages/Welcome.tsx
209
212
  - stubs/inertia-react-ts/app/javascript/entrypoints/application.tsx
210
213
  - stubs/inertia-react-ts/app/javascript/entrypoints/bootstrap.ts
214
+ - stubs/inertia-react-ts/app/javascript/lib/appearance.ts
211
215
  - stubs/inertia-react-ts/app/javascript/types/global.d.ts
212
216
  - stubs/inertia-react-ts/app/javascript/types/index.d.ts
213
217
  - stubs/inertia-react-ts/app/javascript/types/vite-env.d.ts
@@ -239,11 +243,13 @@ files:
239
243
  - stubs/inertia-vue-ts/app/javascript/Pages/Dashboard.vue
240
244
  - stubs/inertia-vue-ts/app/javascript/Pages/Profile/Edit.vue
241
245
  - stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/DeleteUserForm.vue
246
+ - stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/UpdateAppearanceForm.vue
242
247
  - stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/UpdatePasswordForm.vue
243
248
  - stubs/inertia-vue-ts/app/javascript/Pages/Profile/Partials/UpdateProfileInformationForm.vue
244
249
  - stubs/inertia-vue-ts/app/javascript/Pages/Welcome.vue
245
250
  - stubs/inertia-vue-ts/app/javascript/entrypoints/application.ts
246
251
  - stubs/inertia-vue-ts/app/javascript/entrypoints/bootstrap.ts
252
+ - stubs/inertia-vue-ts/app/javascript/lib/appearance.ts
247
253
  - stubs/inertia-vue-ts/app/javascript/types/global.d.ts
248
254
  - stubs/inertia-vue-ts/app/javascript/types/index.d.ts
249
255
  - stubs/inertia-vue-ts/app/javascript/types/vite-env.d.ts