├── .changeset ├── README.md └── config.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-documentation_change.yml │ ├── 2-feature_request.yml │ ├── 3-bug_report.yml │ └── config.yml └── workflows │ ├── build-preview.yml │ ├── ci.yml │ ├── deploy-preview.yml │ ├── deploy-prod.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── .npmrc ├── README.md ├── mdsx.config.js ├── package.json ├── scripts │ ├── build-search-data.js │ └── update-velite-output.js ├── src │ ├── app.css │ ├── app.d.ts │ ├── app.html │ ├── content │ │ ├── components │ │ │ └── mode-watcher.md │ │ ├── getting-started.md │ │ ├── index.md │ │ ├── mode-vs-theme.md │ │ ├── states │ │ │ ├── mode-storage-key.md │ │ │ ├── mode.md │ │ │ ├── system-prefers-mode.md │ │ │ ├── theme-storage-key.md │ │ │ ├── theme.md │ │ │ └── user-prefers-mode.md │ │ ├── testing │ │ │ └── vitest.md │ │ └── utilities │ │ │ ├── create-initial-mode-expression.md │ │ │ ├── reset-mode.md │ │ │ ├── set-mode.md │ │ │ ├── set-theme.md │ │ │ └── toggle-mode.md │ ├── lib │ │ ├── components │ │ │ ├── blueprint.svelte │ │ │ └── logos │ │ │ │ ├── mode-watcher-dark.svelte │ │ │ │ └── mode-watcher-light.svelte │ │ ├── index.ts │ │ ├── navigation.ts │ │ ├── site-config.ts │ │ └── utils.ts │ └── routes │ │ ├── (docs) │ │ ├── +layout.svelte │ │ └── docs │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ └── [...slug] │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── (landing) │ │ ├── +page.svelte │ │ └── +page.ts │ │ ├── +layout.svelte │ │ └── api │ │ └── search.json │ │ ├── +server.ts │ │ └── search.json ├── static │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── logo-dark.svg │ ├── logo-light.svg │ ├── og.png │ └── site.webmanifest ├── svelte.config.js ├── tsconfig.json ├── velite.config.js └── vite.config.ts ├── eslint.config.js ├── package.json ├── packages └── mode-watcher │ ├── .gitignore │ ├── .npmrc │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── playwright.config.ts │ ├── postcss.config.cjs │ ├── scripts │ └── setupTest.ts │ ├── src │ ├── app.d.ts │ ├── app.html │ ├── app.postcss │ ├── index.test.ts │ ├── lib │ │ ├── components │ │ │ ├── mode-watcher-full.svelte │ │ │ ├── mode-watcher-lite.svelte │ │ │ ├── mode-watcher.svelte │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── mode-states.svelte.ts │ │ ├── mode.ts │ │ ├── modes.ts │ │ ├── states.svelte.ts │ │ ├── storage-keys.svelte.ts │ │ ├── theme-state.svelte.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── without-transition.ts │ ├── routes │ │ ├── +layout.svelte │ │ └── +page.svelte │ └── tests │ │ ├── Mode.svelte │ │ ├── StealthMode.svelte │ │ └── mode.spec.ts │ ├── static │ └── favicon.png │ ├── svelte.config.js │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": [ 4 | "@svitejs/changesets-changelog-github-compact", 5 | { "repo": "svecosystem/mode-watcher" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": ["@mode-watcher/docs"] 14 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [huntabyte] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: ollema 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-documentation_change.yml: -------------------------------------------------------------------------------- 1 | name: Report Docs Issue 2 | description: Suggest an addition or modification to the documentation 3 | labels: [triage] 4 | body: 5 | - type: dropdown 6 | attributes: 7 | label: Change Type 8 | description: What type of change are you proposing? 9 | options: 10 | - Addition 11 | - Correction 12 | - Removal 13 | - Cleanup (formatting, typos, etc.) 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | attributes: 19 | label: Proposed Changes 20 | description: Describe the proposed changes and why they are necessary 21 | validations: 22 | required: true 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Request New Feature 2 | description: Let us know what you would like to see added. 3 | labels: [triage] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Describe the feature in detail (code, mocks, or screenshots encouraged) 9 | validations: 10 | required: true 11 | - type: dropdown 12 | id: category 13 | attributes: 14 | label: What type of pull request would this be? 15 | options: 16 | - New Feature 17 | - Enhancement 18 | - Guide 19 | - Docs 20 | - Other 21 | - type: textarea 22 | id: references 23 | attributes: 24 | label: Provide relevant links or additional information. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report an issue with mode-watcher 3 | labels: [traige] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | - type: textarea 10 | id: bug-description 11 | attributes: 12 | label: Describe the bug 13 | description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us how in the description. Thanks! 14 | placeholder: Bug description 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: reproduction 19 | attributes: 20 | label: Reproduction 21 | description: | 22 | Please provide a link to a repo or Stackblitz that can reproduce the problem you ran into. If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "needs reproduction" label. If no reproduction is provided within a reasonable time-frame, the issue will be closed. 23 | 24 | To get started, you can use the following StackBlitz template: 25 | https://mode-watcher.svecosystem.com/repro 26 | placeholder: Reproduction 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: logs 31 | attributes: 32 | label: Logs 33 | description: Please include browser console and server logs around the time this bug occurred. Optional if provided reproduction. Please try not to insert an image but copy paste the log text. 34 | render: bash 35 | - type: textarea 36 | id: system-info 37 | attributes: 38 | label: System Info 39 | description: Output of `npx envinfo --system --npmPackages svelte,mode-watcher,@sveltejs/kit --binaries --browsers` 40 | render: bash 41 | placeholder: System, Binaries, Browsers 42 | validations: 43 | required: true 44 | - type: dropdown 45 | id: severity 46 | attributes: 47 | label: Severity 48 | description: Select the severity of this issue 49 | options: 50 | - annoyance 51 | - blocking an upgrade 52 | - blocking all usage of mode-watcher 53 | validations: 54 | required: true 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/svecosystem/mode-watcher/discussions/new?category=help 5 | about: If you can't get something to work the way you expect, open a question in our discussion forums. 6 | - name: Discord 7 | url: https://discord.gg/rePvPCE9dh 8 | about: If you need to have a back-and-forth conversation, join the Discord server. 9 | -------------------------------------------------------------------------------- /.github/workflows/build-preview.yml: -------------------------------------------------------------------------------- 1 | name: Build Preview Deployment 2 | 3 | # cancel in-progress runs on new commits to same PR (github.event.number) 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.event.number || github.sha }} 6 | cancel-in-progress: true 7 | 8 | on: 9 | pull_request: 10 | types: [opened, synchronize] 11 | 12 | jobs: 13 | build-preview: 14 | runs-on: macos-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | cache: pnpm 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | 26 | - name: Build site 27 | run: pnpm build 28 | 29 | - name: Upload build artifact 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: preview-build 33 | path: docs/.svelte-kit/cloudflare 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | # cancel in-progress runs on new commits to same PR (gitub.event.number) 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.event.number || github.sha }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: read # to fetch code (actions/checkout) 16 | 17 | jobs: 18 | Check: 19 | name: Run svelte-check 20 | runs-on: macos-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: pnpm/action-setup@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: pnpm 28 | 29 | - name: Install dependencies 30 | run: pnpm install 31 | 32 | - name: Run svelte-check 33 | run: pnpm check 34 | Lint: 35 | runs-on: macos-latest 36 | name: Lint 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: pnpm/action-setup@v4 40 | - uses: actions/setup-node@v4 41 | with: 42 | node-version: 20 43 | cache: pnpm 44 | 45 | - name: Install dependencies 46 | run: pnpm install 47 | 48 | - run: pnpm lint 49 | 50 | Test: 51 | runs-on: macos-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: pnpm/action-setup@v4 55 | - uses: actions/setup-node@v4 56 | with: 57 | node-version: 20 58 | cache: pnpm 59 | 60 | - name: Install dependencies 61 | run: pnpm install 62 | 63 | - run: pnpm test 64 | -------------------------------------------------------------------------------- /.github/workflows/deploy-preview.yml: -------------------------------------------------------------------------------- 1 | name: Upload Preview Deployment 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Build Preview Deployment] 6 | types: 7 | - completed 8 | 9 | permissions: 10 | actions: read 11 | deployments: write 12 | contents: read 13 | pull-requests: write 14 | 15 | jobs: 16 | deploy-preview: 17 | runs-on: macos-latest 18 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 19 | steps: 20 | - name: Download build artifact 21 | uses: actions/download-artifact@v4 22 | id: preview-build-artifact 23 | with: 24 | name: preview-build 25 | path: build 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | run-id: ${{ github.event.workflow_run.id }} 28 | 29 | - name: Deploy to Cloudflare Pages 30 | uses: AdrianGonz97/refined-cf-pages-action@v1 31 | with: 32 | apiToken: ${{ secrets.CF_API_TOKEN }} 33 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 34 | githubToken: ${{ secrets.GITHUB_TOKEN }} 35 | projectName: mode-watcher 36 | deploymentName: Preview 37 | directory: ${{ steps.preview-build-artifact.outputs.download-path }} 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prod.yml: -------------------------------------------------------------------------------- 1 | name: Production Deployment 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - docs/** 8 | - packages/mode-watcher/** 9 | 10 | jobs: 11 | deploy-production: 12 | runs-on: macos-latest 13 | permissions: 14 | contents: read 15 | deployments: write 16 | name: Deploy Production Site to Cloudflare Pages 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: pnpm 24 | 25 | - name: Install dependencies 26 | run: pnpm install 27 | 28 | - name: Build site 29 | run: pnpm build 30 | 31 | - name: Deploy to Cloudflare Pages 32 | uses: AdrianGonz97/refined-cf-pages-action@v1 33 | with: 34 | apiToken: ${{ secrets.CF_API_TOKEN }} 35 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 36 | githubToken: ${{ secrets.GITHUB_TOKEN }} 37 | projectName: mode-watcher 38 | directory: ./.svelte-kit/cloudflare 39 | workingDirectory: docs 40 | deploymentName: Production 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Packages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - next 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Build & Publish @latest Release 13 | if: github.repository == 'svecosystem/mode-watcher' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: pnpm 24 | 25 | - name: Install dependencies 26 | run: pnpm install 27 | 28 | - name: Create Release Pull Request or Publish 29 | id: changesets 30 | uses: changesets/action@v1 31 | with: 32 | commit: "chore(release): version package" 33 | title: "chore(release): version package" 34 | publish: pnpm ci:publish 35 | env: 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | dist 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | .pnpm-debug.log* 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # Snowpack dependency directory (https://snowpack.dev/) 53 | web_modules/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variable files 71 | .env 72 | .env.development.local 73 | .env.test.local 74 | .env.production.local 75 | .env.local 76 | 77 | docs/.velite 78 | docs/.wrangler -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | **/build 4 | **/.svelte-kit 5 | .env 6 | .env.* 7 | !.env.example 8 | 9 | # Ignore files for PNPM, NPM and YARN 10 | pnpm-lock.yaml 11 | package-lock.json 12 | yarn.lock 13 | **/.changeset/ 14 | .prettierrc 15 | package.json 16 | .vercel 17 | .contentlayer 18 | **/dist 19 | 20 | CHANGELOG.md 21 | 22 | vite.config.js.timestamp-* 23 | vite.config.ts.timestamp-* 24 | 25 | docs/.velite/**/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "tabWidth": 4, 4 | "singleQuote": false, 5 | "trailingComma": "es5", 6 | "semi": true, 7 | "printWidth": 100, 8 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 9 | "overrides": [ 10 | { 11 | "files": "*.svelte", 12 | "options": { 13 | "parser": "svelte" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.useFlatConfig": true, 4 | 5 | // Auto fix 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit", 8 | "source.organizeImports": "never" 9 | }, 10 | 11 | // Enable eslint for all supported languages 12 | "eslint.validate": [ 13 | "javascript", 14 | "javascriptreact", 15 | "typescript", 16 | "typescriptreact", 17 | "vue", 18 | "svelte", 19 | "html", 20 | "markdown", 21 | "json", 22 | "jsonc", 23 | "yaml", 24 | "toml", 25 | "astro" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hunter Johnston 4 | Copyright (c) 2024 Svecosystem 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mode Watcher 2 | 3 | Simple utilities to manage light & dark mode in your SvelteKit app. 4 | 5 | 6 | 7 | [![npm version](https://flat.badgen.net/npm/v/mode-watcher?color=yellow)](https://npmjs.com/package/mode-watcher) 8 | [![npm downloads](https://flat.badgen.net/npm/dm/mode-watcher?color=yellow)](https://npmjs.com/package/mode-watcher) 9 | [![license](https://flat.badgen.net/github/license/svecosystem/mode-watcher?color=yellow)](https://github.com/svecosystem/mode-watcher/blob/main/LICENSE) 10 | 11 | 12 | 13 | [![](https://dcbadge.vercel.app/api/server/fdXy3Sk8Gq?style=flat)](https://discord.gg/fdXy3Sk8Gq) 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npm install mode-watcher 19 | ``` 20 | 21 | ## Usage 22 | 23 | Add the `ModeWatcher` component to your root `+layout.svelte` file. 24 | 25 | ```svelte 26 | 30 | 31 | 32 | {@render children()} 33 | ``` 34 | 35 | The `ModeWatcher` component will automatically detect the user's preferences and apply/remove the `"dark"` class, along with the corresponding `color-scheme` style attribute to the `html` element. 36 | 37 | `ModeWatcher` will automatically track operating system preferences and apply these if no user preference is set. If you wish to disable this behavior, set the `track` prop to `false`: 38 | 39 | ```svelte 40 | 41 | ``` 42 | 43 | `ModeWatcher` can also be configured with a default mode instead of automatically detecting the user's preference. 44 | 45 | To set a default mode, use the `defaultMode` prop: 46 | 47 | ```svelte 48 | 49 | ``` 50 | 51 | `ModeWatcher` can manage the `theme-color` meta tag for you. 52 | 53 | To enable this, set the `themeColors` prop to your preferred colors: 54 | 55 | ```svelte 56 | 57 | ``` 58 | 59 | ## API 60 | 61 | ### toggleMode 62 | 63 | A function that toggles the current mode. 64 | 65 | ```svelte 66 | 69 | 70 | 71 | ``` 72 | 73 | ### setMode 74 | 75 | A function that sets the current mode. It accepts a string with the value `"light"`, `"dark"` or `"system"`. 76 | 77 | ```svelte 78 | 81 | 82 | 83 | 84 | ``` 85 | 86 | ### resetMode 87 | 88 | A function that resets the mode to system preferences. 89 | 90 | ```svelte 91 | 94 | 95 | 96 | ``` 97 | 98 | ### mode 99 | 100 | A readable store that contains the current mode. It can be `"light"` or `"dark"` or `undefined` if evaluated on the server. 101 | 102 | ```svelte 103 | 114 | 115 | 116 | ``` 117 | 118 | ### userPrefersMode 119 | 120 | A writeable store that represents the user's mode preference. It can be `"light"`, `"dark"` or `"system"`. 121 | 122 | ### systemPrefersMode 123 | 124 | A readable store that represents the operating system's mode preference. It can be `"light"`, `"dark"` or `undefined` if evaluated on the server. Will automatically track changes to the operating system's mode preference unless this is disabled with the `tracking()` method which takes a boolean. Normally this is disabled by setting the `track` prop to false in the `` component. 125 | 126 | ## Demo / Reproduction Template 127 | 128 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/svecosystem/mode-watcher-reproduction) 129 | 130 | ## Sponsors 131 | 132 | This project is supported by the following beautiful people/organizations: 133 | 134 |

