@classic-homes/theme-svelte 1.1.2 → 1.2.1

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.
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import { render, screen } from '@testing-library/svelte';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { axe } from 'vitest-axe';
5
+ import { createRawSnippet } from 'svelte';
5
6
  import Tabs from '../../lib/components/Tabs.svelte';
6
7
  describe('Tabs accessibility', () => {
7
8
  const defaultTabs = [
@@ -85,13 +86,31 @@ describe('Tabs accessibility', () => {
85
86
  await user.click(tabs[1]);
86
87
  expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
87
88
  });
88
- it('should have aria-controls linking tab to panel', () => {
89
+ it('should omit aria-controls when no panel exists', () => {
90
+ // Without children there is no panel in the DOM; an aria-controls pointing
91
+ // at a missing element is an ARIA validity failure (axe: aria-valid-attr-value)
89
92
  render(Tabs, {
90
93
  props: { tabs: defaultTabs, label: 'Test tabs' },
91
94
  });
95
+ for (const tab of screen.getAllByRole('tab')) {
96
+ expect(tab).not.toHaveAttribute('aria-controls');
97
+ }
98
+ });
99
+ it('should have aria-controls referencing a real panel when children exist', () => {
100
+ const children = createRawSnippet(() => ({
101
+ render: () => '<div id="test-tabs-panel-tab1" role="tabpanel">Panel 1</div>',
102
+ }));
103
+ render(Tabs, {
104
+ props: { tabs: defaultTabs, value: 'tab1', id: 'test-tabs', label: 'Test tabs', children },
105
+ });
92
106
  const tabs = screen.getAllByRole('tab');
93
- // Selected tab should have aria-controls
94
107
  const selectedTab = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true');
95
- expect(selectedTab).toHaveAttribute('aria-controls');
108
+ expect(selectedTab).toHaveAttribute('aria-controls', 'test-tabs-panel-tab1');
109
+ // The reference must resolve to an element that exists
110
+ expect(document.getElementById('test-tabs-panel-tab1')).not.toBeNull();
111
+ // Unselected tabs have no panel mounted, so no aria-controls
112
+ for (const tab of tabs.filter((t) => t !== selectedTab)) {
113
+ expect(tab).not.toHaveAttribute('aria-controls');
114
+ }
96
115
  });
97
116
  });
@@ -4,7 +4,7 @@
4
4
  import { tv, type VariantProps } from 'tailwind-variants';
5
5
 
6
6
  const buttonVariants = tv({
7
- base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
7
+ base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
8
8
  variants: {
9
9
  variant: {
10
10
  default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
@@ -17,6 +17,7 @@
17
17
  DEFAULT_PANEL_STATE,
18
18
  DEFAULT_CONSTRAINTS,
19
19
  DEFAULT_CARD_CONFIG,
20
+ DEFAULT_TAB_WIDTH,
20
21
  CARD_HEADER_HEIGHT,
21
22
  CARD_EXPANDED_RATIO,
22
23
  CARD_MIN_WIDTH,
@@ -84,6 +85,17 @@
84
85
  constraints?: PanelConstraints;
85
86
  /** Card mode configuration */
86
87
  cardConfig?: CardConfig;
88
+ /**
89
+ * Length (px) of the collapsed tab along its edge. Fixed rather than
90
+ * content-sized; raise it for longer titles. Defaults to 220.
91
+ */
92
+ tabWidth?: number;
93
+ /**
94
+ * Distance (px) of the collapsed tab from its pinned edge. Use it to clear
95
+ * fixed app chrome — a sticky header, a toolbar — that would otherwise
96
+ * cover the tab. Defaults to 0.
97
+ */
98
+ tabInset?: number;
87
99
  /** Disable close button */
88
100
  disableClose?: boolean;
89
101
  /** Disable resize functionality */
@@ -127,6 +139,8 @@
127
139
  actions = [],
128
140
  constraints = {},
129
141
  cardConfig = {},
142
+ tabWidth = DEFAULT_TAB_WIDTH,
143
+ tabInset = 0,
130
144
  disableClose = false,
131
145
  disableResize = false,
132
146
  disableDrag = false,
@@ -1153,6 +1167,8 @@
1153
1167
  {subtitle}
1154
1168
  {edge}
1155
1169
  {disableClose}
1170
+ {tabWidth}
1171
+ {tabInset}
1156
1172
  onclick={handleTabClick}
1157
1173
  onClose={handleClose}
1158
1174
  offset={internalTabOffset}
@@ -33,6 +33,17 @@ interface Props {
33
33
  constraints?: PanelConstraints;
34
34
  /** Card mode configuration */
35
35
  cardConfig?: CardConfig;
36
+ /**
37
+ * Length (px) of the collapsed tab along its edge. Fixed rather than
38
+ * content-sized; raise it for longer titles. Defaults to 220.
39
+ */
40
+ tabWidth?: number;
41
+ /**
42
+ * Distance (px) of the collapsed tab from its pinned edge. Use it to clear
43
+ * fixed app chrome — a sticky header, a toolbar — that would otherwise
44
+ * cover the tab. Defaults to 0.
45
+ */
46
+ tabInset?: number;
36
47
  /** Disable close button */
37
48
  disableClose?: boolean;
38
49
  /** Disable resize functionality */
@@ -1,7 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../utils.js';
3
3
  import type { PanelEdge } from '@classic-homes/data-panel-core';
4
- import { constrainTabOffset } from '@classic-homes/data-panel-core';
4
+ import {
5
+ constrainTabOffset,
6
+ getTabTransform,
7
+ getTabBounds,
8
+ TAB_THICKNESS,
9
+ DEFAULT_TAB_WIDTH,
10
+ } from '@classic-homes/data-panel-core';
5
11
 
6
12
  interface Props {
7
13
  /** Tab label/title */
@@ -19,6 +25,18 @@
19
25
  offset?: number;
20
26
  /** Called with the new offset after the tab is dragged along its edge */
21
27
  onOffsetChange?: (offset: number) => void;
28
+ /**
29
+ * Length (px) of the tab along its edge. Fixed rather than content-sized so
30
+ * the tab's geometry is pure pixel math in every browser; raise it for
31
+ * longer labels. Defaults to 220.
32
+ */
33
+ tabWidth?: number;
34
+ /**
35
+ * Distance (px) from the pinned edge. Use it to clear fixed app chrome — a
36
+ * sticky header, a toolbar — that would otherwise cover the tab. Defaults
37
+ * to 0 (flush with the edge).
38
+ */
39
+ tabInset?: number;
22
40
  /** Additional class */
23
41
  class?: string;
24
42
  }
@@ -32,6 +50,8 @@
32
50
  disableClose = false,
33
51
  offset = 0,
34
52
  onOffsetChange,
53
+ tabWidth = DEFAULT_TAB_WIDTH,
54
+ tabInset = 0,
35
55
  class: className,
36
56
  }: Props = $props();
37
57
 
@@ -39,35 +59,32 @@
39
59
 
40
60
  const positionClasses = $derived<string>(
41
61
  {
42
- // Side tabs are the whole chip rotated 90°, so they need a fixed thickness
43
- // (h-14 → 56px) for a deterministic edge hug; see tabTransform below. Border
44
- // classes are in the chip's pre-rotation frame: its bottom rotates to the
45
- // attached (edge) side and its top corners to the viewport-facing corners,
46
- // so both edges drop the bottom border and round the top.
47
- left: 'left-0 top-1/2 h-14 rounded-t-lg border-b-0',
48
- right: 'right-0 top-1/2 h-14 rounded-t-lg border-b-0',
49
- top: 'top-0 left-1/2 rounded-b-lg border-t-0',
50
- bottom: 'bottom-0 left-1/2 rounded-t-lg border-b-0',
62
+ // Border classes are in the chip's pre-rotation frame: its bottom rotates
63
+ // to the attached (edge) side and its top corners to the viewport-facing
64
+ // corners, so both side edges drop the bottom border and round the top.
65
+ // The anchored axis is set inline (see insetStyle) so tabInset can move it.
66
+ left: 'top-1/2 rounded-t-lg border-b-0',
67
+ right: 'top-1/2 rounded-t-lg border-b-0',
68
+ top: 'left-1/2 rounded-b-lg border-t-0',
69
+ bottom: 'left-1/2 rounded-t-lg border-b-0',
51
70
  }[edge]
52
71
  );
53
72
 
73
+ // Distance from the anchored edge. Pushes the tab clear of fixed app chrome
74
+ // (header, toolbar) that would otherwise cover it. The transform is relative
75
+ // to the tab's own box, so shifting the anchor leaves the geometry intact.
76
+ const insetStyle = $derived<string>(`${edge}: ${tabInset}px;`);
77
+
54
78
  const draggable = $derived(!!onOffsetChange);
55
79
 
56
- let btn = $state<HTMLDivElement | null>(null);
57
80
  let dragOffset = $state<number | null>(null);
58
81
  const effectiveOffset = $derived(dragOffset ?? offset);
59
82
 
60
- // Half the side-tab thickness (h-14 → 56px). Left/right tabs are the whole chip
61
- // rotated 90°; we center the rotated footprint on the edge, then shift it back
62
- // by half its thickness so the outer long edge sits flush.
63
- const SIDE_HALF = 28;
64
- const tabTransform = $derived<string>(
65
- edge === 'left'
66
- ? `translate(calc(-50% + ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(90deg)`
67
- : edge === 'right'
68
- ? `translate(calc(50% - ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(-90deg)`
69
- : `translateX(calc(-50% + ${effectiveOffset}px))`
70
- );
83
+ // Geometry is entirely pixel-based (see getTabTransform): the tab carries an
84
+ // explicit width so nothing has to resolve a percentage against a
85
+ // content-sized box while a rotation is composed onto it.
86
+ const tabTransform = $derived<string>(getTabTransform(edge, tabWidth, effectiveOffset));
87
+ const tabBounds = $derived(getTabBounds(edge, tabWidth));
71
88
 
72
89
  // Non-reactive drag bookkeeping.
73
90
  let drag = { startX: 0, startY: 0, startOffset: 0, moved: false, pointerId: -1 };
@@ -93,12 +110,10 @@
93
110
  if (drag.pointerId !== e.pointerId || !el.hasPointerCapture(e.pointerId)) return;
94
111
  const delta = isVertical ? e.clientY - drag.startY : e.clientX - drag.startX;
95
112
  if (Math.abs(delta) > 3) drag.moved = true;
96
- const rect = btn?.getBoundingClientRect();
97
- const tabSize = { width: rect?.width ?? 160, height: rect?.height ?? 48 };
98
113
  dragOffset = constrainTabOffset(
99
114
  drag.startOffset + delta,
100
115
  edge,
101
- tabSize,
116
+ tabBounds,
102
117
  window.innerWidth,
103
118
  window.innerHeight
104
119
  );
@@ -148,27 +163,24 @@
148
163
 
149
164
  function clampOffset() {
150
165
  if (!onOffsetChange) return;
151
- const rect = btn?.getBoundingClientRect();
152
- if (!rect) return;
153
166
  const clamped = constrainTabOffset(
154
167
  offset,
155
168
  edge,
156
- { width: rect.width, height: rect.height },
169
+ tabBounds,
157
170
  window.innerWidth,
158
171
  window.innerHeight
159
172
  );
160
173
  if (clamped !== offset) onOffsetChange(clamped);
161
174
  }
162
175
 
163
- // Re-clamp the persisted offset to the current tab geometry. The tab's
164
- // along-edge extent depends on its rendered size (and, for side edges, on the
165
- // 90° rotation), so an offset saved under a different size/edge/viewport can
166
- // land the tab off-screen. Runs on mount and whenever edge/offset/size change.
176
+ // Re-clamp the persisted offset to the current geometry: an offset saved under
177
+ // a different edge, tabWidth, or viewport can land the tab off-screen. The
178
+ // tab's bounds are known from its props, so this never measures the DOM.
167
179
  $effect(() => {
168
180
  // Track the reactive inputs so this re-runs when they change.
169
181
  void edge;
170
182
  void offset;
171
- void btn;
183
+ void tabBounds;
172
184
  if (dragOffset !== null) return;
173
185
  clampOffset();
174
186
  });
@@ -187,14 +199,19 @@
187
199
  container carries the position/transform and the pointer-drag handlers. -->
188
200
  <!-- svelte-ignore a11y_no_static_element_interactions -->
189
201
  <div
190
- bind:this={btn}
191
202
  class={cn(
192
- 'fixed z-40 flex max-w-[220px] items-center gap-2 border border-border bg-background px-2.5 py-1.5 shadow-md focus-within:ring-2 focus-within:ring-ring',
203
+ // Same layer as the expanded panel: the tab is the only way to bring the
204
+ // panel back, so it must never sit under chrome the panel covers.
205
+ 'fixed z-50 flex items-center gap-2 border border-border bg-background px-2.5 py-1.5 shadow-md focus-within:ring-2 focus-within:ring-ring',
193
206
  draggable && 'cursor-grab touch-none active:cursor-grabbing',
194
207
  positionClasses,
195
208
  className
196
209
  )}
197
- style={`transform: ${tabTransform}`}
210
+ style={`${insetStyle} width: ${tabWidth}px; ${
211
+ /* Side tabs rotate, so they need a fixed thickness for a deterministic edge
212
+ hug; top/bottom stay content-height. */
213
+ isVertical ? `height: ${TAB_THICKNESS}px; ` : ''
214
+ }transform-origin: 0 0; transform: ${tabTransform};`}
198
215
  onpointerdown={handlePointerDown}
199
216
  onpointermove={draggable ? handlePointerMove : undefined}
200
217
  onpointerup={draggable ? handlePointerUp : undefined}
@@ -15,6 +15,18 @@ interface Props {
15
15
  offset?: number;
16
16
  /** Called with the new offset after the tab is dragged along its edge */
17
17
  onOffsetChange?: (offset: number) => void;
18
+ /**
19
+ * Length (px) of the tab along its edge. Fixed rather than content-sized so
20
+ * the tab's geometry is pure pixel math in every browser; raise it for
21
+ * longer labels. Defaults to 220.
22
+ */
23
+ tabWidth?: number;
24
+ /**
25
+ * Distance (px) from the pinned edge. Use it to clear fixed app chrome — a
26
+ * sticky header, a toolbar — that would otherwise cover the tab. Defaults
27
+ * to 0 (flush with the edge).
28
+ */
29
+ tabInset?: number;
18
30
  /** Additional class */
19
31
  class?: string;
20
32
  }
@@ -54,7 +54,7 @@
54
54
  dark: {
55
55
  logo: '#C50F22', // Brand red
56
56
  title: '#000000', // Black for light backgrounds
57
- subtitle: '#7a7a7a', // Gray
57
+ subtitle: '#666666', // Gray - #7a7a7a was 4.29:1 on white (WCAG 1.4.3 fail)
58
58
  },
59
59
  light: {
60
60
  logo: '#ffffff',
@@ -64,7 +64,7 @@
64
64
  'mono-dark': {
65
65
  logo: '#002e3f', // Navy
66
66
  title: '#002e3f',
67
- subtitle: '#7a7a7a',
67
+ subtitle: '#666666', // see dark variant note
68
68
  },
69
69
  'mono-light': {
70
70
  logo: '#ffffff',
@@ -31,6 +31,19 @@
31
31
  ...restProps
32
32
  }: Props = $props();
33
33
 
34
+ // Dev-mode a11y warning, matching Input/Select/Button: a Switch with no
35
+ // aria-label, aria-labelledby, or id (for an external <label for>) announces
36
+ // as a bare "switch" with no name.
37
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
38
+ $effect(() => {
39
+ if (!ariaLabel && !ariaLabelledby && !id) {
40
+ console.warn(
41
+ 'Switch: provide aria-label, aria-labelledby, or an id paired with a <label for> so the switch has an accessible name.'
42
+ );
43
+ }
44
+ });
45
+ }
46
+
34
47
  // Handle checked state changes from user interaction
35
48
  // The primitive handles the actual state management via bind:checked
36
49
  function handleCheckedChange(newChecked: boolean) {
@@ -19,7 +19,16 @@
19
19
  label: string;
20
20
  /** Additional classes for container */
21
21
  class?: string;
22
- /** Tab panel content */
22
+ /**
23
+ * Tab panel content - compose with `TabPanel` so each panel gets the
24
+ * matching `role="tabpanel"` / id / aria-labelledby wiring.
25
+ *
26
+ * aria-controls is only emitted while a panel actually exists in the DOM
27
+ * (children provided + tab active): a reference to a missing element is an
28
+ * ARIA validity failure (axe: aria-valid-attr-value). Consumers rendering
29
+ * panels OUTSIDE this component should give them
30
+ * `id="{id}-panel-{tabId}"` + `aria-labelledby="{id}-tab-{tabId}"`.
31
+ */
23
32
  children?: Snippet;
24
33
  /** Callback when active tab changes */
25
34
  onValueChange?: (value: string) => void;
@@ -111,7 +120,7 @@
111
120
  role="tab"
112
121
  type="button"
113
122
  aria-selected={value === tab.id}
114
- aria-controls={`${id}-panel-${tab.id}`}
123
+ aria-controls={children && value === tab.id ? `${id}-panel-${tab.id}` : undefined}
115
124
  tabindex={value === tab.id ? 0 : -1}
116
125
  disabled={tab.disabled}
117
126
  class={cn(
@@ -15,7 +15,16 @@ interface Props {
15
15
  label: string;
16
16
  /** Additional classes for container */
17
17
  class?: string;
18
- /** Tab panel content */
18
+ /**
19
+ * Tab panel content - compose with `TabPanel` so each panel gets the
20
+ * matching `role="tabpanel"` / id / aria-labelledby wiring.
21
+ *
22
+ * aria-controls is only emitted while a panel actually exists in the DOM
23
+ * (children provided + tab active): a reference to a missing element is an
24
+ * ARIA validity failure (axe: aria-valid-attr-value). Consumers rendering
25
+ * panels OUTSIDE this component should give them
26
+ * `id="{id}-panel-{tabId}"` + `aria-labelledby="{id}-tab-{tabId}"`.
27
+ */
19
28
  children?: Snippet;
20
29
  /** Callback when active tab changes */
21
30
  onValueChange?: (value: string) => void;
@@ -85,8 +85,8 @@
85
85
  className
86
86
  )}
87
87
  >
88
- <!-- Logo -->
89
- <div class="mb-6 flex flex-col items-center">
88
+ <!-- Logo. <header> so it isn't content outside every landmark (axe: region) -->
89
+ <header class="mb-6 flex flex-col items-center">
90
90
  {#if logo}
91
91
  {@render logo()}
92
92
  {:else}
@@ -98,7 +98,7 @@
98
98
  environment={logoEnvironment}
99
99
  />
100
100
  {/if}
101
- </div>
101
+ </header>
102
102
 
103
103
  <!-- Main Content Card -->
104
104
  <main
@@ -52,6 +52,14 @@
52
52
  footer?: Snippet;
53
53
  /** Additional classes for the container */
54
54
  class?: string;
55
+ /**
56
+ * Whether this layout owns the whole page (default). Set false when
57
+ * rendering inside an app layout that already provides the page chrome -
58
+ * e.g. a SvelteKit +error.svelte inside a layout with its own
59
+ * <main id="main-content"> - so AppShell and the main landmark aren't
60
+ * duplicated (axe: landmark-no-duplicate-main / landmark-main-is-top-level).
61
+ */
62
+ standalone?: boolean;
55
63
  /** Main content (custom error content, replaces default display) */
56
64
  children?: Snippet;
57
65
  }
@@ -72,6 +80,7 @@
72
80
  actions,
73
81
  footer,
74
82
  class: className,
83
+ standalone = true,
75
84
  children,
76
85
  }: Props = $props();
77
86
 
@@ -128,16 +137,19 @@
128
137
  }
129
138
  </script>
130
139
 
131
- <AppShell>
140
+ {#snippet layoutBody()}
132
141
  <div
133
142
  class={cn(
134
- 'flex h-screen flex-col items-center justify-center overflow-auto bg-content-bg px-4 py-8 sm:px-6 lg:px-8',
143
+ 'flex flex-col items-center justify-center overflow-auto bg-content-bg px-4 py-8 sm:px-6 lg:px-8',
144
+ standalone ? 'h-screen' : 'min-h-[60vh]',
135
145
  className
136
146
  )}
137
147
  >
138
- <!-- Logo -->
148
+ <!-- Logo. <header> so the logo isn't content outside every landmark
149
+ (axe: region); nested under a host <main> it simply loses the banner
150
+ role, which is correct. -->
139
151
  {#if showLogo}
140
- <div class="mb-6">
152
+ <header class="mb-6">
141
153
  {#if logo}
142
154
  {@render logo()}
143
155
  {:else}
@@ -145,11 +157,16 @@
145
157
  <LogoMain variant="horizontal" color="dark" size="md" />
146
158
  </a>
147
159
  {/if}
148
- </div>
160
+ </header>
149
161
  {/if}
150
162
 
151
- <!-- Main Content -->
152
- <main id="main-content" class="w-full max-w-md text-center">
163
+ <!-- Main Content. A second main landmark is invalid when the host layout
164
+ already renders one, so embedded mode uses a plain div. -->
165
+ <svelte:element
166
+ this={standalone ? 'main' : 'div'}
167
+ id={standalone ? 'main-content' : undefined}
168
+ class="w-full max-w-md text-center"
169
+ >
153
170
  {#if children}
154
171
  {@render children()}
155
172
  {:else}
@@ -197,7 +214,7 @@
197
214
  </div>
198
215
  {/if}
199
216
  {/if}
200
- </main>
217
+ </svelte:element>
201
218
 
202
219
  <!-- Footer -->
203
220
  {#if footer}
@@ -206,4 +223,12 @@
206
223
  </footer>
207
224
  {/if}
208
225
  </div>
209
- </AppShell>
226
+ {/snippet}
227
+
228
+ {#if standalone}
229
+ <AppShell>
230
+ {@render layoutBody()}
231
+ </AppShell>
232
+ {:else}
233
+ {@render layoutBody()}
234
+ {/if}
@@ -46,6 +46,14 @@ interface Props {
46
46
  footer?: Snippet;
47
47
  /** Additional classes for the container */
48
48
  class?: string;
49
+ /**
50
+ * Whether this layout owns the whole page (default). Set false when
51
+ * rendering inside an app layout that already provides the page chrome -
52
+ * e.g. a SvelteKit +error.svelte inside a layout with its own
53
+ * <main id="main-content"> - so AppShell and the main landmark aren't
54
+ * duplicated (axe: landmark-no-duplicate-main / landmark-main-is-top-level).
55
+ */
56
+ standalone?: boolean;
49
57
  /** Main content (custom error content, replaces default display) */
50
58
  children?: Snippet;
51
59
  }
@@ -65,9 +65,11 @@
65
65
  {#each links as section}
66
66
  <div>
67
67
  {#if section.title}
68
- <h3 class={cn('text-lg font-bold mb-4', isDark ? 'text-white' : 'text-foreground')}>
68
+ <!-- h2, not h3: most pages reach the footer with only an h1 in
69
+ scope, so h3 skipped a level (axe: heading-order) -->
70
+ <h2 class={cn('text-lg font-bold mb-4', isDark ? 'text-white' : 'text-foreground')}>
69
71
  {section.title}
70
- </h3>
72
+ </h2>
71
73
  {/if}
72
74
  <ul class="mt-4 space-y-2">
73
75
  {#each section.items as item}
@@ -127,7 +129,8 @@
127
129
  <LogoMain variant="horizontal" color={isDark ? 'light' : 'dark'} size="sm" />
128
130
  {/if}
129
131
 
130
- <p class={cn('text-center text-sm', isDark ? 'text-gray-400' : 'text-muted-foreground')}>
132
+ <!-- gray-300, not gray-400: #9ca3af is 3.22:1 on the gray-600 footer -->
133
+ <p class={cn('text-center text-sm', isDark ? 'text-gray-300' : 'text-muted-foreground')}>
131
134
  {displayCopyright}
132
135
  </p>
133
136
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",