spoko-design-system 1.39.2 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [1.40.0](https://github.com/polo-blue/sds/compare/v1.39.3...v1.40.0) (2026-06-25)
2
+
3
+ ### ⚠ BREAKING CHANGES
4
+
5
+ * **Breadcrumbs:** the Breadcrumbs prop `productNumber` is renamed to `suffix`,
6
+ and the hard-coded `Polo 6R` link-title prefix is removed (use `titlePrefix`).
7
+ Consumers must update usages and bump spoko-design-system to ^2.0.0.
8
+
9
+ ### Features
10
+
11
+ * **Breadcrumbs:** universal props (suffix/titlePrefix/hideOnMobile) ([#471](https://github.com/polo-blue/sds/issues/471)) ([8a23dac](https://github.com/polo-blue/sds/commit/8a23dacc55cd8e8248c7c3365990cb5c8df509a3))
12
+
13
+ ## [1.39.3](https://github.com/polo-blue/sds/compare/v1.39.2...v1.39.3) (2026-06-17)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **Header:** repair navbar dark-mode toggle (duplicate listeners) ([#468](https://github.com/polo-blue/sds/issues/468)) ([8e915c3](https://github.com/polo-blue/sds/commit/8e915c3452b670d3280fde19c0410412f2208e33)), closes [#dark-mode-toggle](https://github.com/polo-blue/sds/issues/dark-mode-toggle)
18
+
1
19
  ## [1.39.2](https://github.com/polo-blue/sds/compare/v1.39.1...v1.39.2) (2026-06-17)
2
20
 
3
21
  ## [1.39.1](https://github.com/polo-blue/sds/compare/v1.39.0...v1.39.1) (2026-06-17)
@@ -0,0 +1,106 @@
1
+ # Migracja komponentu `Breadcrumbs` — uniwersalne propsy
2
+
3
+ > **BREAKING CHANGE** → bump major (np. `1.x` → `2.0.0`).
4
+ > Dotyczy konsumentów paczki `spoko-design-system` używających `Breadcrumbs`
5
+ > (m.in. `sale.polo.blue`, `catalog.polo.blue`).
6
+
7
+ ## Co się zmieniło
8
+
9
+ Komponent przestał być związany z domeną produktu/marką i używa teraz
10
+ uniwersalnych nazw propsów.
11
+
12
+ | Przed (1.x) | Po (2.0) | Uwagi |
13
+ |-------------|----------|-------|
14
+ | `productNumber` | **`suffix`** | tekst doklejany po ostatnim okruszku (np. numer produktu) |
15
+ | _(zahardkodowany `Polo 6R` w `title`)_ | **`titlePrefix`** | opcjonalny prefiks tytułów linków; domyślnie `''` (brak) |
16
+ | _(zawsze `hidden sm:inline`)_ | **`hideOnMobile`** | `Array<'suffix' \| 'home' \| 'separators' \| 'trail'>`, domyślnie `['suffix']` |
17
+
18
+ ### Pełna lista propsów po zmianie
19
+
20
+ | Prop | Typ | Domyślnie |
21
+ |------|-----|-----------|
22
+ | `breadcrumbs` | `Array<{ name: string; path: string }>` | — (wymagane) |
23
+ | `showBack` | `boolean` | `false` |
24
+ | `textBack` | `string` | `'Back'` |
25
+ | `showHome` | `boolean` | `false` |
26
+ | `suffix` | `string` | `undefined` |
27
+ | `titlePrefix` | `string` | `''` |
28
+ | `hideOnMobile` | `Array<'suffix' \| 'home' \| 'separators' \| 'trail'>` | `['suffix']` |
29
+ | `withMicrodata` | `boolean` | `true` |
30
+
31
+ ## Jak zaktualizować konsumenta
32
+
33
+ ### 1. Zmień nazwę propsa `productNumber` → `suffix`
34
+
35
+ W Vue atrybut był kebab-case (`product-number`), w Astro/JSX camelCase
36
+ (`productNumber`). Znajdź oba warianty:
37
+
38
+ ```bash
39
+ # w repo konsumenta
40
+ grep -rEn "product-number|productNumber" src
41
+ ```
42
+
43
+ **Przed:**
44
+ ```vue
45
+ <Breadcrumbs
46
+ :breadcrumbs="trail"
47
+ product-number="6R0XXXXXX"
48
+ />
49
+ ```
50
+ ```astro
51
+ <Breadcrumbs breadcrumbs={trail} productNumber="6R0XXXXXX" client:visible />
52
+ ```
53
+
54
+ **Po:**
55
+ ```vue
56
+ <Breadcrumbs
57
+ :breadcrumbs="trail"
58
+ suffix="6R0XXXXXX"
59
+ />
60
+ ```
61
+ ```astro
62
+ <Breadcrumbs breadcrumbs={trail} suffix="6R0XXXXXX" client:visible />
63
+ ```
64
+
65
+ ### 2. (Opcjonalnie) Przywróć prefiks marki w tytułach linków
66
+
67
+ Wcześniej tytuły linków (`title="..."`) miały na sztywno prefiks `Polo 6R`.
68
+ Teraz domyślnie go nie ma. Jeśli zależy Ci na tym samym tekście tooltipów,
69
+ podaj `titlePrefix`:
70
+
71
+ ```vue
72
+ <Breadcrumbs :breadcrumbs="trail" suffix="6R0XXXXXX" title-prefix="Polo 6R" />
73
+ ```
74
+
75
+ ### 3. Zachowanie na mobile bez zmian (domyślnie)
76
+
77
+ Domyślnie `hideOnMobile` to `['suffix']` — czyli suffix jest ukrywany poniżej
78
+ breakpointu `sm` (640px) i wraca od `sm` w górę, **dokładnie jak wcześniej**.
79
+ Nic nie trzeba robić, żeby zachować dotychczasowe zachowanie.
80
+
81
+ Jeśli chcesz to zmienić:
82
+
83
+ ```vue
84
+ <!-- suffix widoczny też na mobile -->
85
+ <Breadcrumbs :breadcrumbs="trail" suffix="6R0XXXXXX" :hide-on-mobile="[]" />
86
+
87
+ <!-- ukryj na mobile także separatory i ikonę domu -->
88
+ <Breadcrumbs
89
+ :breadcrumbs="trail"
90
+ suffix="6R0XXXXXX"
91
+ show-home
92
+ :hide-on-mobile="['suffix', 'separators', 'home']"
93
+ />
94
+
95
+ <!-- ukryj cały trail na mobile (zostaje np. tylko przycisk Back) -->
96
+ <Breadcrumbs :breadcrumbs="trail" show-back :hide-on-mobile="['trail']" />
97
+ ```
98
+
99
+ Dozwolone wartości: `'suffix'`, `'home'`, `'separators'`, `'trail'`.
100
+
101
+ ## Checklista PR w repo konsumenta
102
+
103
+ - [ ] `grep -rEn "product-number|productNumber"` — wszystkie wystąpienia w użyciach `Breadcrumbs` zamienione na `suffix`.
104
+ - [ ] (jeśli potrzebne) dodany `title-prefix` tam, gdzie tooltipy miały prefiks marki.
105
+ - [ ] zweryfikowane zachowanie na mobile (suffix ukryty domyślnie — bez zmian).
106
+ - [ ] bump wersji `spoko-design-system` do `^2.0.0` w `package.json`.
package/eslint.config.js CHANGED
@@ -4,6 +4,7 @@ import typescriptParser from '@typescript-eslint/parser';
4
4
  import vue from 'eslint-plugin-vue';
5
5
  import astro from 'eslint-plugin-astro';
6
6
  import globals from 'globals';
7
+ import eslintConfigPrettier from 'eslint-config-prettier';
7
8
 
8
9
  export default [
9
10
  js.configs.recommended,
@@ -57,6 +58,13 @@ export default [
57
58
  // Astro files
58
59
  ...astro.configs.recommended,
59
60
 
61
+ // Disable all ESLint formatting rules that conflict with Prettier.
62
+ // Prettier is the single source of truth for formatting (run via the
63
+ // pre-commit hook), so rules like vue/max-attributes-per-line and
64
+ // vue/html-self-closing must be turned off to avoid fighting it.
65
+ // MUST stay last so it overrides the recommended configs above.
66
+ eslintConfigPrettier,
67
+
60
68
  // Global ignores
61
69
  {
62
70
  ignores: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoko-design-system",
3
- "version": "1.39.2",
3
+ "version": "1.40.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./index.ts",
@@ -39,6 +39,7 @@
39
39
  "lint": "eslint \"src/**/*.{js,ts,jsx,tsx,vue,astro}\"",
40
40
  "lint:fix": "eslint \"src/**/*.{js,ts,jsx,tsx,vue,astro}\" --fix",
41
41
  "check": "astro check",
42
+ "test:e2e": "playwright test",
42
43
  "prepare": "husky"
43
44
  },
44
45
  "repository": {
@@ -127,6 +128,7 @@
127
128
  "@commitlint/cli": "^21.0.2",
128
129
  "@commitlint/config-conventional": "^21.0.2",
129
130
  "@eslint/js": "^10.0.1",
131
+ "@playwright/test": "^1.50.0",
130
132
  "@semantic-release/changelog": "^6.0.3",
131
133
  "@semantic-release/git": "^10.0.1",
132
134
  "@types/culori": "^4.0.1",
@@ -141,6 +143,7 @@
141
143
  "astro": "^6.4.6",
142
144
  "conventional-changelog-conventionalcommits": "^9.3.1",
143
145
  "eslint": "^10.5.0",
146
+ "eslint-config-prettier": "^10.1.8",
144
147
  "eslint-plugin-astro": "^1.7.0",
145
148
  "eslint-plugin-vue": "^10.9.2",
146
149
  "globals": "^17.6.0",
@@ -0,0 +1,28 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+ const PORT = 1234;
4
+ const BASE_URL = `http://localhost:${PORT}`;
5
+
6
+ export default defineConfig({
7
+ testDir: './tests',
8
+ fullyParallel: true,
9
+ forbidOnly: !!process.env.CI,
10
+ retries: process.env.CI ? 2 : 0,
11
+ reporter: 'list',
12
+ use: {
13
+ baseURL: BASE_URL,
14
+ trace: 'on-first-retry',
15
+ },
16
+ projects: [
17
+ {
18
+ name: 'chromium',
19
+ use: { ...devices['Desktop Chrome'] },
20
+ },
21
+ ],
22
+ webServer: {
23
+ command: 'pnpm dev',
24
+ url: BASE_URL,
25
+ reuseExistingServer: !process.env.CI,
26
+ timeout: 120_000,
27
+ },
28
+ });
@@ -6,12 +6,17 @@ export interface Breadcrumb {
6
6
  path: string;
7
7
  }
8
8
 
9
+ // Parts of the breadcrumb that can be hidden on mobile via `hideOnMobile`.
10
+ export type BreadcrumbPart = 'suffix' | 'home' | 'separators' | 'trail';
11
+
9
12
  interface Props {
10
13
  showBack?: boolean;
11
14
  textBack?: string;
12
15
  showHome?: boolean;
13
16
  breadcrumbs: Breadcrumb[];
14
- productNumber?: string;
17
+ suffix?: string;
18
+ titlePrefix?: string;
19
+ hideOnMobile?: BreadcrumbPart[];
15
20
  withMicrodata?: boolean;
16
21
  }
17
22
 
@@ -19,7 +24,10 @@ const props = withDefaults(defineProps<Props>(), {
19
24
  showBack: false,
20
25
  textBack: 'Back',
21
26
  showHome: false,
22
- productNumber: undefined,
27
+ suffix: undefined,
28
+ titlePrefix: '',
29
+ // Default keeps the original behaviour: the suffix is hidden below `sm`.
30
+ hideOnMobile: () => ['suffix'],
23
31
  withMicrodata: true,
24
32
  });
25
33
 
@@ -27,6 +35,15 @@ const isLast = (index: number) => {
27
35
  return index === props.breadcrumbs.length - 1;
28
36
  };
29
37
 
38
+ // Build the link title from the optional prefix, the crumb name and (on the
39
+ // last crumb) the suffix — empty parts are dropped so there are no stray spaces.
40
+ const getTitle = (name: string, last = false) =>
41
+ [props.titlePrefix, name, last ? props.suffix : undefined].filter(Boolean).join(' ');
42
+
43
+ // Hides the given part below the `sm` breakpoint; the element's base display
44
+ // (flex/inline) is restored from `sm` up. Empty string = always visible.
45
+ const mobileHidden = (part: BreadcrumbPart) => (props.hideOnMobile.includes(part) ? 'max-sm:hidden' : '');
46
+
30
47
  // Microdata attributes - only added when withMicrodata is true
31
48
  const listMicrodata = computed(() =>
32
49
  props.withMicrodata
@@ -58,7 +75,7 @@ const listItemMicrodata = computed(() =>
58
75
  </li>
59
76
  </ul>
60
77
  <ul class="breadcrumbs-base overflow-x-auto overflow-y-hidden sm:mr-12" v-bind="listMicrodata">
61
- <li v-if="props.showHome" class="breadcrumb-item">
78
+ <li v-if="props.showHome" class="breadcrumb-item" :class="mobileHidden('home')">
62
79
  <a
63
80
  href="/"
64
81
  class="breadcrumb-link flex items-center px-3 sm:px-0 py-4.25 sm:py-1 hover:text-brand-secondary whitespace-nowrap translate-y-0 text-sm my-auto"
@@ -72,16 +89,23 @@ const listItemMicrodata = computed(() =>
72
89
  v-for="(crumb, index) in breadcrumbs"
73
90
  :key="index"
74
91
  class="breadcrumb-item"
92
+ :class="mobileHidden('trail')"
75
93
  v-bind="listItemMicrodata"
76
94
  >
77
- <span v-if="index > 0 || props.showHome" class="text-gray-400 px-1 py-4.25 sm:py-1">/</span>
95
+ <span
96
+ v-if="index > 0 || props.showHome"
97
+ class="text-gray-400 px-1 py-4.25 sm:py-1"
98
+ :class="mobileHidden('separators')"
99
+ >
100
+ /
101
+ </span>
78
102
 
79
103
  <a
80
104
  v-if="!isLast(index)"
81
105
  :href="crumb.path"
82
106
  class="breadcrumb-link"
83
107
  v-bind="withMicrodata ? { itemprop: 'item' } : {}"
84
- :title="`Polo 6R ${crumb.name}`"
108
+ :title="getTitle(crumb.name)"
85
109
  >
86
110
  <strong class="font-normal" v-bind="withMicrodata ? { itemprop: 'name' } : {}">
87
111
  {{ crumb.name }}
@@ -91,12 +115,12 @@ const listItemMicrodata = computed(() =>
91
115
  v-else
92
116
  :href="crumb.path"
93
117
  class="breadcrumb-link breadcrumb-link-disabled"
94
- :title="`Polo 6R ${crumb.name} ${productNumber}`"
118
+ :title="getTitle(crumb.name, true)"
95
119
  v-bind="withMicrodata ? { itemprop: 'item' } : {}"
96
120
  >
97
121
  <span class="font-normal" v-bind="withMicrodata ? { itemprop: 'name' } : {}">
98
122
  <span v-html="crumb.name" />
99
- <b v-if="productNumber" class="hidden sm:inline font-normal ml-1">&nbsp;{{ productNumber }}</b>
123
+ <b v-if="suffix" class="font-normal ml-1" :class="mobileHidden('suffix')">&nbsp;{{ suffix }}</b>
100
124
  </span>
101
125
  </a>
102
126
 
@@ -135,11 +135,16 @@ import SearchModal from './SearchModal.vue';
135
135
  <script>
136
136
  function setupDarkModeToggle() {
137
137
  const toggle = document.getElementById('dark-mode-toggle');
138
- if (toggle) {
138
+ if (toggle && toggle.dataset.bound !== '1') {
139
+ toggle.dataset.bound = '1';
139
140
  toggle.addEventListener('click', () => {
140
141
  const isDark = document.documentElement.classList.toggle('dark');
141
142
  document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
142
- localStorage.setItem('sds-color-scheme', isDark ? 'dark' : 'light');
143
+ try {
144
+ localStorage.setItem('sds-color-scheme', isDark ? 'dark' : 'light');
145
+ } catch {
146
+ /* localStorage may be unavailable */
147
+ }
143
148
  });
144
149
  }
145
150
  }
@@ -36,7 +36,7 @@ const trail = [
36
36
  ---
37
37
  <Breadcrumbs
38
38
  breadcrumbs={trail}
39
- productNumber="6R0XXXXXX"
39
+ suffix="6R0XXXXXX"
40
40
  class="py-1 px-1 max-w-full flex bg-white"
41
41
  showBack
42
42
  textBack="Back"
@@ -55,7 +55,7 @@ const trail = [
55
55
  <template>
56
56
  <Breadcrumbs
57
57
  :breadcrumbs="trail"
58
- product-number="6R0XXXXXX"
58
+ suffix="6R0XXXXXX"
59
59
  class="py-1 px-1 max-w-full flex bg-white"
60
60
  show-back
61
61
  text-back="Back"
@@ -63,7 +63,7 @@ const trail = [
63
63
  </template>`}>
64
64
  <Breadcrumbs
65
65
  breadcrumbs={[{name: 'Level1', path: '#level1'}, {name: 'Level 2', path: '#level2'}, {name: 'Level 3', path: '#level3'}]}
66
- product-number="6R0XXXXXX"
66
+ suffix="6R0XXXXXX"
67
67
  class="py-1 order-0 px-1 max-w-full flex bg-white"
68
68
  show-back
69
69
  text-back="Back"
@@ -76,20 +76,20 @@ const trail = [
76
76
  <ComponentPreview client:visible
77
77
  astro={`<Breadcrumbs
78
78
  breadcrumbs={trail}
79
- productNumber="6R0XXXXXX"
79
+ suffix="6R0XXXXXX"
80
80
  class="py-1 px-1 max-w-full flex bg-white"
81
81
  showHome
82
82
  client:visible
83
83
  />`}
84
84
  vue={`<Breadcrumbs
85
85
  :breadcrumbs="trail"
86
- product-number="6R0XXXXXX"
86
+ suffix="6R0XXXXXX"
87
87
  class="py-1 px-1 max-w-full flex bg-white"
88
88
  show-home
89
89
  />`}>
90
90
  <Breadcrumbs
91
91
  breadcrumbs={[{name: 'Level1', path: '#level1'}, {name: 'Level 2', path: '#level2'}, {name: 'Level 3', path: '#level3'}]}
92
- product-number="6R0XXXXXX"
92
+ suffix="6R0XXXXXX"
93
93
  class="py-1 order-0 px-1 max-w-full flex bg-white"
94
94
  show-home
95
95
  />
@@ -101,18 +101,48 @@ const trail = [
101
101
  <ComponentPreview client:visible
102
102
  astro={`<Breadcrumbs
103
103
  breadcrumbs={trail}
104
- productNumber="6R0XXXXXX"
104
+ suffix="6R0XXXXXX"
105
105
  class="py-1 px-1 max-w-full flex bg-white"
106
106
  client:visible
107
107
  />`}
108
108
  vue={`<Breadcrumbs
109
109
  :breadcrumbs="trail"
110
- product-number="6R0XXXXXX"
110
+ suffix="6R0XXXXXX"
111
111
  class="py-1 px-1 max-w-full flex bg-white"
112
112
  />`}>
113
113
  <Breadcrumbs
114
114
  breadcrumbs={[{name: 'Level1', path: '#level1'}, {name: 'Level 2', path: '#level2'}, {name: 'Level 3', path: '#level3'}]}
115
- product-number="6R0XXXXXX"
115
+ suffix="6R0XXXXXX"
116
+ class="py-1 order-0 px-1 max-w-full flex bg-white"
117
+ />
118
+ </ComponentPreview>
119
+
120
+ ## Hide parts on mobile
121
+
122
+ Use `hideOnMobile` to hide selected parts below the `sm` breakpoint (640px); they
123
+ reappear from `sm` up. Accepted parts: `'suffix'`, `'home'`, `'separators'`, `'trail'`.
124
+ It defaults to `['suffix']`, so pass `[]` to show everything on mobile, or list more
125
+ parts to hide. Resize the preview below to see the suffix and separators come and go.
126
+
127
+ <ComponentPreview client:visible
128
+ astro={`<Breadcrumbs
129
+ breadcrumbs={trail}
130
+ suffix="6R0XXXXXX"
131
+ hideOnMobile={['suffix', 'separators']}
132
+ class="py-1 px-1 max-w-full flex bg-white"
133
+ client:visible
134
+ />`}
135
+ vue={`<Breadcrumbs
136
+ :breadcrumbs="trail"
137
+ suffix="6R0XXXXXX"
138
+ :hide-on-mobile="['suffix', 'separators']"
139
+ class="py-1 px-1 max-w-full flex bg-white"
140
+ />`}>
141
+ <Breadcrumbs
142
+ breadcrumbs={[{name: 'Level1', path: '#level1'}, {name: 'Level 2', path: '#level2'}, {name: 'Level 3', path: '#level3'}]}
143
+ suffix="6R0XXXXXX"
144
+ hideOnMobile={['suffix', 'separators']}
145
+ showHome
116
146
  class="py-1 order-0 px-1 max-w-full flex bg-white"
117
147
  />
118
148
  </ComponentPreview>
@@ -124,7 +154,9 @@ const trail = [
124
154
  { name: 'showBack', type: 'boolean', default: 'false', description: 'Show a back button before the breadcrumbs' },
125
155
  { name: 'textBack', type: 'string', default: "'Back'", description: 'Label text for the back button' },
126
156
  { name: 'showHome', type: 'boolean', default: 'false', description: 'Show home icon as first breadcrumb' },
127
- { name: 'productNumber', type: 'string', default: 'null', description: 'Product number shown after last breadcrumb (hidden on mobile)' },
157
+ { name: 'suffix', type: 'string', default: 'undefined', description: 'Text appended after the last breadcrumb e.g. a product number' },
158
+ { name: 'titlePrefix', type: 'string', default: "''", description: 'Optional prefix prepended to each link title attribute — e.g. a brand name' },
159
+ { name: 'hideOnMobile', type: "Array<'suffix' | 'home' | 'separators' | 'trail'>", default: "['suffix']", description: 'Parts hidden below the sm breakpoint (restored from sm up). Defaults to hiding the suffix; pass [] to show everything on mobile' },
128
160
  { name: 'withMicrodata', type: 'boolean', default: 'true', description: 'Include Schema.org BreadcrumbList markup — set false to avoid duplicates when rendering twice' },
129
161
  ]} />
130
162
 
@@ -0,0 +1,73 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ /**
4
+ * Regression test for the navbar dark-mode toggle.
5
+ *
6
+ * Bug: `setupDarkModeToggle()` in Header.astro ran three times (immediate call +
7
+ * `astro:page-load` + `astro:after-swap`) with no guard, attaching multiple click
8
+ * listeners to the same `#dark-mode-toggle` button. Each click toggled `.dark` an
9
+ * even number of times, so the theme never visibly changed — switching to light
10
+ * mode (or back) appeared broken.
11
+ *
12
+ * These tests assert that a single click flips the theme exactly once and that the
13
+ * choice persists to localStorage and survives a reload.
14
+ */
15
+
16
+ const TOGGLE = '#dark-mode-toggle';
17
+ // A docs page that uses MainLayout, where the navbar dark-mode toggle is rendered.
18
+ const PAGE = '/core/introduction/';
19
+ // Matches the `dark` class as a standalone token in the <html> class list.
20
+ const DARK_CLASS = /(^|\s)dark(\s|$)/;
21
+
22
+ test.describe('navbar dark-mode toggle', () => {
23
+ // Force a deterministic starting point: emulate the light system preference so the
24
+ // page boots in light mode regardless of the runner's OS settings.
25
+ test.use({ colorScheme: 'light' });
26
+
27
+ test.beforeEach(async ({ page }) => {
28
+ // Each test runs in an isolated browser context with empty localStorage, so the
29
+ // `colorScheme: 'light'` emulation above is enough to boot deterministically in
30
+ // light mode — no manual storage reset needed (resetting here would also wipe the
31
+ // value on reload and break the persistence test).
32
+ await page.goto(PAGE);
33
+ // Wait for the toggle to be wired before interacting.
34
+ await expect(page.locator(TOGGLE)).toBeVisible();
35
+ });
36
+
37
+ test('starts in light mode', async ({ page }) => {
38
+ await expect(page.locator('html')).not.toHaveClass(DARK_CLASS);
39
+ });
40
+
41
+ test('a single click switches to dark mode', async ({ page }) => {
42
+ await page.locator(TOGGLE).click();
43
+
44
+ await expect(page.locator('html')).toHaveClass(DARK_CLASS);
45
+ await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
46
+
47
+ const stored = await page.evaluate(() => window.localStorage.getItem('sds-color-scheme'));
48
+ expect(stored).toBe('dark');
49
+ });
50
+
51
+ test('clicking twice returns to light mode (the regression)', async ({ page }) => {
52
+ const html = page.locator('html');
53
+
54
+ await page.locator(TOGGLE).click();
55
+ await expect(html).toHaveClass(DARK_CLASS);
56
+
57
+ await page.locator(TOGGLE).click();
58
+ // With the double-bind bug this stayed dark; one listener now flips it back.
59
+ await expect(html).not.toHaveClass(DARK_CLASS);
60
+ await expect(html).toHaveCSS('color-scheme', 'light');
61
+
62
+ const stored = await page.evaluate(() => window.localStorage.getItem('sds-color-scheme'));
63
+ expect(stored).toBe('light');
64
+ });
65
+
66
+ test('the chosen theme persists across a reload', async ({ page }) => {
67
+ await page.locator(TOGGLE).click();
68
+ await expect(page.locator('html')).toHaveClass(DARK_CLASS);
69
+
70
+ await page.reload();
71
+ await expect(page.locator('html')).toHaveClass(DARK_CLASS);
72
+ });
73
+ });