135 | 136 | Logos from Sponsors 137 | 138 |

139 | 140 | ## License 141 | 142 | 143 | 144 | Published under the [MIT](https://github.com/svecosystem/mode-watcher/blob/main/LICENSE) license. 145 | Made by [@huntabyte](https://github.com/huntabyte), [@ollema](https://github.com/ollema), and [community](https://github.com/svecosystem/mode-watcher/graphs/contributors) 💛 146 |

147 | 148 | 149 | 150 | 151 | 152 | 153 | ## Community 154 | 155 | Join the Discord server to ask questions, find collaborators, or just say hi! 156 | 157 | 158 | 159 | 160 | Svecosystem Discord community 161 | 162 | 163 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | 9 | # OS 10 | .DS_Store 11 | Thumbs.db 12 | 13 | # Env 14 | .env 15 | .env.* 16 | !.env.example 17 | !.env.test 18 | 19 | # Vite 20 | vite.config.js.timestamp-* 21 | vite.config.ts.timestamp-* 22 | -------------------------------------------------------------------------------- /docs/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # sv 2 | 3 | Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npx sv create 12 | 13 | # create a new project in my-app 14 | npx sv create my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /docs/mdsx.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "mdsx"; 2 | import { baseRemarkPlugins, baseRehypePlugins } from "@svecodocs/kit/mdsxConfig"; 3 | import { resolve } from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 7 | 8 | export default defineConfig({ 9 | remarkPlugins: [...baseRemarkPlugins], 10 | // @ts-expect-error shh 11 | rehypePlugins: [...baseRehypePlugins], 12 | blueprints: { 13 | default: { 14 | path: resolve(__dirname, "./src/lib/components/blueprint.svelte"), 15 | }, 16 | }, 17 | extensions: [".md"], 18 | }); 19 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mode-watcher/docs", 3 | "description": "Documentation site for mode-watcher", 4 | "version": "0.0.0", 5 | "type": "module", 6 | "private": true, 7 | "scripts": { 8 | "dev": "pnpm \"/dev:/\"", 9 | "dev:content": "velite dev --watch", 10 | "dev:svelte": "vite dev", 11 | "build": "velite && node ./scripts/update-velite-output.js && pnpm build:search && vite build", 12 | "build:search": "node ./scripts/build-search-data.js", 13 | "preview": "vite preview", 14 | "check": "velite && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 15 | "check:watch": "pnpm build:content && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 16 | }, 17 | "devDependencies": { 18 | "@svecodocs/kit": "^0.2.1", 19 | "@sveltejs/adapter-cloudflare": "^4.8.0", 20 | "@sveltejs/kit": "^2.20.3", 21 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 22 | "@tailwindcss/vite": "^4.1.1", 23 | "mdsx": "^0.0.6", 24 | "mode-watcher": "workspace:*", 25 | "phosphor-svelte": "^3.0.1", 26 | "svelte": "^5.27.0", 27 | "svelte-check": "^4.1.5", 28 | "tailwindcss": "^4.1.1", 29 | "typescript": "^5.0.0", 30 | "velite": "^0.2.1", 31 | "vite": "^6.2.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/scripts/build-search-data.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { writeFileSync } from "node:fs"; 3 | import { resolve } from "node:path"; 4 | import { docs } from "../.velite/index.js"; 5 | import { cleanMarkdown } from "../node_modules/@svecodocs/kit/dist/utils.js"; 6 | 7 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 8 | 9 | export function buildDocsSearchIndex() { 10 | return docs.map((doc) => ({ 11 | title: doc.title, 12 | href: `/docs/${doc.slug}`, 13 | description: doc.description, 14 | content: cleanMarkdown(doc.raw), 15 | })); 16 | } 17 | 18 | const searchData = buildDocsSearchIndex(); 19 | 20 | writeFileSync( 21 | resolve(__dirname, "../src/routes/api/search.json/search.json"), 22 | JSON.stringify(searchData), 23 | { flag: "w" } 24 | ); 25 | -------------------------------------------------------------------------------- /docs/scripts/update-velite-output.js: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 6 | const dtsPath = join(__dirname, "../.velite/index.d.ts"); 7 | const indexPath = join(__dirname, "../.velite/index.js"); 8 | 9 | async function replaceContents() { 10 | const data = await readFile(dtsPath, "utf8").catch((err) => { 11 | console.error("Error reading file:", err); 12 | }); 13 | if (!data) return; 14 | 15 | const updatedContent = data.replace("'../velite.config'", "'../velite.config.js'"); 16 | if (updatedContent === data) return; 17 | 18 | await writeFile(dtsPath, updatedContent, "utf8").catch((err) => { 19 | console.error("Error writing file:", err); 20 | }); 21 | } 22 | 23 | async function replaceIndexContents() { 24 | const data = await readFile(indexPath, "utf8").catch((err) => { 25 | console.error("Error reading file:", err); 26 | }); 27 | if (!data) return; 28 | 29 | const updatedContent = data.replaceAll(".json'", ".json' with { type: 'json' }"); 30 | if (updatedContent === data) return; 31 | 32 | await writeFile(indexPath, updatedContent, "utf8").catch((err) => { 33 | console.error("Error writing file:", err); 34 | }); 35 | } 36 | 37 | await replaceContents(); 38 | await replaceIndexContents(); 39 | -------------------------------------------------------------------------------- /docs/src/app.css: -------------------------------------------------------------------------------- 1 | @import "@svecodocs/kit/theme-amber.css"; 2 | @import "@svecodocs/kit/globals.css"; 3 | @source "../node_modules/@svecodocs/kit"; 4 | -------------------------------------------------------------------------------- /docs/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /docs/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/src/content/components/mode-watcher.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: ModeWatcher 3 | description: API Reference for the ModeWatcher component. 4 | section: Components 5 | --- 6 | 7 | 10 | 11 | ## Usage 12 | 13 | Add the `ModeWatcher` component to your root `+layout.svelte` file to automatically apply mode and theme preferences: 14 | 15 | ```svelte title="src/routes/+layout.svelte" 16 | 20 | 21 | 22 | {@render children()} 23 | ``` 24 | 25 | `ModeWatcher` will: 26 | 27 | - Detect user mode preferences (`light`, `dark`, or `system`) 28 | - Apply the appropriate class (dark by default) to the `` element 29 | - Set the `color-scheme` attribute accordingly 30 | - Optionally apply a theme via the `data-theme` attribute 31 | 32 | ## Features 33 | 34 | ### Disable Tracking 35 | 36 | `ModeWatcher` will automatically track operating system preferences and apply these if no user preference is set. If you wish to disable this behavior, set the track prop to `false`: 37 | 38 | ```svelte 39 | 40 | ``` 41 | 42 | ### Default Mode 43 | 44 | Use the `defaultMode` prop to specify a fallback when no user preference is available: 45 | 46 | ```svelte 47 | 48 | ``` 49 | 50 | ### Themes 51 | 52 | In addition to the `dark`, `light`, and `system` modes, `ModeWatcher` can also be configured with a theme which will be applied to the root `html` element like so: 53 | 54 | ```html 55 | 56 | ``` 57 | 58 | ### Theme Colors 59 | 60 | Manage the browser's `` dynamically based on mode: 61 | 62 | ```svelte 63 | 64 | ``` 65 | 66 | ### Custom Class Names 67 | 68 | Customize the class names added to `` when switching modes: 69 | 70 | ```svelte 71 | 72 | ``` 73 | 74 | Now, when the mode is dark, the root `html` element will have the `dddd` class, and when the mode is light, the root `html` element will have the `fff` class. 75 | 76 | ### Custom Local Storage Keys 77 | 78 | Override the default `localStorage` keys: 79 | 80 | ```svelte 81 | 82 | ``` 83 | 84 | ### CSP Nonce Support 85 | 86 | Provide a nonce if using a strict Content Security Policy. 87 | 88 | This will be applied to the injected ` 10 | 11 | ## Installation & Setup 12 | 13 | 14 | 15 | Install the package 16 | 17 | Install the `mode-watcher` package from npm. 18 | 19 | ```bash 20 | npm install mode-watcher 21 | ``` 22 | 23 | Add the ModeWatcher component 24 | 25 | Add the `` component to your root `+layout.svelte` file. 26 | 27 | ```svelte {2,5}#add title="src/routes/+layout.svelte" 28 | 32 | 33 | 34 | {@render children()} 35 | ``` 36 | 37 | That's it! 38 | 39 | You're now ready to use Mode Watcher in your Svelte app. 40 | 41 | Here's an example of how to use the `toggleMode` function to toggle the mode: 42 | 43 | ```svelte title="src/lib/components/light-switch.svelte" 44 | 47 | 48 | 49 | ``` 50 | 51 | For additional information and configuration, please refer to the [API reference](/docs/api-reference/mode-watcher). 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/src/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: What is Mode Watcher? 4 | section: Overview 5 | --- 6 | 7 | Mode Watcher provides simple utilities to manage light & dark mode in your Svelte apps. 8 | 9 | ## Features 10 | 11 | - Dark mode for your Svelte app with two lines of code. 12 | - No flash of unstyled content, compatible with SSR, CSR and SSG. 13 | - Detect and track `prefers-color-scheme` changes in real-time. 14 | - Theme scrollbars and form controls through the `color-scheme` property. 15 | - Theme surrounding browser interface through the `theme-color` meta tag. 16 | - Allows users to toggle between light and dark mode or respect their system preference. 17 | - User preference persistence thanks `localStorage` - syncs theme across tabs and windows. 18 | - Allows for a default theme to be set. 19 | - Disables CSS transitions during theme changes to prevent flickering. 20 | -------------------------------------------------------------------------------- /docs/src/content/mode-vs-theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mode vs Theme 3 | description: A comparison between mode and theme. 4 | section: Overview 5 | --- 6 | 7 | In Mode Watcher, _mode_ and _theme_ are distinct concepts. They work together, but they're not the same, and knowing the difference is key to using Mode Watcher effectively. 8 | 9 | ## Mode 10 | 11 | The mode represents the user's preference for a light or dark interface. It can be one of the following: 12 | 13 | - `"light"` 14 | - `"dark"` 15 | - `"system"` (follows the operating system’s preference) 16 | 17 | Mode Watcher uses this value to: 18 | 19 | - Apply the correct `class` (`light` or `dark`) to the root `` element 20 | - Set the corresponding `color-scheme` (`light` or `dark`) for browser rendering 21 | 22 | This ensures consistent styling based on user or system preferences. 23 | 24 | ## Theme 25 | 26 | A theme is a design system that defines the visual identity of your application - colors, typography, spacing, layout, etc. 27 | 28 | Themes can include both light and dark variants. For example: 29 | 30 | - A `dracula` theme might contain both `dracula-light` and `dracula-dark` styles. 31 | - Mode Watcher automatically chooses the correct variant based on the current mode. 32 | 33 | You don't need to create separate themes like `dracula-light` and `dracula-dark`. Instead, provide a single `dracula` theme with both variants, and let Mode Watcher handle the switching. 34 | 35 | ## Summary 36 | 37 | - **Mode** = user's light/dark preference 38 | - **Theme** = overall design system (can adapt to mode) 39 | 40 | They're different layers of customization—mode controls which variant of the theme is shown. 41 | -------------------------------------------------------------------------------- /docs/src/content/states/mode-storage-key.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: modeStorageKey 3 | description: The local storage key used to persist the user's selected mode. 4 | section: States 5 | --- 6 | 7 | `modeStorageKey` is a readable state containing the string key used to persist the user's selected mode (`"light"`, `"dark"`, or `"system"`) in `localStorage`. 8 | 9 | This is useful if you need to manually read, write, or clear the stored value. 10 | 11 | ## Usage 12 | 13 | If you wanted to clear the history of the user's mode preference, you could use the `modeStorageKey` like so: 14 | 15 | ```svelte 16 | 23 | 24 |

Clear the user's mode preference history.

25 | 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/src/content/states/mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: mode 3 | description: Tracks the current resolved mode (light or dark). 4 | section: States 5 | --- 6 | 7 | 10 | 11 | `mode` is a readable state representing the resolved mode: either `"light"` or `"dark"`. If accessed on the server, its value is `undefined`. 12 | 13 | This value updates automatically based on user preferences and system settings. 14 | 15 | 16 | 17 | This is the **resolved** mode - not the user’s selected preference. If the user chose `"system"`, this reflects the actual system setting (e.g., `"dark"`), not the string `"system"`.
To get the user’s selection (`"light"`, `"dark"`, or `"system"`), use [`userPrefersMode`](/docs/states/user-prefers-mode). 18 | 19 |
20 | 21 | ## Usage 22 | 23 | ```svelte 24 | 35 | 36 | 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/src/content/states/system-prefers-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: systemPrefersMode 3 | description: Tracks the operating system's preferred color scheme. 4 | section: States 5 | --- 6 | 7 | `systemPrefersMode` is a readable state representing the operating system's current color scheme preference. 8 | It will be `"light"` or `"dark"` in the browser, or `undefined` when evaluated on the server. 9 | 10 | This value updates automatically when the system's preference changes - unless tracking is disabled by setting `track={false}` in the [ModeWatcher](/docs/components/mode-watcher) component. 11 | 12 | ## Usage 13 | 14 | ```svelte 15 | 18 | 19 |

The system prefers mode is: {systemPrefersMode.current}

20 | ``` 21 | -------------------------------------------------------------------------------- /docs/src/content/states/theme-storage-key.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: themeStorageKey 3 | description: The local storage key used to persist the theme. 4 | section: States 5 | --- 6 | 7 | `themeStorageKey` is a readable state containing the string key used to persist the user's selected theme in `localStorage`. 8 | 9 | This is helpful if you need to manually inspect, modify, or clear the stored theme value. 10 | 11 | ## Usage 12 | 13 | If you wanted to clear the history of the user's mode preference, you could use the `themeStorageKey` like so: 14 | 15 | ```svelte 16 | 23 | 24 |

Clear the user's theme preference history.

25 | 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/src/content/states/theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: theme 3 | description: Tracks the current theme. 4 | section: States 5 | --- 6 | 7 | `theme` is a readable state that holds the currently active theme - a custom string defined by you. 8 | 9 | Unlike [mode](/docs/states/mode), which resolves to `"light"` or `"dark"`, `theme` can be any string (e.g. `"dracula"`, `"retro"`, `"corporate"`) and is often used to support more granular visual styles. 10 | 11 | Use it alongside `mode` to build a custom theme switcher, similar to [DaisyUI](https://daisyui.com)'s approach. 12 | 13 | ## Usage 14 | 15 | ```svelte 16 | 27 | 28 | 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/src/content/states/user-prefers-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: userPrefersMode 3 | description: Tracks the user's selected mode preference ("light", "dark", or "system"). 4 | section: States 5 | --- 6 | 7 | `userPrefersMode` is a writable state representing the user's explicit preference: `"light"`, `"dark"`, or `"system"`. 8 | 9 | This differs from [mode](/docs/states/mode), which reflects the resolved mode based on system settings when `"system"` is selected. 10 | 11 | Use `userPrefersMode` when you want to display or persist the user's selected preference, even if it defers to the system. 12 | 13 | ## Usage 14 | 15 | ```svelte 16 | 19 | 20 |

Your preferred mode is: {userPrefersMode.current}

21 | ``` 22 | -------------------------------------------------------------------------------- /docs/src/content/testing/vitest.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vitest 3 | description: How to test with vitest 4 | section: Testing 5 | --- 6 | 7 | When testing components that use mode-watcher, you'll need to set up a proper testing environment that mocks the `matchMedia` API. This guide will show you how to properly configure your tests. 8 | 9 | ## Setup 10 | 11 | Create a `vitest.setup.ts` file in your project root (or wherever you keep your test configuration) and add the following code: 12 | 13 | ```ts 14 | import { vi } from "vitest"; 15 | 16 | const mockMatchMedia = vi.fn().mockImplementation((query) => ({ 17 | matches: false, 18 | media: query, 19 | onchange: null, 20 | addEventListener: vi.fn(), 21 | removeEventListener: vi.fn(), 22 | dispatchEvent: vi.fn(), 23 | // Additional properties to better match the MediaQueryList interface 24 | matchMedia: true, 25 | mediaQueryList: true, 26 | // Method to simulate media query changes 27 | simulateChange: (matches: boolean) => { 28 | mockMatchMedia.mock.results[0].value.matches = matches; 29 | if (mockMatchMedia.mock.results[0].value.onchange) { 30 | mockMatchMedia.mock.results[0].value.onchange(); 31 | } 32 | }, 33 | })); 34 | 35 | Object.defineProperty(window, "matchMedia", { 36 | writable: true, 37 | value: mockMatchMedia, 38 | }); 39 | ``` 40 | 41 | Then, update your `vitest.config.ts` to include this setup file: 42 | 43 | ```ts 44 | import { defineConfig } from "vitest/config"; 45 | 46 | export default defineConfig({ 47 | test: { 48 | setupFiles: ["./vitest.setup.ts"], 49 | // ... other config options 50 | }, 51 | }); 52 | ``` 53 | 54 | ## Usage in Tests 55 | 56 | With the setup in place, you can now write tests for components that use mode-watcher. Here's an example: 57 | 58 | ```ts 59 | import { describe, it, expect } from "vitest"; 60 | import { render } from "@testing-library/svelte"; 61 | import YourComponent from "./YourComponent.svelte"; 62 | 63 | describe("YourComponent", () => { 64 | it("should render in light mode by default", () => { 65 | const { container } = render(YourComponent); 66 | // Your assertions here 67 | }); 68 | }); 69 | ``` 70 | 71 | ## Important Notes 72 | 73 | 1. The mock implementation must be set up before any components are rendered that use mode-watcher. 74 | 2. The `simulateChange` method allows you to programmatically trigger media query changes in your tests. 75 | 3. Make sure to clean up any event listeners in your `afterEach` or `afterAll` blocks if needed. 76 | 77 | ## Troubleshooting 78 | 79 | If you encounter issues with the `matchMedia` mock not working as expected: 80 | 81 | 1. Verify that your `vitest.setup.ts` file is properly configured in your `vitest.config.ts` 82 | 2. Ensure that the mock is set up before any components are rendered 83 | 3. Check that you're using the latest version of mode-watcher, as the implementation details may change between versions 84 | -------------------------------------------------------------------------------- /docs/src/content/utilities/create-initial-mode-expression.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: createInitialModeExpression 3 | description: Creates a secure inline script to set the initial mode (light, dark, or system) before hydration. 4 | section: Utilities 5 | --- 6 | 7 | 10 | 11 | `createInitialModeExpression` outputs a small, inline JavaScript snippet as a string. 12 | 13 | It's typically used alongside server-rendered HTML and injected into the page head securely using SvelteKit's `transformPageChunk`. 14 | 15 | ## When to Use 16 | 17 | Use `createInitialModeExpression` when: 18 | 19 | - You're operating under a Content Security Policy (CSP) that requires a `nonce` for inline scripts. 20 | - You're rendering the initial page via SvelteKit server hooks and want to inject logic at render time. 21 | 22 | This approach is ideal for security-sensitive environments or platforms with strict CSP headers, where inline scripts must include a trusted nonce. 23 | 24 | ## Usage 25 | 26 | To use `createInitialModeExpression`, you need two things: 27 | 28 | ### 1. Modify `app.html` 29 | 30 | Add the following placeholder in the `` of your `app.html` file: 31 | 32 | ```html title="app.html" 33 | 36 | ``` 37 | 38 | This placeholder will be replaced server-side at render time. 39 | 40 | ### 2. Update `hooks.server.ts` 41 | 42 | Inject the snippet during SSR using `transformPageChunk`: 43 | 44 | ```ts title="hooks.server.ts" 45 | import { createInitialModeExpression } from "mode-watcher"; 46 | 47 | export const handle: Handle = async ({ event, resolve }) => { 48 | return resolve(event, { 49 | transformPageChunk: ({ html }) => 50 | html.replace("%modewatcher.snippet%", createInitialModeExpression()), 51 | }); 52 | }; 53 | ``` 54 | 55 | 56 | 57 | If you're planning to inject multiple types of initial client-side logic (e.g., directionality, locale), consider using a shared `%placeholder%` strategy with `transformPageChunk`. 58 | 59 | 60 | 61 | ## Credits 62 | 63 | Thanks to [@fnimick](https://github.com/fnimick) for contributing and validating this approach. 64 | -------------------------------------------------------------------------------- /docs/src/content/utilities/reset-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: resetMode 3 | description: Resets the mode to follow the system preference. 4 | section: Utilities 5 | --- 6 | 7 | `resetMode` is a utility function that clears the user's override and sets the mode back to `"system"`, allowing it to follow the operating system's color scheme. 8 | 9 | This is equivalent to calling `setMode("system")`. 10 | 11 | ## Usage 12 | 13 | ```svelte 14 | 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/src/content/utilities/set-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: setMode 3 | description: Sets the current mode to "light", "dark", or "system". 4 | section: Utilities 5 | --- 6 | 7 | `setMode` is a function that updates the user's preferred mode. 8 | 9 | It accepts one of three string values: `"light"`, `"dark"`, or `"system"`. 10 | 11 | This updates both the visual mode and the persisted preference in `localStorage`. 12 | 13 | ## Usage 14 | 15 | ```svelte 16 | 19 | 20 | 21 | 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/src/content/utilities/set-theme.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: setTheme 3 | description: Sets the current custom theme 4 | section: Utilities 5 | --- 6 | 7 | `setTheme` is a function that updates the active custom theme. 8 | 9 | Unlike [setMode](/docs/utilities/set-mode), which toggles light/dark/system modes, `setTheme` accepts any string (e.g. `"dracula"`, `"retro"`, `"corporate"`), persists it to `localStorage`, and applies it to the `` element via the `data-theme` attribute. 10 | 11 | This enables support for more granular visual themes, similar to [DaisyUI](https://daisyui.com). 12 | 13 | ## Usage 14 | 15 | ```svelte 16 | 19 | 20 | 21 | 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/src/content/utilities/toggle-mode.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: toggleMode 3 | description: Toggles between "light" and "dark" modes. 4 | section: Utilities 5 | --- 6 | 7 | `toggleMode` is a utility function that switches the current mode between `"light"` and `"dark"`. 8 | 9 | If the mode is currently set to `"system"`, it will first resolve to the system preference, then toggle from there. 10 | 11 | ## Usage 12 | 13 | ```svelte 14 | 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/src/lib/components/blueprint.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 | {@render children?.()} 11 | -------------------------------------------------------------------------------- /docs/src/lib/components/logos/mode-watcher-dark.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 51 | 55 | 59 | 65 | 66 | -------------------------------------------------------------------------------- /docs/src/lib/components/logos/mode-watcher-light.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 51 | 55 | 59 | 65 | 66 | -------------------------------------------------------------------------------- /docs/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /docs/src/lib/navigation.ts: -------------------------------------------------------------------------------- 1 | import { defineNavigation } from "@svecodocs/kit"; 2 | import ChalkboardTeacher from "phosphor-svelte/lib/ChalkboardTeacher"; 3 | import RocketLaunch from "phosphor-svelte/lib/RocketLaunch"; 4 | import NoteBlank from "phosphor-svelte/lib/NoteBlank"; 5 | import Tag from "phosphor-svelte/lib/Tag"; 6 | import { getAllDocs } from "./utils.js"; 7 | 8 | const allDocs = getAllDocs(); 9 | 10 | const components = allDocs 11 | .filter((doc) => doc.section === "Components") 12 | .map((doc) => ({ 13 | title: doc.title, 14 | href: `/docs/${doc.slug}`, 15 | })); 16 | const states = allDocs 17 | .filter((doc) => doc.section === "States") 18 | .map((doc) => ({ 19 | title: doc.title, 20 | href: `/docs/${doc.slug}`, 21 | })); 22 | const utilities = allDocs 23 | .filter((doc) => doc.section === "Utilities") 24 | .map((doc) => ({ 25 | title: doc.title, 26 | href: `/docs/${doc.slug}`, 27 | })); 28 | 29 | const testing = allDocs 30 | .filter((doc) => doc.section === "Testing") 31 | .map((doc) => ({ 32 | title: doc.title, 33 | href: `/docs/${doc.slug}`, 34 | })); 35 | 36 | export const navigation = defineNavigation({ 37 | anchors: [ 38 | { 39 | title: "Introduction", 40 | href: "/docs", 41 | icon: ChalkboardTeacher, 42 | }, 43 | { 44 | title: "Getting Started", 45 | href: "/docs/getting-started", 46 | icon: RocketLaunch, 47 | }, 48 | { 49 | title: "Mode vs Theme", 50 | href: "/docs/mode-vs-theme", 51 | icon: NoteBlank, 52 | }, 53 | { 54 | title: "Releases", 55 | href: "https://github.com/svecosystem/mode-watcher/releases", 56 | icon: Tag, 57 | }, 58 | ], 59 | sections: [ 60 | { 61 | title: "Components", 62 | items: components, 63 | }, 64 | { 65 | title: "States", 66 | items: states, 67 | }, 68 | { 69 | title: "Utilities", 70 | items: utilities, 71 | }, 72 | { 73 | title: "Testing", 74 | items: testing, 75 | }, 76 | ], 77 | }); 78 | -------------------------------------------------------------------------------- /docs/src/lib/site-config.ts: -------------------------------------------------------------------------------- 1 | import { defineSiteConfig } from "@svecodocs/kit"; 2 | 3 | export const siteConfig = defineSiteConfig({ 4 | name: "Mode Watcher", 5 | url: "https://mode-watcher.sveco.dev", 6 | ogImage: { 7 | url: "https://mode-watcher.sveco.dev/og.png", 8 | height: "630", 9 | width: "1200", 10 | }, 11 | description: "Simple light/dark mode and theme management for Svelte apps.", 12 | author: "Huntabyte", 13 | keywords: [ 14 | "svelte", 15 | "sveltekit", 16 | "dark mode", 17 | "themes", 18 | "light mode", 19 | "theme switcher", 20 | "theme toggle", 21 | "mode watcher", 22 | ], 23 | license: { 24 | name: "MIT", 25 | url: "https://github.com/svecosystem/mode-watcher/blob/main/LICENSE", 26 | }, 27 | links: { 28 | x: "https://x.com/huntabyte", 29 | github: "https://github.com/svecosystem/mode-watcher", 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /docs/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { docs, type Doc } from "$content/index.js"; 2 | import { error } from "@sveltejs/kit"; 3 | import type { Component } from "svelte"; 4 | 5 | export function getDocMetadata(slug: string = "index") { 6 | return docs.find((doc) => doc.slug === slug); 7 | } 8 | 9 | export function getAllDocs() { 10 | return docs; 11 | } 12 | 13 | function slugFromPath(path: string) { 14 | return path.replace("/src/content/", "").replace(".md", ""); 15 | } 16 | 17 | export type DocResolver = () => Promise<{ default: Component; metadata: Doc }>; 18 | 19 | export async function getDoc(slug: string = "index") { 20 | const modules = import.meta.glob("/src/content/**/*.md"); 21 | 22 | let match: { path?: string; resolver?: DocResolver } = {}; 23 | 24 | for (const [path, resolver] of Object.entries(modules)) { 25 | if (slugFromPath(path) === slug) { 26 | match = { path, resolver: resolver as unknown as DocResolver }; 27 | break; 28 | } 29 | } 30 | const doc = await match?.resolver?.(); 31 | const metadata = getDocMetadata(slug); 32 | if (!doc || !metadata) { 33 | error(404, "Could not find the document."); 34 | } 35 | 36 | return { 37 | component: doc.default, 38 | metadata, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /docs/src/routes/(docs)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | {#snippet logo()} 12 | 19 | -------------------------------------------------------------------------------- /docs/src/routes/(docs)/docs/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/src/routes/(docs)/docs/+page.ts: -------------------------------------------------------------------------------- 1 | import { getDoc } from "$lib/utils"; 2 | 3 | export async function load() { 4 | return getDoc(); 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/routes/(docs)/docs/[...slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/src/routes/(docs)/docs/[...slug]/+page.ts: -------------------------------------------------------------------------------- 1 | import { getDoc } from "$lib/utils"; 2 | 3 | export async function load({ params }) { 4 | return getDoc(params.slug); 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/routes/(landing)/+page.svelte: -------------------------------------------------------------------------------- 1 |

Welcome to SvelteKit

2 |

Visit svelte.dev/docs/kit to read the documentation

3 | -------------------------------------------------------------------------------- /docs/src/routes/(landing)/+page.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit"; 2 | 3 | export function load() { 4 | redirect(302, "/docs"); 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {@render children?.()} 12 | -------------------------------------------------------------------------------- /docs/src/routes/api/search.json/+server.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from "@sveltejs/kit"; 2 | import search from "./search.json" assert { type: "json" }; 3 | 4 | export const prerender = true; 5 | 6 | export const GET: RequestHandler = () => { 7 | return Response.json(search); 8 | }; 9 | -------------------------------------------------------------------------------- /docs/src/routes/api/search.json/search.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Getting Started", 4 | "href": "/docs/getting-started", 5 | "description": "A quick guide to get started using Svecodocs", 6 | "content": " import { Callout } from \"@svecodocs/kit\"; The following guide will walk you through the process of getting a Svecodocs project up and running. Clone the starter template Clone the Svecodocs starter template: pnpx degit svecosystem/svecodocs/starter Navigation The starter template comes with a basic navigation structure to get your started. To customize the navigation, adjust the src/lib/navigation.ts file. import { createNavigation } from \"@svecodocs/kit\"; export const navigation = createNavigation({ // Customize the navigation here }); Site config The site config is used to configure site-wide settings, such as the title, description, keywords, ogImage, and other metadata. The config is located in the src/lib/site-config.ts file. import { defineSiteConfig } from \"@svecodocs/kit\"; export const siteConfig = defineSiteConfig({ title: \"Svecodocs\", description: \"A SvelteKit docs starter template\", keywords: \"sveltekit, docs, starter, template\", ogImage: { url: \"https://docs.svecosystem.com/og.png\", height: 630, width: 1200, }, }); Per-Route Site Config You can override any part of the site config on a per-route basis using the useSiteConfig hook. This feature is still being worked on. Theme The starter template comes with the default Svecodocs theme (orange). To customize the theme, adjust the import in the src/app.css file to reflect the color scheme you want to use for your project. Each theme has been designed to work well in both light and dark mode. /* @import \"@svecodocs/kit/themes/orange.css\"; */ @import \"@svecodocs/kit/themes/emerald.css\"; @import \"@svecodocs/kit/globals.css\"; Logo To customize the logo displayed in the sidebar header, head to the src/routes/(docs)/+layout.svelte file and adjust the contents of the logo snippet. If the logo has a light and dark version, ensure to handle those similarly to the default Svecosystem logo. {#snippet logo()} The project name here {/snippet} `" 7 | }, 8 | { 9 | "title": "Introduction", 10 | "href": "/docs/index", 11 | "description": "What exactly is Svecodocs?", 12 | "content": " import { Callout } from '@svecodocs/kit' After spending countless hours building documentation sites for various projects, we decided to build a docs package/starter template that we can use for future projects. This project is a result of that effort. Svecodocs is a starting point/utility library for building documentation sites under the $2 umbrella. The code is open source, but it's built and maintained for our own specific needs, so we won't be accepting any public feature requests. You are more than welcome to fork the project and customize it to your own needs. Features Markdown-based docs**. Write docs using Markdown and Svelte components Light and dark mode**. Toggle between light and dark mode Syntax highlighting**. Code blocks are automatically highlighted SEO-friendly**. Meta tags and Open Graph support out of the box Pre-built components**. Tabs, callouts, and more to use within the documentation Custom unified plugins**. Custom remark and rehype plugins to give more flexibility over the rendered HTML shadcn-svelte**. Beautifully designed Svelte components Tailwind v4**. Tailwind CSS v4 is used for styling" 13 | }, 14 | { 15 | "title": "Button", 16 | "href": "/docs/components/button", 17 | "description": "A button component to use in examples and documentation.", 18 | "content": " import { Button, DemoContainer } from \"@svecodocs/kit\"; Usage import { Button } from \"@svecodocs/kit\"; Default Brand Ghost Outline Subtle Link Example Default Size Default Brand Destructive Ghost Outline Subtle Link Small Size Default Brand Destructive Ghost Outline Subtle Link " 19 | }, 20 | { 21 | "title": "Callout", 22 | "href": "/docs/components/callout", 23 | "description": "A callout component to highlight important information.", 24 | "content": " import { Callout } from \"@svecodocs/kit\"; import Avocado from \"phosphor-svelte/lib/Avocado\"; Callouts (also known as admonitions) are used to highlight a block of text. There are five types of callouts available: 'note', 'warning', 'danger', 'tip', and 'success'. You can override the default icon for the callout by passing a component via the icon prop. Usage import { Callout } from \"$lib/components\"; This is a note, used to highlight important information or provide additional context. You can use markdown in here as well! Just ensure you include a space between the component and the content in your Markdown file. Examples Warning This is an example of a warning callout. Note This is an example of a note callout. Danger This is an example of a danger callout. Tip This is an example of a tip callout. Success This is an example of a success callout. Custom Icon This is an example of a note callout with a custom icon. Custom Title This is an example of a warning callout with a custom title. " 25 | }, 26 | { 27 | "title": "Card Grid", 28 | "href": "/docs/components/card-grid", 29 | "description": "Display a grid of cards.", 30 | "content": " import { CardGrid, Card } from \"@svecodocs/kit\"; import RocketLaunch from \"phosphor-svelte/lib/RocketLaunch\"; import Blueprint from \"phosphor-svelte/lib/Blueprint\"; import Binary from \"phosphor-svelte/lib/Binary\"; import CloudCheck from \"phosphor-svelte/lib/CloudCheck\"; Use the CardGrid component to display a grid of $2 components. Usage import { CardGrid, Card } from \"@svecodocs/ui\"; You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. Examples 2 Columns (default) You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. 3 Columns You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. " 31 | }, 32 | { 33 | "title": "Card", 34 | "href": "/docs/components/card", 35 | "description": "Display a card with a title and optional icon.", 36 | "content": " import { Card } from \"@svecodocs/kit\"; import RocketLaunch from \"phosphor-svelte/lib/RocketLaunch\"; You can use the Card component to display a card with a title and optional icon. Usage With Icon Pass an icon component to the icon prop to display an icon in the card. import { Card } from \"@svecodocs/ui\"; import RocketLaunch from \"phosphor-svelte/lib/RocketLaunch\"; You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. Link Card Pass the href prop to convert the card into a link. import { Card } from \"@svecodocs/ui\"; import RocketLaunch from \"phosphor-svelte/lib/RocketLaunch\"; You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. Without Icon If you don't want to use an icon, just don't pass the icon prop. import { Card } from \"@svecodocs/ui\"; You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. Horizontal You can use the horizontal prop to display the card horizontally. import { Card } from \"@svecodocs/ui\"; import RocketLaunch from \"phosphor-svelte/lib/RocketLaunch\"; You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. You can use markdown in here, just ensure to include a space between the component and the content in your Markdown file. " 37 | }, 38 | { 39 | "title": "Demo Container", 40 | "href": "/docs/components/demo-container", 41 | "description": "Display a container with a border and a background color for examples/demos.", 42 | "content": " import { DemoContainer, Button } from \"@svecodocs/kit\"; Often times you'll want to display some demo/example components in a container. The DemoContainer component is a great way to do this, as it aligns effortlessly with the rest of the docs theme. Usage import { DemoContainer, Button } from \"@svecodocs/ui\"; Default Brand Outline Ghost Subtle Link Example Default Brand Outline Ghost Subtle Link " 43 | }, 44 | { 45 | "title": "Input", 46 | "href": "/docs/components/input", 47 | "description": "A form input component to use in examples and documentation.", 48 | "content": " import { Input, Label, DemoContainer, Button } from \"@svecodocs/kit\"; When building documentation, it's often necessary to provide users with a form input to showcase a specific feature. The Input component is a great way to do this, as it aligns effortlessly with the rest of the docs theme. The Label component is also provided to help with accessibility. Usage import { Input, Label } from \"@svecodocs/kit\"; Your name Example First name Last name Update Profile " 49 | }, 50 | { 51 | "title": "Native Select", 52 | "href": "/docs/components/native-select", 53 | "description": "A styled native select component to use in examples and documentation.", 54 | "content": " import { NativeSelect, Label, DemoContainer } from \"@svecodocs/kit\"; The NativeSelect component is a styled native select component that you can use in your examples and documentation. Usage import { NativeSelect } from \"@svecodocs/kit\"; Option 1 Option 2 Option 3 Example Select an option Option 1 Option 2 Option 3 " 55 | }, 56 | { 57 | "title": "Steps", 58 | "href": "/docs/components/steps", 59 | "description": "Display a series of series of steps.", 60 | "content": " import { Step, Steps, Callout } from \"@svecodocs/kit\"; The Steps and Step components are used to display a series of steps, breaking down a process into more manageable chunks. Usage import { Steps, Step } from \"$lib/components\"; Install the package You can install the project via npm or pnpm. Start your engines You can start the project by running npm run dev or pnpm run dev. Example Install the package You can install the project via npm or pnpm. npm install @svecodocs/ui Start your engines You can start the project by running npm run dev or pnpm dev. npm run dev If you plan to use markdown-specific syntax in your steps, ensure you include a space between the component and the content in your Markdown file. " 61 | }, 62 | { 63 | "title": "Tabs", 64 | "href": "/docs/components/tabs", 65 | "description": "Break content into multiple panes to reduce cognitive load.", 66 | "content": " import { Tabs, TabItem, Callout } from \"@svecodocs/kit\"; You can use the Tabs and TabItem components to create tabbed interfaces. A label prop must be provided to each TabItem which will be used to display the label. Whichever tab should be active by default is specified by the value prop on the Tabs component. Usage import { Tabs, TabItem } from \"@svecodocs/kit\"; This is the first tab's content. This is the second tab's content. Examples Simple Text This is the first tab's content. This is the second tab's content. Markdown Syntax import { Button } from \"@svecodocs/kit\"; alert(\"Hello!\")}>Click me export async function load() { return { transactions: [], }; } If you plan to use markdown-specific syntax in your tabs, ensure you include a space between the component and the content in your Markdown file. " 67 | }, 68 | { 69 | "title": "Textarea", 70 | "href": "/docs/components/textarea", 71 | "description": "A textarea component to use in examples and documentation.", 72 | "content": " import { Textarea, Label, DemoContainer, Button } from \"@svecodocs/kit\"; When building documentation, it's often necessary to provide users with a textarea to showcase a specific feature. The Textarea component is a great way to do this, as it aligns effortlessly with the rest of the docs theme. The Label component is also provided to help with accessibility. Usage import { Textarea, Label } from \"@svecodocs/kit\"; Your bio Example Bio Update Profile " 73 | }, 74 | { 75 | "title": "Navigation", 76 | "href": "/docs/configuration/navigation", 77 | "description": "Learn how to customize the navigation in your Svecodocs project.", 78 | "content": "Navigation is a key component of every site, documenting the structure of your site and providing a clear path for users to navigate through your content. Svecodocs comes with a navigation structure that is designed to be flexible and customizable. Each page in your site should have a corresponding navigation item, and the navigation items should be nested according to their hierarchy. Navigation Structure Main You can think of the main navigation as the root navigation for your site. Links in the main navigation are used to navigation to different sections of your site, such as \"Documentation\", \"API Reference\", and \"Blog\". Anchors Anchors are links that are displayed at the top of the sidebar and typically used to either highlight important information or provide quick access to linked content. Sections Sections are used to group related navigation items together. They are typically used to organize content into different categories, such as \"Components\", \"Configuration\", and \"Utilities\"." 79 | }, 80 | { 81 | "title": "Theme", 82 | "href": "/docs/configuration/theme", 83 | "description": "Learn how to customize the theme in your Svecodocs project.", 84 | "content": "The theme determines the branded color scheme for your site. A theme for each of the TailwindCSS colors is provided by the @svecodocs/kit package. Each theme has been designed to present well in both light and dark mode. Using a theme To use a theme, import the theme file into your src/app.css file before importing the @svecodocs/kit/globals.css file. /* @import \"@svecodocs/kit/theme-orange.css\"; */ @import \"@svecodocs/kit/theme-emerald.css\"; @import \"@svecodocs/kit/globals.css\"; It's not recommended to customize the theme to maintain consistency across the UI components that are provided by Svecodocs and align with the provided themes. Available themes | Theme name | Import path | | ---------- | ---------------------------------- | | orange | @svecodocs/kit/theme-orange.css | | green | @svecodocs/kit/theme-green.css | | blue | @svecodocs/kit/theme-blue.css | | purple | @svecodocs/kit/theme-purple.css | | pink | @svecodocs/kit/theme-pink.css | | lime | @svecodocs/kit/theme-lime.css | | yellow | @svecodocs/kit/theme-yellow.css | | cyan | @svecodocs/kit/theme-cyan.css | | teal | @svecodocs/kit/theme-teal.css | | violet | @svecodocs/kit/theme-violet.css | | amber | @svecodocs/kit/theme-amber.css | | red | @svecodocs/kit/theme-red.css | | sky | @svecodocs/kit/theme-sky.css | | emerald | @svecodocs/kit/theme-emerald.css | | fuchsia | @svecodocs/kit/theme-fuchsia.css | | rose | @svecodocs/kit/theme-rose.css | Tailwind Variables Svecodocs uses TailwindCSS to style the UI components and provides a set of Tailwind variables that can be used to style your examples/custom components. Gray We override the TailwindCSS gray color scale to provide our own grays. Brand You can use the brand color to use the brand color of your project." 85 | } 86 | ] 87 | -------------------------------------------------------------------------------- /docs/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/favicon-32x32.png -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/favicon.ico -------------------------------------------------------------------------------- /docs/static/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/static/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/static/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/docs/static/og.png -------------------------------------------------------------------------------- /docs/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, 6 | { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } 7 | ], 8 | "theme_color": "#ffffff", 9 | "background_color": "#ffffff", 10 | "display": "standalone" 11 | } 12 | -------------------------------------------------------------------------------- /docs/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 | import { mdsx } from "mdsx"; 3 | import mdsxConfig from "./mdsx.config.js"; 4 | import adapter from "@sveltejs/adapter-cloudflare"; 5 | 6 | /** @type {import('@sveltejs/kit').Config} */ 7 | const config = { 8 | preprocess: [mdsx(mdsxConfig), vitePreprocess()], 9 | kit: { 10 | alias: { 11 | "$content/*": ".velite/*", 12 | }, 13 | adapter: adapter(), 14 | }, 15 | extensions: [".svelte", ".md"], 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/velite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, s } from "velite"; 2 | 3 | const baseSchema = s.object({ 4 | title: s.string(), 5 | description: s.string(), 6 | path: s.path(), 7 | content: s.markdown(), 8 | navLabel: s.string().optional(), 9 | raw: s.raw(), 10 | toc: s.toc(), 11 | section: s.enum(["Overview", "Components", "States", "Utilities", "Testing"]), 12 | }); 13 | 14 | const docSchema = baseSchema.transform((data) => { 15 | return { 16 | ...data, 17 | slug: data.path, 18 | slugFull: `/${data.path}`, 19 | }; 20 | }); 21 | 22 | export default defineConfig({ 23 | root: "./src/content", 24 | collections: { 25 | docs: { 26 | name: "Doc", 27 | pattern: "./**/*.md", 28 | schema: docSchema, 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /docs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from "@sveltejs/kit/vite"; 2 | import { defineConfig } from "vite"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { resolve } from "node:path"; 5 | 6 | const __dirname = new URL(".", import.meta.url).pathname; 7 | 8 | export default defineConfig({ 9 | plugins: [sveltekit(), tailwindcss()], 10 | server: { 11 | fs: { 12 | allow: [resolve(__dirname, "./.velite")], 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import prettier from "eslint-config-prettier"; 3 | import js from "@eslint/js"; 4 | import { includeIgnoreFile } from "@eslint/compat"; 5 | import svelte from "eslint-plugin-svelte"; 6 | import globals from "globals"; 7 | import ts from "typescript-eslint"; 8 | 9 | const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); 10 | 11 | export default ts.config( 12 | includeIgnoreFile(gitignorePath), 13 | js.configs.recommended, 14 | ...ts.configs.recommended, 15 | ...svelte.configs.recommended, 16 | prettier, 17 | ...svelte.configs.prettier, 18 | { 19 | languageOptions: { 20 | globals: { ...globals.browser, ...globals.node }, 21 | }, 22 | rules: { "no-undef": "off" }, 23 | }, 24 | { 25 | files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], 26 | ignores: ["eslint.config.js", "svelte.config.js"], 27 | languageOptions: { 28 | parserOptions: { 29 | projectService: true, 30 | extraFileExtensions: [".svelte"], 31 | parser: ts.parser, 32 | }, 33 | }, 34 | }, 35 | { 36 | rules: { 37 | "@typescript-eslint/no-unused-vars": [ 38 | "error", 39 | { 40 | argsIgnorePattern: "^_", 41 | varsIgnorePattern: "^_", 42 | }, 43 | ], 44 | "@typescript-eslint/no-unused-expressions": "off", 45 | "svelte/no-unused-svelte-ignore": "off", 46 | }, 47 | }, 48 | { 49 | ignores: [ 50 | "build/", 51 | ".svelte-kit/", 52 | "dist/", 53 | ".svelte-kit/**/*", 54 | "docs/.svelte-kit/**/*", 55 | ".svelte-kit", 56 | "packages/mode-watcher/dist/**/*", 57 | "packages/mode-watcher/.svelte-kit/**/*", 58 | ], 59 | } 60 | ); 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "description": "Monorepo for Mode Watcher", 4 | "private": true, 5 | "version": "0.0.0", 6 | "author": "Hunter Johnston ", 7 | "license": "MIT", 8 | "type": "module", 9 | "scripts": { 10 | "build": "pnpm build:packages && pnpm build:docs", 11 | "build:packages": "pnpm -F \"./packages/**\" --parallel build", 12 | "build:docs": "pnpm -F \"./docs/**\" build", 13 | "check": "pnpm build:packages && pnpm -r check", 14 | "ci:publish": "pnpm build:packages && changeset publish", 15 | "dev": "pnpm -F \"./packages/**\" svelte-kit sync && pnpm -r --parallel --reporter append-only --color dev", 16 | "dev:sandbox": "pnpm -F \"./packages/**\" svelte-kit sync && pnpm -r --parallel --reporter append-only --color dev:sandbox", 17 | "format": "prettier --write .", 18 | "lint": "prettier --check . && eslint .", 19 | "test": "pnpm -r test" 20 | }, 21 | "engines": { 22 | "pnpm": ">=9.0.0", 23 | "node": ">=20" 24 | }, 25 | "packageManager": "pnpm@9.14.4", 26 | "devDependencies": { 27 | "@changesets/cli": "^2.27.10", 28 | "@eslint/compat": "^1.2.8", 29 | "@eslint/js": "^9.18.0", 30 | "@svitejs/changesets-changelog-github-compact": "^1.2.0", 31 | "@tailwindcss/vite": "^4.1.1", 32 | "@types/node": "^22.10.1", 33 | "eslint": "^9.18.0", 34 | "eslint-config-prettier": "^10.0.1", 35 | "eslint-plugin-svelte": "^3.5.0", 36 | "globals": "^16.0.0", 37 | "prettier": "^3.3.3", 38 | "prettier-plugin-svelte": "^3.3.3", 39 | "prettier-plugin-tailwindcss": "^0.6.11", 40 | "svelte": "^5.25.6", 41 | "typescript": "^5.6.3", 42 | "typescript-eslint": "^8.20.0", 43 | "wrangler": "^3.91.0" 44 | }, 45 | "pnpm": { 46 | "onlyBuiltDependencies": [ 47 | "esbuild" 48 | ] 49 | } 50 | } -------------------------------------------------------------------------------- /packages/mode-watcher/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | -------------------------------------------------------------------------------- /packages/mode-watcher/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/mode-watcher/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # mode-watcher 2 | 3 | ## 1.0.7 4 | 5 | ### Patch Changes 6 | 7 | - fix: ensure `window.matchMedia` is defined as function before calling ([#138](https://github.com/svecosystem/mode-watcher/pull/138)) 8 | 9 | ## 1.0.6 10 | 11 | ### Patch Changes 12 | 13 | - perf: prevent running `withoutTransition` on initial load as there is nothing to transition from ([#136](https://github.com/svecosystem/mode-watcher/pull/136)) 14 | 15 | ## 1.0.5 16 | 17 | ### Patch Changes 18 | 19 | - fix: prevent passing an empty token to classList.add/remove ([#134](https://github.com/svecosystem/mode-watcher/pull/134)) 20 | 21 | ## 1.0.4 22 | 23 | ### Patch Changes 24 | 25 | - change: mark `generateSetInitialModeExpression` as deprecated in favor of `createInitialModeExpression` ([#132](https://github.com/svecosystem/mode-watcher/pull/132)) 26 | 27 | ## 1.0.3 28 | 29 | ### Patch Changes 30 | 31 | - fix: ensure to always use custom storage keys if provided ([#130](https://github.com/svecosystem/mode-watcher/pull/130)) 32 | 33 | ## 1.0.2 34 | 35 | ### Patch Changes 36 | 37 | - fix: FOUC ([#122](https://github.com/svecosystem/mode-watcher/pull/122)) 38 | 39 | ## 1.0.1 40 | 41 | ### Patch Changes 42 | 43 | - fix: `window.matchMedia` is not defined ([#120](https://github.com/svecosystem/mode-watcher/pull/120)) 44 | 45 | ## 1.0.0 46 | 47 | ### Major Changes 48 | 49 | - Mode Watcher 1.0 (Svelte 5) ([#112](https://github.com/svecosystem/mode-watcher/pull/112)) 50 | 51 | ## 0.5.1 52 | 53 | ### Patch Changes 54 | 55 | - silence hydration mismatch warning ([#108](https://github.com/svecosystem/mode-watcher/pull/108)) 56 | 57 | ## 0.5.0 58 | 59 | ### Minor Changes 60 | 61 | - feat: add ability to disable head script injection via the `disableHeadScriptInjection` prop for handling in hooks.server files ([#89](https://github.com/svecosystem/mode-watcher/pull/89)) 62 | 63 | ### Patch Changes 64 | 65 | - fix: hydration issues ([#99](https://github.com/svecosystem/mode-watcher/pull/99)) 66 | 67 | ## 0.4.1 68 | 69 | ### Patch Changes 70 | 71 | - Fix bug where `data-theme` attribute wasn't syncing with the theme ([#87](https://github.com/svecosystem/mode-watcher/pull/87)) 72 | 73 | ## 0.4.0 74 | 75 | ### Minor Changes 76 | 77 | - feat: Custom ClassNames ([#80](https://github.com/svecosystem/mode-watcher/pull/80)) 78 | 79 | - feat: Add `nonce` prop ([#82](https://github.com/svecosystem/mode-watcher/pull/82)) 80 | 81 | - feat: Custom storage key names `modeStorageKey` `themeStorageKey` ([#84](https://github.com/svecosystem/mode-watcher/pull/84)) 82 | 83 | - feat: Add support for custom themes ([#83](https://github.com/svecosystem/mode-watcher/pull/83)) 84 | 85 | ## 0.3.1 86 | 87 | ### Patch Changes 88 | 89 | - chore: add svelte 5 to peer deps ([#77](https://github.com/svecosystem/mode-watcher/pull/77)) 90 | 91 | ## 0.3.0 92 | 93 | ### Minor Changes 94 | 95 | - feat: `disableTransitions` prop ([#68](https://github.com/svecosystem/mode-watcher/pull/68)) 96 | 97 | ## 0.2.2 98 | 99 | ### Patch Changes 100 | 101 | - Update `moduleResolution` to `NodeNext` ([#63](https://github.com/svecosystem/mode-watcher/pull/63)) 102 | 103 | ## 0.2.1 104 | 105 | ### Patch Changes 106 | 107 | - Fix incorrect localStorage key ([#51](https://github.com/svecosystem/mode-watcher/pull/51)) 108 | 109 | ## 0.2.0 110 | 111 | ### Minor Changes 112 | 113 | - Allow `mode-watcher` to manage the theme-color meta tag ([#48](https://github.com/svecosystem/mode-watcher/pull/48)) 114 | 115 | ## 0.1.2 116 | 117 | ### Patch Changes 118 | 119 | - f30aa9f: add defaultMode prop 120 | 121 | ## 0.1.1 122 | 123 | ### Patch Changes 124 | 125 | - 8c71d5a: Fix bug where mode would not change unless the `mode` store was subscribed to 126 | 127 | ## 0.1.0 128 | 129 | ### Minor Changes 130 | 131 | - ec7750d: Rewrite mode-watcher with custom stores 132 | 133 | ## 0.0.7 134 | 135 | ### Patch Changes 136 | 137 | - abc9b03: Fix bug missing withoutTransition in head 138 | 139 | ## 0.0.6 140 | 141 | ### Patch Changes 142 | 143 | - 289d4d6: Fix: prevent transitions during theme change 144 | 145 | ## 0.0.5 146 | 147 | ### Patch Changes 148 | 149 | - 8c93706: Add `track` prop which allows `` to track changes in system preference 150 | - 9dbbb39: Fixed bug in `setMode` which prevented user preferences from being set 151 | - 4cb519e: Fix: remove unnecessary dep 152 | 153 | ## 0.0.4 154 | 155 | ### Patch Changes 156 | 157 | - 487c5e3: Change persistent stores to use `dark` | `light` strings instead of booleans 158 | 159 | ## 0.0.3 160 | 161 | ### Patch Changes 162 | 163 | - 0d3ef7f: Add `resetMode` function to reset mode to OS preference 164 | 165 | ## 0.0.2 166 | 167 | ### Patch Changes 168 | 169 | - a03b451: Add `color-scheme` style to document element 170 | 171 | ## 0.0.1 172 | 173 | ### Patch Changes 174 | 175 | - 5a18026: Initial release 176 | -------------------------------------------------------------------------------- /packages/mode-watcher/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hunter Johnston 4 | Copyright (c) 2024 Svecosystem 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /packages/mode-watcher/README.md: -------------------------------------------------------------------------------- 1 | # Mode Watcher 2 | 3 | Simple utilities to manage light & dark mode in your SvelteKit app. 4 | 5 | 6 | 7 | [![npm version](https://flat.badgen.net/npm/v/mode-watcher?color=yellow)](https://npmjs.com/package/mode-watcher) 8 | [![npm downloads](https://flat.badgen.net/npm/dm/mode-watcher?color=yellow)](https://npmjs.com/package/mode-watcher) 9 | [![license](https://flat.badgen.net/github/license/svecosystem/mode-watcher?color=yellow)](https://github.com/svecosystem/mode-watcher/blob/main/LICENSE) 10 | 11 | 12 | 13 | [![](https://dcbadge.vercel.app/api/server/fdXy3Sk8Gq?style=flat)](https://discord.gg/fdXy3Sk8Gq) 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npm install mode-watcher 19 | ``` 20 | 21 | ## Usage 22 | 23 | Add the `ModeWatcher` component to your root `+layout.svelte` file. 24 | 25 | ```svelte 26 | 30 | 31 | 32 | {@render children()} 33 | ``` 34 | 35 | The `ModeWatcher` component will automatically detect the user's preferences and apply/remove the `"dark"` class, along with the corresponding `color-scheme` style attribute to the `html` element. 36 | 37 | `ModeWatcher` will automatically track operating system preferences and apply these if no user preference is set. If you wish to disable this behavior, set the `track` prop to `false`: 38 | 39 | ```svelte 40 | 41 | ``` 42 | 43 | `ModeWatcher` can also be configured with a default mode instead of automatically detecting the user's preference. 44 | 45 | To set a default mode, use the `defaultMode` prop: 46 | 47 | ```svelte 48 | 49 | ``` 50 | 51 | `ModeWatcher` can manage the `theme-color` meta tag for you. 52 | 53 | To enable this, set the `themeColors` prop to your preferred colors: 54 | 55 | ```svelte 56 | 57 | ``` 58 | 59 | ## API 60 | 61 | ### toggleMode 62 | 63 | A function that toggles the current mode. 64 | 65 | ```svelte 66 | 69 | 70 | 71 | ``` 72 | 73 | ### setMode 74 | 75 | A function that sets the current mode. It accepts a string with the value `"light"`, `"dark"` or `"system"`. 76 | 77 | ```svelte 78 | 81 | 82 | 83 | 84 | ``` 85 | 86 | ### resetMode 87 | 88 | A function that resets the mode to system preferences. 89 | 90 | ```svelte 91 | 94 | 95 | 96 | ``` 97 | 98 | ### mode 99 | 100 | A readable store that contains the current mode. It can be `"light"` or `"dark"` or `undefined` if evaluated on the server. 101 | 102 | ```svelte 103 | 114 | 115 | 116 | ``` 117 | 118 | ### userPrefersMode 119 | 120 | A writeable store that represents the user's mode preference. It can be `"light"`, `"dark"` or `"system"`. 121 | 122 | ### systemPrefersMode 123 | 124 | A readable store that represents the operating system's mode preference. It can be `"light"`, `"dark"` or `undefined` if evaluated on the server. Will automatically track changes to the operating system's mode preference unless this is disabled with the `tracking()` method which takes a boolean. Normally this is disabled by setting the `track` prop to false in the `` component. 125 | 126 | ## Demo / Reproduction Template 127 | 128 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/svecosystem/mode-watcher-reproduction) 129 | 130 | ## Sponsors 131 | 132 | This project is supported by the following beautiful people/organizations: 133 | 134 |

135 | 136 | Logos from Sponsors 137 | 138 |

139 | 140 | ## License 141 | 142 | 143 | 144 | Published under the [MIT](https://github.com/svecosystem/mode-watcher/blob/main/LICENSE) license. 145 | Made by [@huntabyte](https://github.com/huntabyte), [@ollema](https://github.com/ollema), and [community](https://github.com/svecosystem/mode-watcher/graphs/contributors) 💛 146 |

147 | 148 | 149 | 150 | 151 | 152 | 153 | ## Community 154 | 155 | Join the Discord server to ask questions, find collaborators, or just say hi! 156 | 157 | 158 | 159 | 160 | Svecosystem Discord community 161 | 162 | 163 | -------------------------------------------------------------------------------- /packages/mode-watcher/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mode-watcher", 3 | "version": "1.0.7", 4 | "description": "SSR-friendly light and dark mode for SvelteKit", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/svecosystem/mode-watcher.git", 9 | "directory": "packages/mode-watcher" 10 | }, 11 | "scripts": { 12 | "dev": "pnpm watch", 13 | "dev:sandbox": "vite", 14 | "build": "pnpm package", 15 | "preview": "vite preview", 16 | "package": "svelte-kit sync && svelte-package && publint", 17 | "test": "vitest", 18 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 19 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 20 | "watch": "svelte-kit sync && svelte-package --watch" 21 | }, 22 | "exports": { 23 | ".": { 24 | "types": "./dist/index.d.ts", 25 | "svelte": "./dist/index.js" 26 | } 27 | }, 28 | "files": [ 29 | "dist", 30 | "!dist/**/*.test.*", 31 | "!dist/**/*.spec.*" 32 | ], 33 | "peerDependencies": { 34 | "svelte": "^5.27.0" 35 | }, 36 | "devDependencies": { 37 | "@playwright/test": "^1.28.1", 38 | "@sveltejs/kit": "^2.20.7", 39 | "@sveltejs/package": "^2.3.11", 40 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 41 | "@svitejs/changesets-changelog-github-compact": "^1.1.0", 42 | "@testing-library/dom": "^10.3.1", 43 | "@testing-library/jest-dom": "^6.4.6", 44 | "@testing-library/svelte": "^5.2.0", 45 | "@testing-library/user-event": "^14.5.1", 46 | "@types/node": "^20.14.10", 47 | "autoprefixer": "^10.4.14", 48 | "jsdom": "^24.1.0", 49 | "postcss": "^8.4.24", 50 | "postcss-load-config": "^4.0.1", 51 | "publint": "^0.1.9", 52 | "svelte": "^5.27.0", 53 | "svelte-check": "^4.1.6", 54 | "tailwindcss": "^3.3.2", 55 | "tslib": "^2.8.1", 56 | "typescript": "^5.8.3", 57 | "vite": "^6.3.0", 58 | "vitest": "^3.1.1", 59 | "vitest-localstorage-mock": "^0.1.2" 60 | }, 61 | "svelte": "./dist/index.js", 62 | "types": "./dist/index.d.ts", 63 | "type": "module", 64 | "dependencies": { 65 | "runed": "^0.25.0", 66 | "svelte-toolbelt": "^0.7.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/mode-watcher/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: "pnpm run build && pnpm run preview", 6 | port: 4173, 7 | }, 8 | testDir: "tests", 9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/mode-watcher/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | 3 | const tailwindcss = require("tailwindcss"); 4 | const autoprefixer = require("autoprefixer"); 5 | 6 | const config = { 7 | plugins: [ 8 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 9 | tailwindcss(), 10 | //But others, like autoprefixer, need to run after, 11 | autoprefixer, 12 | ], 13 | }; 14 | 15 | module.exports = config; 16 | -------------------------------------------------------------------------------- /packages/mode-watcher/scripts/setupTest.ts: -------------------------------------------------------------------------------- 1 | // setupTest.ts 2 | import "@testing-library/svelte/vitest"; 3 | import "@testing-library/jest-dom/vitest"; 4 | import "vitest-localstorage-mock"; 5 | import * as matchers from "@testing-library/jest-dom/matchers"; 6 | import { expect, vi } from "vitest"; 7 | import type { Navigation, Page } from "@sveltejs/kit"; 8 | import { readable } from "svelte/store"; 9 | import { configure } from "@testing-library/dom"; 10 | import type * as environment from "$app/environment"; 11 | import type * as navigation from "$app/navigation"; 12 | import type * as stores from "$app/stores"; 13 | 14 | // @ts-expect-error - this works 15 | expect.extend(matchers); 16 | 17 | configure({ 18 | asyncUtilTimeout: 1500, 19 | }); 20 | 21 | // Mock SvelteKit runtime module $app/environment 22 | vi.mock("$app/environment", (): typeof environment => ({ 23 | browser: false, 24 | dev: true, 25 | building: false, 26 | version: "any", 27 | })); 28 | 29 | // Mock SvelteKit runtime module $app/navigation 30 | vi.mock("$app/navigation", (): typeof navigation => ({ 31 | afterNavigate: () => {}, 32 | beforeNavigate: () => {}, 33 | disableScrollHandling: () => {}, 34 | goto: () => Promise.resolve(), 35 | invalidate: () => Promise.resolve(), 36 | invalidateAll: () => Promise.resolve(), 37 | preloadData: () => 38 | Promise.resolve({ 39 | data: {}, 40 | type: "loaded", 41 | status: 200, 42 | }), 43 | preloadCode: () => Promise.resolve(), 44 | onNavigate: () => {}, 45 | pushState: () => {}, 46 | replaceState: () => {}, 47 | })); 48 | 49 | // Mock SvelteKit runtime module $app/stores 50 | vi.mock("$app/stores", (): typeof stores => { 51 | const getStores: typeof stores.getStores = () => { 52 | const navigating = readable(null); 53 | const page = readable({ 54 | url: new URL("http://localhost"), 55 | params: {}, 56 | route: { 57 | id: null, 58 | }, 59 | status: 200, 60 | error: null, 61 | data: {}, 62 | form: undefined, 63 | state: {}, 64 | }); 65 | const updated = { subscribe: readable(false).subscribe, check: async () => false }; 66 | 67 | return { navigating, page, updated }; 68 | }; 69 | 70 | const page: typeof stores.page = { 71 | subscribe(fn) { 72 | return getStores().page.subscribe(fn); 73 | }, 74 | }; 75 | const navigating: typeof stores.navigating = { 76 | subscribe(fn) { 77 | return getStores().navigating.subscribe(fn); 78 | }, 79 | }; 80 | const updated: typeof stores.updated = { 81 | subscribe(fn) { 82 | return getStores().updated.subscribe(fn); 83 | }, 84 | check: async () => false, 85 | }; 86 | 87 | return { 88 | getStores, 89 | navigating, 90 | page, 91 | updated, 92 | }; 93 | }); 94 | 95 | export const mediaQueryState = { 96 | matches: false, 97 | }; 98 | 99 | const listeners: ((event: unknown) => void)[] = []; 100 | 101 | Object.defineProperty(window, "matchMedia", { 102 | writable: true, 103 | value: vi.fn().mockImplementation((query) => ({ 104 | matches: mediaQueryState.matches, 105 | media: query, 106 | onchange: null, 107 | addListener: vi.fn(), 108 | removeListener: vi.fn(), 109 | addEventListener: vi.fn((type, callback) => { 110 | if (type === "change") { 111 | listeners.push(callback); 112 | } 113 | }), 114 | removeEventListener: vi.fn((type, callback) => { 115 | const index = listeners.indexOf(callback); 116 | if (index !== -1) { 117 | listeners.splice(index, 1); 118 | } 119 | }), 120 | dispatchEvent: vi.fn((event) => { 121 | if (event.type === "change") { 122 | for (const callback of listeners) { 123 | callback({ 124 | matches: mediaQueryState.matches, 125 | media: "(prefers-color-scheme: light)", 126 | }); 127 | } 128 | } 129 | }), 130 | })), 131 | }); 132 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/app.postcss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5% 64.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 240 10% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --card: 240 10% 3.9%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 240 10% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 0 0% 98%; 37 | --primary-foreground: 240 5.9% 10%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 240 3.7% 15.9%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 240 3.7% 15.9%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 85.7% 97.3%; 46 | --border: 240 3.7% 15.9%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 4.9% 83.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | font-feature-settings: 59 | "rlig" 1, 60 | "calt" 1; 61 | } 62 | } 63 | 64 | @media (max-width: 640px) { 65 | .container { 66 | @apply px-4; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | describe("sum test", () => { 4 | it("adds 1 + 2 to equal 3", () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/components/mode-watcher-full.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | {#if themeColors} 18 | 19 | 20 | 21 | 22 | {/if} 23 | 24 | {@html `(` + 25 | setInitialMode.toString() + 26 | `)(` + 27 | JSON.stringify(initConfig) + 28 | `);`} 29 | 30 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/components/mode-watcher-lite.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {#if themeColors} 8 | 9 | 10 | 11 | 12 | {/if} 13 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/components/mode-watcher.svelte: -------------------------------------------------------------------------------- 1 | 92 | 93 | {#if disableHeadScriptInjection} 94 | 95 | {:else} 96 | 97 | {/if} 98 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/components/types.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/packages/mode-watcher/src/lib/components/types.ts -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateSetInitialModeExpression, 3 | createInitialModeExpression, 4 | resetMode, 5 | setMode, 6 | setTheme, 7 | toggleMode, 8 | } from "./mode.js"; 9 | import { modeStorageKey, themeStorageKey } from "./storage-keys.svelte.js"; 10 | import { mode, theme } from "./states.svelte.js"; 11 | import { userPrefersMode, systemPrefersMode } from "./mode-states.svelte.js"; 12 | 13 | export { 14 | generateSetInitialModeExpression, 15 | createInitialModeExpression, 16 | setMode, 17 | toggleMode, 18 | resetMode, 19 | modeStorageKey, 20 | userPrefersMode, 21 | systemPrefersMode, 22 | mode, 23 | theme, 24 | setTheme, 25 | themeStorageKey, 26 | }; 27 | export type { SystemModeValue, UserPrefersMode, SystemPrefersMode } from "./mode-states.svelte.js"; 28 | export { default as ModeWatcher } from "./components/mode-watcher.svelte"; 29 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/mode-states.svelte.ts: -------------------------------------------------------------------------------- 1 | import { PersistedState, watch } from "runed"; 2 | import { isBrowser, noopStorage } from "./utils.js"; 3 | import type { Mode } from "./types.js"; 4 | import { modeStorageKey } from "./storage-keys.svelte.js"; 5 | import { isValidMode } from "./modes.js"; 6 | import { MediaQuery } from "svelte/reactivity"; 7 | 8 | export class UserPrefersMode { 9 | #defaultValue: Mode = "system"; 10 | #storage = isBrowser ? localStorage : noopStorage; 11 | #initialValue = this.#storage.getItem(modeStorageKey.current); 12 | #value = isValidMode(this.#initialValue) ? this.#initialValue : this.#defaultValue; 13 | #persisted = $state(this.#makePersisted()); 14 | 15 | #makePersisted(value: Mode = this.#value) { 16 | return new PersistedState(modeStorageKey.current, value, { 17 | serializer: { 18 | serialize: (v) => v, 19 | deserialize: (v) => { 20 | if (isValidMode(v)) return v; 21 | return this.#defaultValue; 22 | }, 23 | }, 24 | }); 25 | } 26 | 27 | constructor() { 28 | $effect.root(() => { 29 | return watch.pre( 30 | () => modeStorageKey.current, 31 | (_, prevStorageKey) => { 32 | const currModeValue = this.#persisted.current; 33 | this.#persisted = this.#makePersisted(currModeValue); 34 | if (prevStorageKey) { 35 | localStorage.removeItem(prevStorageKey); 36 | } 37 | } 38 | ); 39 | }); 40 | } 41 | 42 | get current() { 43 | return this.#persisted.current; 44 | } 45 | 46 | set current(newValue: Mode) { 47 | this.#persisted.current = newValue; 48 | } 49 | } 50 | 51 | export type SystemModeValue = "light" | "dark" | undefined; 52 | 53 | export class SystemPrefersMode { 54 | #defaultValue: SystemModeValue = undefined; 55 | #track = true; 56 | #current = $state(this.#defaultValue); 57 | #mediaQueryState = 58 | typeof window !== "undefined" && typeof window.matchMedia === "function" 59 | ? new MediaQuery("prefers-color-scheme: light") 60 | : { current: false }; 61 | 62 | query() { 63 | if (!isBrowser) return; 64 | this.#current = this.#mediaQueryState.current ? "light" : "dark"; 65 | } 66 | 67 | tracking(active: boolean) { 68 | this.#track = active; 69 | } 70 | 71 | constructor() { 72 | $effect.root(() => { 73 | $effect.pre(() => { 74 | if (!this.#track) return; 75 | this.query(); 76 | }); 77 | }); 78 | 79 | this.query = this.query.bind(this); 80 | this.tracking = this.tracking.bind(this); 81 | } 82 | 83 | get current() { 84 | return this.#current; 85 | } 86 | } 87 | 88 | /** 89 | * Writable state that represents the user's preferred mode 90 | * (`"dark"`, `"light"` or `"system"`) 91 | */ 92 | export const userPrefersMode = new UserPrefersMode(); 93 | 94 | /** 95 | * Readable store that represents the system's preferred mode (`"dark"`, `"light"` or `undefined`) 96 | */ 97 | export const systemPrefersMode = new SystemPrefersMode(); 98 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/mode.ts: -------------------------------------------------------------------------------- 1 | import { userPrefersMode } from "./mode-states.svelte.js"; 2 | import { customTheme } from "./theme-state.svelte.js"; 3 | import { derivedMode } from "./states.svelte.js"; 4 | import type { Mode, ThemeColors } from "./types.js"; 5 | 6 | /** Toggle between light and dark mode */ 7 | export function toggleMode(): void { 8 | userPrefersMode.current = derivedMode.current === "dark" ? "light" : "dark"; 9 | } 10 | 11 | /** Set the mode to light or dark */ 12 | export function setMode(mode: Mode): void { 13 | userPrefersMode.current = mode; 14 | } 15 | 16 | /** Reset the mode to operating system preference */ 17 | export function resetMode(): void { 18 | userPrefersMode.current = "system"; 19 | } 20 | 21 | /** Set the theme to a custom value */ 22 | export function setTheme(newTheme: string): void { 23 | customTheme.current = newTheme; 24 | } 25 | 26 | export function defineConfig(config: SetInitialModeArgs) { 27 | return config; 28 | } 29 | 30 | type SetInitialModeArgs = { 31 | defaultMode?: Mode; 32 | themeColors?: ThemeColors; 33 | darkClassNames?: string[]; 34 | lightClassNames?: string[]; 35 | defaultTheme?: string; 36 | modeStorageKey?: string; 37 | themeStorageKey?: string; 38 | }; 39 | 40 | /** Used to set the mode on initial page load to prevent FOUC */ 41 | export function setInitialMode({ 42 | defaultMode = "system", 43 | themeColors, 44 | darkClassNames = ["dark"], 45 | lightClassNames = [], 46 | defaultTheme = "", 47 | modeStorageKey = "mode-watcher-mode", 48 | themeStorageKey = "mode-watcher-theme", 49 | }: SetInitialModeArgs) { 50 | const rootEl = document.documentElement; 51 | const mode = localStorage.getItem(modeStorageKey) ?? defaultMode; 52 | const theme = localStorage.getItem(themeStorageKey) ?? defaultTheme; 53 | const light = 54 | mode === "light" || 55 | (mode === "system" && window.matchMedia("(prefers-color-scheme: light)").matches); 56 | if (light) { 57 | if (darkClassNames.length) rootEl.classList.remove(...darkClassNames.filter(Boolean)); 58 | if (lightClassNames.length) rootEl.classList.add(...lightClassNames.filter(Boolean)); 59 | } else { 60 | if (lightClassNames.length) rootEl.classList.remove(...lightClassNames.filter(Boolean)); 61 | if (darkClassNames.length) rootEl.classList.add(...darkClassNames.filter(Boolean)); 62 | } 63 | rootEl.style.colorScheme = light ? "light" : "dark"; 64 | 65 | if (themeColors) { 66 | const themeMetaEl = document.querySelector('meta[name="theme-color"]'); 67 | if (themeMetaEl) { 68 | themeMetaEl.setAttribute( 69 | "content", 70 | mode === "light" ? themeColors.light : themeColors.dark 71 | ); 72 | } 73 | } 74 | 75 | if (theme) { 76 | rootEl.setAttribute("data-theme", theme); 77 | localStorage.setItem(themeStorageKey, theme); 78 | } 79 | 80 | localStorage.setItem(modeStorageKey, mode); 81 | } 82 | 83 | /** 84 | * A type-safe way to generate the source expression used to set the initial mode and avoid FOUC. 85 | * 86 | * @deprecated Use `createInitialModeExpression` instead. 87 | */ 88 | export function generateSetInitialModeExpression(config: SetInitialModeArgs = {}): string { 89 | return `(${setInitialMode.toString()})(${JSON.stringify(config)});`; 90 | } 91 | 92 | /** 93 | * A type-safe way to generate the source expression used to set the initial mode and avoid FOUC. 94 | */ 95 | export const createInitialModeExpression = generateSetInitialModeExpression; 96 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/modes.ts: -------------------------------------------------------------------------------- 1 | import type { Mode } from "./types.js"; 2 | 3 | /** 4 | * the modes that are supported, used for validation & type 5 | * derivation 6 | */ 7 | export const modes = ["dark", "light", "system"] as const; 8 | 9 | export function isValidMode(value: unknown): value is Mode { 10 | if (typeof value !== "string") return false; 11 | return modes.includes(value as Mode); 12 | } 13 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/states.svelte.ts: -------------------------------------------------------------------------------- 1 | import { box } from "svelte-toolbelt"; 2 | import { isBrowser, sanitizeClassNames } from "./utils.js"; 3 | import type { ThemeColors } from "./types.js"; 4 | import { withoutTransition } from "./without-transition.js"; 5 | import { systemPrefersMode, userPrefersMode } from "./mode-states.svelte.js"; 6 | import { customTheme } from "./theme-state.svelte.js"; 7 | 8 | /** 9 | * Theme colors for light and dark modes. 10 | */ 11 | export const themeColors = box(undefined); 12 | 13 | /** 14 | * Whether to disable transitions when changing the mode. 15 | */ 16 | export const disableTransitions = box(true); 17 | 18 | /** 19 | * The classnames to add to the root `html` element when the mode is dark. 20 | */ 21 | export const darkClassNames = box([]); 22 | 23 | /** 24 | * The classnames to add to the root `html` element when the mode is light. 25 | */ 26 | export const lightClassNames = box([]); 27 | 28 | function createDerivedMode() { 29 | const current = $derived.by(() => { 30 | if (!isBrowser) return undefined; 31 | const derivedMode = 32 | userPrefersMode.current === "system" 33 | ? systemPrefersMode.current 34 | : userPrefersMode.current; 35 | const sanitizedDarkClassNames = sanitizeClassNames(darkClassNames.current); 36 | const sanitizedLightClassNames = sanitizeClassNames(lightClassNames.current); 37 | 38 | function update() { 39 | const htmlEl = document.documentElement; 40 | const themeColorEl = document.querySelector('meta[name="theme-color"]'); 41 | if (derivedMode === "light") { 42 | if (sanitizedDarkClassNames.length) 43 | htmlEl.classList.remove(...sanitizedDarkClassNames); 44 | if (sanitizedLightClassNames.length) 45 | htmlEl.classList.add(...sanitizedLightClassNames); 46 | htmlEl.style.colorScheme = "light"; 47 | if (themeColorEl && themeColors.current) { 48 | themeColorEl.setAttribute("content", themeColors.current.light); 49 | } 50 | } else { 51 | if (sanitizedLightClassNames.length) 52 | htmlEl.classList.remove(...sanitizedLightClassNames); 53 | if (sanitizedDarkClassNames.length) 54 | htmlEl.classList.add(...sanitizedDarkClassNames); 55 | htmlEl.style.colorScheme = "dark"; 56 | if (themeColorEl && themeColors.current) { 57 | themeColorEl.setAttribute("content", themeColors.current.dark); 58 | } 59 | } 60 | } 61 | 62 | if (disableTransitions.current) { 63 | withoutTransition(update); 64 | } else { 65 | update(); 66 | } 67 | 68 | return derivedMode; 69 | }); 70 | 71 | return { 72 | get current() { 73 | return current; 74 | }, 75 | }; 76 | } 77 | 78 | function createDerivedTheme() { 79 | const current = $derived.by(() => { 80 | customTheme.current; 81 | if (!isBrowser) return undefined; 82 | 83 | function update() { 84 | const htmlEl = document.documentElement; 85 | htmlEl.setAttribute("data-theme", customTheme.current); 86 | } 87 | 88 | if (disableTransitions.current) { 89 | withoutTransition(update); 90 | } else { 91 | update(); 92 | } 93 | return customTheme.current; 94 | }); 95 | 96 | return { 97 | get current() { 98 | return current; 99 | }, 100 | }; 101 | } 102 | 103 | /** 104 | * Derived store that represents the current mode (`"dark"`, `"light"` or `undefined`) 105 | */ 106 | export const derivedMode = createDerivedMode(); 107 | 108 | /** 109 | * Derived store that represents the current custom theme 110 | */ 111 | export const derivedTheme = createDerivedTheme(); 112 | 113 | export { derivedMode as mode, derivedTheme as theme }; 114 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/storage-keys.svelte.ts: -------------------------------------------------------------------------------- 1 | import { box } from "svelte-toolbelt"; 2 | 3 | /** 4 | * The key used to store the `mode` in localStorage. 5 | */ 6 | export const modeStorageKey = box("mode-watcher-mode"); 7 | 8 | /** 9 | * The key used to store the `theme` in localStorage. 10 | */ 11 | export const themeStorageKey = box("mode-watcher-theme"); 12 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/theme-state.svelte.ts: -------------------------------------------------------------------------------- 1 | import { PersistedState, watch } from "runed"; 2 | import { themeStorageKey } from "./storage-keys.svelte.js"; 3 | import { isBrowser, noopStorage } from "./utils.js"; 4 | 5 | class CustomTheme { 6 | #storage = isBrowser ? localStorage : noopStorage; 7 | #initialValue = this.#storage.getItem(themeStorageKey.current); 8 | #value = 9 | this.#initialValue === null || this.#initialValue === undefined ? "" : this.#initialValue; 10 | #persisted = $state(this.#makePersisted()); 11 | 12 | #makePersisted(value: string = this.#value) { 13 | return new PersistedState(themeStorageKey.current, value, { 14 | serializer: { 15 | serialize: (v) => { 16 | if (typeof v !== "string") return ""; 17 | return v; 18 | }, 19 | deserialize: (v) => v, 20 | }, 21 | }); 22 | } 23 | 24 | constructor() { 25 | $effect.root(() => { 26 | return watch.pre( 27 | () => themeStorageKey.current, 28 | (_, prevStorageKey) => { 29 | const currModeValue = this.#persisted.current; 30 | this.#persisted = this.#makePersisted(currModeValue); 31 | if (prevStorageKey) { 32 | localStorage.removeItem(prevStorageKey); 33 | } 34 | } 35 | ); 36 | }); 37 | } 38 | 39 | /** 40 | * The current theme. 41 | * @returns The current theme. 42 | */ 43 | get current() { 44 | return this.#persisted.current; 45 | } 46 | 47 | /** 48 | * Set the current theme. 49 | * @param newValue The new theme to set. 50 | */ 51 | set current(newValue: string) { 52 | this.#persisted.current = newValue; 53 | } 54 | } 55 | 56 | /** 57 | * A custom theme to apply and persist to the root `html` element. 58 | */ 59 | export const customTheme = new CustomTheme(); 60 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { modes } from "./modes.js"; 2 | 3 | export type Mode = (typeof modes)[number]; 4 | export type ThemeColors = { dark: string; light: string } | undefined; 5 | 6 | export type ModeWatcherProps = { 7 | /** 8 | * Whether to automatically track operating system preferences 9 | * and update the mode accordingly. 10 | * 11 | * @defaultValue `true` 12 | */ 13 | track?: boolean; 14 | 15 | /** 16 | * The default mode to use instead of the user's preference. 17 | * 18 | * @defaultValue `"system"` 19 | */ 20 | defaultMode?: Mode; 21 | 22 | /** 23 | * The default theme to use, which will be applied to the root `html` element 24 | * and can be managed with the `setTheme` function. 25 | * 26 | * @example 27 | * ```html 28 | * 29 | * ``` 30 | * 31 | * @defaultValue `undefined` 32 | */ 33 | defaultTheme?: string; 34 | 35 | /** 36 | * The theme colors to use for each mode. 37 | */ 38 | themeColors?: ThemeColors; 39 | 40 | /** 41 | * Whether to disable transitions when updating the mode. 42 | */ 43 | disableTransitions?: boolean; 44 | 45 | /** 46 | * The classname to add to the root `html` element when the mode is dark. 47 | * 48 | * @defaultValue `["dark"]` 49 | */ 50 | darkClassNames?: string[]; 51 | 52 | /** 53 | * The classname to add to the root `html` element when the mode is light. 54 | * 55 | * @defaultValue `[]` 56 | */ 57 | lightClassNames?: string[]; 58 | 59 | /** 60 | * Optionally provide a custom local storage key to use for storing the mode. 61 | * 62 | * @defaultValue `'mode-watcher-mode'` 63 | */ 64 | modeStorageKey?: string; 65 | 66 | /** 67 | * Optionally provide a custom local storage key to use for storing the theme. 68 | * 69 | * @defaultValue `'mode-watcher-theme'` 70 | */ 71 | themeStorageKey?: string; 72 | 73 | /** 74 | * An optional nonce to use for the injected script tag to allow-list mode-watcher 75 | * if you are using a Content Security Policy. 76 | * 77 | * @defaultValue `undefined` 78 | */ 79 | nonce?: string; 80 | 81 | /** 82 | * Whether to disable the injected script tag that sets the initial mode. 83 | * Set this if you are manually injecting the script using a hook. 84 | * 85 | * @defaultValue `false` 86 | */ 87 | disableHeadScriptInjection?: boolean; 88 | }; 89 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sanitizes an array of classnames by removing any empty strings. 3 | */ 4 | export function sanitizeClassNames(classNames: string[]): string[] { 5 | return classNames.filter((className) => className.length > 0); 6 | } 7 | 8 | export const noopStorage = { 9 | getItem: (_key: string) => null, 10 | setItem: (_key: string, _value: string) => {}, 11 | }; 12 | 13 | export const isBrowser = typeof document !== "undefined"; 14 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/lib/without-transition.ts: -------------------------------------------------------------------------------- 1 | // Original Source: https://reemus.dev/article/disable-css-transition-color-scheme-change#heading-ultimate-solution-for-changing-color-scheme-without-transitions 2 | 3 | let timeoutAction: number; 4 | let timeoutEnable: number; 5 | /** 6 | * Whether this is the first time the function has been 7 | * called, which will be true for the initial load, where 8 | * we shouldn't need to disable any transitions, as there 9 | * is nothing to transition from. 10 | */ 11 | let hasLoaded = false; 12 | 13 | // Perform a task without any css transitions 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | export function withoutTransition(action: () => any) { 16 | if (typeof document === "undefined") return; 17 | if (!hasLoaded) { 18 | hasLoaded = true; 19 | action(); 20 | return; 21 | } 22 | // Clear fallback timeouts 23 | clearTimeout(timeoutAction); 24 | clearTimeout(timeoutEnable); 25 | 26 | // Create style element to disable transitions 27 | const style = document.createElement("style"); 28 | const css = document.createTextNode(`* { 29 | -webkit-transition: none !important; 30 | -moz-transition: none !important; 31 | -o-transition: none !important; 32 | -ms-transition: none !important; 33 | transition: none !important; 34 | }`); 35 | style.appendChild(css); 36 | 37 | // Functions to insert and remove style element 38 | const disable = () => document.head.appendChild(style); 39 | const enable = () => document.head.removeChild(style); 40 | 41 | // Best method, getComputedStyle forces browser to repaint 42 | if (typeof window.getComputedStyle !== "undefined") { 43 | disable(); 44 | action(); 45 | window.getComputedStyle(style).opacity; 46 | enable(); 47 | return; 48 | } 49 | 50 | // Better method, requestAnimationFrame processes function before next repaint 51 | if (typeof window.requestAnimationFrame !== "undefined") { 52 | disable(); 53 | action(); 54 | window.requestAnimationFrame(enable); 55 | return; 56 | } 57 | 58 | // Fallback 59 | disable(); 60 | timeoutAction = window.setTimeout(() => { 61 | action(); 62 | timeoutEnable = window.setTimeout(enable, 120); 63 | }, 120); 64 | } 65 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | {@render children()} 10 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
34 |

User prefers mode: {userPrefersMode.current}

35 |

System prefers mode: {systemPrefersMode.current}

36 |

Current mode: {mode.current}

37 |

Custom theme: {theme.current ? theme.current : "N/A"}

38 | 39 | {#if htmlElement !== undefined} 40 |
{htmlElement}
41 | {/if} 42 | {#if themeColorElement !== undefined} 43 |
{themeColorElement}
44 | {/if} 45 | 46 | 52 | 58 | 64 | 70 | 76 |
77 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/tests/Mode.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | 20 | 21 | {modeStorageKey.current} 22 | {themeStorageKey.current} 23 | {mode.current} 24 | {theme.current} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/tests/StealthMode.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/mode-watcher/src/tests/mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/svelte/svelte5"; 2 | import { afterEach, describe, expect, it } from "vitest"; 3 | import { userEvent } from "@testing-library/user-event"; 4 | import { tick } from "svelte"; 5 | import { mediaQueryState } from "../../scripts/setupTest.js"; 6 | import Mode from "./Mode.svelte"; 7 | import StealthMode from "./StealthMode.svelte"; 8 | import type { ModeWatcherProps } from "$lib/types.js"; 9 | 10 | function setup(props: Partial = {}) { 11 | const user = userEvent.setup(); 12 | const returned = render(Mode, { props }); 13 | const theme = returned.getByTestId("theme"); 14 | const mode = returned.getByTestId("mode"); 15 | const themeStorageKey = returned.getByTestId("theme-storage-key"); 16 | const modeStorageKey = returned.getByTestId("mode-storage-key"); 17 | const rootEl = returned.container.parentElement?.parentElement as HTMLElement; 18 | const themeDracula = returned.getByTestId("theme-dracula"); 19 | const themeRetro = returned.getByTestId("theme-retro"); 20 | const themeClear = returned.getByTestId("theme-clear"); 21 | return { 22 | user, 23 | theme, 24 | mode, 25 | themeStorageKey, 26 | modeStorageKey, 27 | rootEl, 28 | themeDracula, 29 | themeRetro, 30 | themeClear, 31 | ...returned, 32 | }; 33 | } 34 | 35 | describe("mode-watcher", () => { 36 | afterEach(() => { 37 | localStorage.clear(); 38 | }); 39 | 40 | it("renders mode", async () => { 41 | const { rootEl } = setup(); 42 | const classes = getClasses(rootEl); 43 | expect(classes).toContain("dark"); 44 | }); 45 | 46 | it("toggles the mode", async () => { 47 | const { getByTestId, user, rootEl } = setup(); 48 | 49 | const classes = getClasses(rootEl); 50 | const colorScheme = getColorScheme(rootEl); 51 | const themeColor = getThemeColor(rootEl); 52 | expect(classes).toContain("dark"); 53 | expect(colorScheme).toBe("dark"); 54 | expect(themeColor).toBe("black"); 55 | const toggle = getByTestId("toggle"); 56 | await user.click(toggle); 57 | const classes2 = getClasses(rootEl); 58 | const colorScheme2 = getColorScheme(rootEl); 59 | const themeColor2 = getThemeColor(rootEl); 60 | expect(classes2).not.toContain("dark"); 61 | expect(colorScheme2).toBe("light"); 62 | expect(themeColor2).toBe("white"); 63 | await user.click(toggle); 64 | const classes3 = getClasses(rootEl); 65 | const colorScheme3 = getColorScheme(rootEl); 66 | const themeColor3 = getThemeColor(rootEl); 67 | expect(classes3).toContain("dark"); 68 | expect(colorScheme3).toBe("dark"); 69 | expect(themeColor3).toBe("black"); 70 | }); 71 | 72 | it("allows the user to set the mode", async () => { 73 | const { getByTestId, user, rootEl } = setup(); 74 | const classes = getClasses(rootEl); 75 | const colorScheme = getColorScheme(rootEl); 76 | const themeColor = getThemeColor(rootEl); 77 | expect(classes).toContain("dark"); 78 | expect(colorScheme).toBe("dark"); 79 | expect(themeColor).toBe("black"); 80 | const light = getByTestId("light"); 81 | await user.click(light); 82 | const classes2 = getClasses(rootEl); 83 | const colorScheme2 = getColorScheme(rootEl); 84 | const themeColor2 = getThemeColor(rootEl); 85 | expect(classes2).not.toContain("dark"); 86 | expect(colorScheme2).toBe("light"); 87 | expect(themeColor2).toBe("white"); 88 | 89 | const dark = getByTestId("dark"); 90 | await user.click(dark); 91 | const classes3 = getClasses(rootEl); 92 | const colorScheme3 = getColorScheme(rootEl); 93 | const themeColor3 = getThemeColor(rootEl); 94 | expect(classes3).toContain("dark"); 95 | expect(colorScheme3).toBe("dark"); 96 | expect(themeColor3).toBe("black"); 97 | }); 98 | 99 | it("keeps the mode store in sync with current mode", async () => { 100 | const { getByTestId, user, rootEl } = setup(); 101 | const light = getByTestId("light"); 102 | const dark = getByTestId("dark"); 103 | const mode = getByTestId("mode"); 104 | const classes = getClasses(rootEl); 105 | const colorScheme = getColorScheme(rootEl); 106 | const themeColor = getThemeColor(rootEl); 107 | expect(classes).toContain("dark"); 108 | expect(colorScheme).toBe("dark"); 109 | expect(themeColor).toBe("black"); 110 | expect(mode.textContent).toBe("dark"); 111 | 112 | await user.click(light); 113 | const classes2 = getClasses(rootEl); 114 | const colorScheme2 = getColorScheme(rootEl); 115 | const themeColor2 = getThemeColor(rootEl); 116 | expect(classes2).not.toContain("dark"); 117 | expect(colorScheme2).toBe("light"); 118 | expect(themeColor2).toBe("white"); 119 | expect(mode.textContent).toBe("light"); 120 | 121 | await user.click(dark); 122 | const classes3 = getClasses(rootEl); 123 | const colorScheme3 = getColorScheme(rootEl); 124 | const themeColor3 = getThemeColor(rootEl); 125 | expect(classes3).toContain("dark"); 126 | expect(colorScheme3).toBe("dark"); 127 | expect(themeColor3).toBe("black"); 128 | expect(mode.textContent).toBe("dark"); 129 | }); 130 | 131 | it("resets the mode to system preferences", async () => { 132 | const { getByTestId, user, rootEl } = setup(); 133 | const light = getByTestId("light"); 134 | const reset = getByTestId("reset"); 135 | const mode = getByTestId("mode"); 136 | const classes = getClasses(rootEl); 137 | const colorScheme = getColorScheme(rootEl); 138 | const themeColor = getThemeColor(rootEl); 139 | expect(classes).toContain("dark"); 140 | expect(colorScheme).toBe("dark"); 141 | expect(themeColor).toBe("black"); 142 | expect(mode.textContent).toBe("dark"); 143 | 144 | await user.click(light); 145 | const classes2 = getClasses(rootEl); 146 | const colorScheme2 = getColorScheme(rootEl); 147 | const themeColor2 = getThemeColor(rootEl); 148 | expect(classes2).not.toContain("dark"); 149 | expect(colorScheme2).toBe("light"); 150 | expect(themeColor2).toBe("white"); 151 | expect(mode.textContent).toBe("light"); 152 | 153 | await user.click(reset); 154 | const classes3 = getClasses(rootEl); 155 | const colorScheme3 = getColorScheme(rootEl); 156 | const themeColor3 = getThemeColor(rootEl); 157 | expect(classes3).toContain("dark"); 158 | expect(colorScheme3).toBe("dark"); 159 | expect(themeColor3).toBe("black"); 160 | expect(mode.textContent).toBe("dark"); 161 | }); 162 | 163 | // need to mock Svelte's media query somehow 164 | it.skip("tracks changes to system preferences", async () => { 165 | const { getByTestId, rootEl } = setup(); 166 | const mode = getByTestId("mode"); 167 | const classes = getClasses(rootEl); 168 | const colorScheme = getColorScheme(rootEl); 169 | const themeColor = getThemeColor(rootEl); 170 | expect(classes).toContain("dark"); 171 | expect(colorScheme).toBe("dark"); 172 | expect(themeColor).toBe("black"); 173 | expect(mode.textContent).toBe("dark"); 174 | 175 | mediaQueryState.matches = true; 176 | const changeEvent = new Event("change"); 177 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 178 | await tick(); 179 | const classes2 = getClasses(rootEl); 180 | const colorScheme2 = getColorScheme(rootEl); 181 | const themeColor2 = getThemeColor(rootEl); 182 | expect(classes2).not.toContain("dark"); 183 | expect(colorScheme2).toBe("light"); 184 | expect(themeColor2).toBe("white"); 185 | expect(mode.textContent).toBe("light"); 186 | 187 | mediaQueryState.matches = false; 188 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 189 | await tick(); 190 | const classes3 = getClasses(rootEl); 191 | const colorScheme3 = getColorScheme(rootEl); 192 | const themeColor3 = getThemeColor(rootEl); 193 | expect(classes3).toContain("dark"); 194 | expect(colorScheme3).toBe("dark"); 195 | expect(themeColor3).toBe("black"); 196 | expect(mode.textContent).toBe("dark"); 197 | }); 198 | 199 | // need to mock Svelte's media query somehow 200 | it.skip("stops tracking changes to system preferences when user sets a mode", async () => { 201 | const { getByTestId, user, rootEl } = setup(); 202 | const light = getByTestId("light"); 203 | const reset = getByTestId("reset"); 204 | const mode = getByTestId("mode"); 205 | const classes = getClasses(rootEl); 206 | const colorScheme = getColorScheme(rootEl); 207 | const themeColor = getThemeColor(rootEl); 208 | expect(classes).toContain("dark"); 209 | expect(colorScheme).toBe("dark"); 210 | expect(themeColor).toBe("black"); 211 | expect(mode.textContent).toBe("dark"); 212 | 213 | mediaQueryState.matches = true; 214 | const changeEvent = new Event("change"); 215 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 216 | await tick(); 217 | const classes2 = getClasses(rootEl); 218 | const colorScheme2 = getColorScheme(rootEl); 219 | const themeColor2 = getThemeColor(rootEl); 220 | expect(classes2).not.toContain("dark"); 221 | expect(colorScheme2).toBe("light"); 222 | expect(themeColor2).toBe("white"); 223 | expect(mode.textContent).toBe("light"); 224 | 225 | mediaQueryState.matches = false; 226 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 227 | await tick(); 228 | const classes3 = getClasses(rootEl); 229 | const colorScheme3 = getColorScheme(rootEl); 230 | const themeColor3 = getThemeColor(rootEl); 231 | expect(classes3).toContain("dark"); 232 | expect(colorScheme3).toBe("dark"); 233 | expect(themeColor3).toBe("black"); 234 | expect(mode.textContent).toBe("dark"); 235 | 236 | await user.click(light); 237 | const classes4 = getClasses(rootEl); 238 | const colorScheme4 = getColorScheme(rootEl); 239 | const themeColor4 = getThemeColor(rootEl); 240 | expect(classes4).not.toContain("dark"); 241 | expect(colorScheme4).toBe("light"); 242 | expect(themeColor4).toBe("white"); 243 | expect(mode.textContent).toBe("light"); 244 | 245 | mediaQueryState.matches = true; 246 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 247 | await tick(); 248 | const classes5 = getClasses(rootEl); 249 | const colorScheme5 = getColorScheme(rootEl); 250 | const themeColor5 = getThemeColor(rootEl); 251 | expect(classes5).not.toContain("dark"); 252 | expect(colorScheme5).toBe("light"); 253 | expect(themeColor5).toBe("white"); 254 | expect(mode.textContent).toBe("light"); 255 | 256 | mediaQueryState.matches = false; 257 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 258 | await tick(); 259 | const classes6 = getClasses(rootEl); 260 | const colorScheme6 = getColorScheme(rootEl); 261 | const themeColor6 = getThemeColor(rootEl); 262 | expect(classes6).not.toContain("dark"); 263 | expect(colorScheme6).toBe("light"); 264 | expect(themeColor6).toBe("white"); 265 | expect(mode.textContent).toBe("light"); 266 | 267 | await user.click(reset); 268 | const classes7 = getClasses(rootEl); 269 | const colorScheme7 = getColorScheme(rootEl); 270 | const themeColor7 = getThemeColor(rootEl); 271 | expect(classes7).toContain("dark"); 272 | expect(colorScheme7).toBe("dark"); 273 | expect(themeColor7).toBe("black"); 274 | expect(mode.textContent).toBe("dark"); 275 | }); 276 | 277 | // need to mock Svelte's media query 278 | it.skip("does not track changes to system preference when track prop is set to false", async () => { 279 | const { container, getByTestId } = render(Mode, { track: false }); 280 | const rootEl = container.parentElement?.parentElement as HTMLElement; 281 | const mode = getByTestId("mode"); 282 | const classes = getClasses(rootEl); 283 | const colorScheme = getColorScheme(rootEl); 284 | const themeColor = getThemeColor(rootEl); 285 | expect(classes).toContain("dark"); 286 | expect(colorScheme).toBe("dark"); 287 | expect(themeColor).toBe("black"); 288 | expect(mode.textContent).toBe("dark"); 289 | 290 | mediaQueryState.matches = true; 291 | const changeEvent = new Event("change"); 292 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 293 | await tick(); 294 | const classes2 = getClasses(rootEl); 295 | const colorScheme2 = getColorScheme(rootEl); 296 | const themeColor2 = getThemeColor(rootEl); 297 | expect(classes2).toContain("dark"); 298 | expect(colorScheme2).toBe("dark"); 299 | expect(themeColor2).toBe("black"); 300 | expect(mode.textContent).toBe("dark"); 301 | 302 | mediaQueryState.matches = false; 303 | window.matchMedia("(prefers-color-scheme: light)").dispatchEvent(changeEvent); 304 | await tick(); 305 | const classes3 = getClasses(rootEl); 306 | const colorScheme3 = getColorScheme(rootEl); 307 | const themeColor3 = getThemeColor(rootEl); 308 | expect(classes3).toContain("dark"); 309 | expect(colorScheme3).toBe("dark"); 310 | expect(themeColor3).toBe("black"); 311 | expect(mode.textContent).toBe("dark"); 312 | }); 313 | 314 | it("also works when $mode is not used in the current page", async () => { 315 | const user = userEvent.setup(); 316 | const { container, getByTestId } = render(StealthMode); 317 | const rootEl = container.parentElement?.parentElement as HTMLElement; 318 | 319 | const classes = getClasses(rootEl); 320 | const colorScheme = getColorScheme(rootEl); 321 | const themeColor = getThemeColor(rootEl); 322 | expect(classes).toContain("dark"); 323 | expect(colorScheme).toBe("dark"); 324 | expect(themeColor).toBe("black"); 325 | const toggle = getByTestId("toggle"); 326 | await user.click(toggle); 327 | const classes2 = getClasses(rootEl); 328 | const colorScheme2 = getColorScheme(rootEl); 329 | const themeColor2 = getThemeColor(rootEl); 330 | expect(classes2).not.toContain("dark"); 331 | expect(colorScheme2).toBe("light"); 332 | expect(themeColor2).toBe("white"); 333 | await user.click(toggle); 334 | const classes3 = getClasses(rootEl); 335 | const colorScheme3 = getColorScheme(rootEl); 336 | const themeColor3 = getThemeColor(rootEl); 337 | expect(classes3).toContain("dark"); 338 | expect(colorScheme3).toBe("dark"); 339 | expect(themeColor3).toBe("black"); 340 | }); 341 | 342 | it("allows the user to apply custom classnames to the root html element", async () => { 343 | const { getByTestId, user, rootEl } = setup({ 344 | darkClassNames: ["custom-d-class"], 345 | lightClassNames: ["custom-l-class"], 346 | }); 347 | 348 | const classes = getClasses(rootEl); 349 | expect(classes).toContain("custom-d-class"); 350 | const toggle = getByTestId("toggle"); 351 | await user.click(toggle); 352 | const classes2 = getClasses(rootEl); 353 | expect(classes2).toContain("custom-l-class"); 354 | }); 355 | 356 | it("allows the user to set a custom theme via the `defaultTheme` prop", async () => { 357 | const { theme, rootEl } = setup({ 358 | defaultTheme: "dracula", 359 | }); 360 | expect(rootEl).toHaveAttribute("data-theme", "dracula"); 361 | expect(theme).toHaveTextContent("dracula"); 362 | }); 363 | 364 | it("allows the user to programmatically change the theme", async () => { 365 | const { themeDracula, theme, rootEl, user } = setup({ 366 | defaultTheme: "money", 367 | }); 368 | expect(rootEl).toHaveAttribute("data-theme", "money"); 369 | await user.click(themeDracula); 370 | expect(rootEl).toHaveAttribute("data-theme", "dracula"); 371 | expect(theme).toHaveTextContent("dracula"); 372 | }); 373 | 374 | it("allows the user to programmatically clear the theme", async () => { 375 | const { themeClear, rootEl, user } = setup({ 376 | defaultTheme: "money", 377 | }); 378 | expect(rootEl).toHaveAttribute("data-theme", "money"); 379 | await user.click(themeClear); 380 | expect(rootEl).toHaveAttribute("data-theme", ""); 381 | expect(rootEl).not.toHaveAttribute("data-theme", "money"); 382 | }); 383 | }); 384 | 385 | function getClasses(element: HTMLElement | null): string[] { 386 | if (element === null) return []; 387 | const classes = element.className.split(" ").filter((c) => c.length > 0); 388 | return classes; 389 | } 390 | 391 | function getColorScheme(element: HTMLElement | null) { 392 | if (element === null) return ""; 393 | return element.style.colorScheme; 394 | } 395 | 396 | function getThemeColor(element: HTMLElement | null) { 397 | if (element === null) return ""; 398 | 399 | const themeMetaEl = element.querySelector('meta[name="theme-color"]'); 400 | if (themeMetaEl === null) return ""; 401 | 402 | const content = themeMetaEl.getAttribute("content"); 403 | if (content === null) return ""; 404 | 405 | return content; 406 | } 407 | -------------------------------------------------------------------------------- /packages/mode-watcher/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svecosystem/mode-watcher/441b87e978484af349a1f00f94a974aaf93e1e9b/packages/mode-watcher/static/favicon.png -------------------------------------------------------------------------------- /packages/mode-watcher/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 | 3 | /** @type {import('@sveltejs/kit').Config} */ 4 | const config = { 5 | preprocess: [vitePreprocess({})], 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /packages/mode-watcher/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config}*/ 2 | const config = { 3 | content: ["./src/**/*.{html,js,svelte,ts}"], 4 | theme: { 5 | container: { 6 | center: true, 7 | padding: "2rem", 8 | screens: { 9 | "2xl": "1400px", 10 | }, 11 | }, 12 | extend: { 13 | colors: { 14 | border: "hsl(var(--border))", 15 | input: "hsl(var(--input))", 16 | ring: "hsl(var(--ring))", 17 | background: "hsl(var(--background))", 18 | foreground: "hsl(var(--foreground))", 19 | primary: { 20 | DEFAULT: "hsl(var(--primary))", 21 | foreground: "hsl(var(--primary-foreground))", 22 | }, 23 | secondary: { 24 | DEFAULT: "hsl(var(--secondary))", 25 | foreground: "hsl(var(--secondary-foreground))", 26 | }, 27 | destructive: { 28 | DEFAULT: "hsl(var(--destructive) / )", 29 | foreground: "hsl(var(--destructive-foreground) / )", 30 | }, 31 | muted: { 32 | DEFAULT: "hsl(var(--muted))", 33 | foreground: "hsl(var(--muted-foreground))", 34 | }, 35 | accent: { 36 | DEFAULT: "hsl(var(--accent))", 37 | foreground: "hsl(var(--accent-foreground))", 38 | }, 39 | popover: { 40 | DEFAULT: "hsl(var(--popover))", 41 | foreground: "hsl(var(--popover-foreground))", 42 | }, 43 | card: { 44 | DEFAULT: "hsl(var(--card))", 45 | foreground: "hsl(var(--card-foreground))", 46 | }, 47 | }, 48 | borderRadius: { 49 | xl: `calc(var(--radius) + 4px)`, 50 | lg: `var(--radius)`, 51 | md: `calc(var(--radius) - 2px)`, 52 | sm: "calc(var(--radius) - 4px)", 53 | }, 54 | }, 55 | plugins: [], 56 | }, 57 | }; 58 | 59 | module.exports = config; 60 | -------------------------------------------------------------------------------- /packages/mode-watcher/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "NodeNext", 13 | "module": "NodeNext", 14 | "types": ["node"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/mode-watcher/vite.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import { sveltekit } from "@sveltejs/kit/vite"; 3 | import { defineConfig } from "vitest/config"; 4 | import { svelteTesting } from "@testing-library/svelte/vite"; 5 | import type { Plugin } from "vite"; 6 | 7 | const vitestBrowserConditionPlugin: Plugin = { 8 | name: "vite-plugin-vitest-browser-condition", 9 | //@ts-expect-error - this works 10 | config({ resolve }: { resolve: { conditions: string[] } }) { 11 | if (process.env.VITEST) { 12 | resolve.conditions.unshift("browser"); 13 | } 14 | }, 15 | }; 16 | 17 | export default defineConfig({ 18 | plugins: [vitestBrowserConditionPlugin, sveltekit(), svelteTesting()], 19 | test: { 20 | include: ["src/**/*.{test,spec}.{js,ts}"], 21 | // jest like globals 22 | globals: true, 23 | environment: "jsdom", 24 | // in-source testing 25 | includeSource: ["src/**/*.{js,ts,svelte}"], 26 | // Add @testing-library/jest-dom matchers & mocks of SvelteKit modules 27 | setupFiles: ["./scripts/setupTest.ts"], 28 | // Exclude files in v8 29 | coverage: { 30 | exclude: ["setupTest.ts"], 31 | }, 32 | mockReset: false, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - docs 4 | --------------------------------------------------------------------------------