@xenide-io/the-old-ui-theme 0.5.2 → 0.5.4

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.
Files changed (59) hide show
  1. package/README.md +47 -34
  2. package/dist/{index-Od4pIP4F.d.mts → Chip-C5hRFAhe.d.mts} +10 -606
  3. package/dist/{index-Od4pIP4F.d.ts → Chip-C5hRFAhe.d.ts} +10 -606
  4. package/dist/{chunk-G53YLHVE.mjs → chunk-E5ZBQYEX.mjs} +59 -9
  5. package/dist/{chunk-RZXHZWUB.mjs → chunk-SBBKXV35.mjs} +174 -222
  6. package/dist/index.d.mts +4 -80
  7. package/dist/index.d.ts +4 -80
  8. package/dist/index.js +562 -3746
  9. package/dist/index.mjs +3 -765
  10. package/dist/suite.d.mts +56 -25
  11. package/dist/suite.d.ts +56 -25
  12. package/dist/suite.js +248 -107
  13. package/dist/suite.mjs +217 -105
  14. package/dist/ui.d.mts +608 -2
  15. package/dist/ui.d.ts +608 -2
  16. package/dist/ui.js +232 -230
  17. package/dist/ui.mjs +2 -2
  18. package/package.json +2 -6
  19. package/scripts/install-theme-dev.sh +60 -0
  20. package/scripts/sync-posthog-icons.mjs +75 -0
  21. package/src/app/globals.css +1 -0
  22. package/src/components/ui/Chip.tsx +36 -18
  23. package/src/components/ui/CommandPalette.test.tsx +41 -0
  24. package/src/components/ui/CommandPalette.tsx +58 -9
  25. package/src/components/ui/DropdownMenu.tsx +50 -11
  26. package/src/components/ui/SegmentedControl.tsx +2 -1
  27. package/src/components/ui/SettingsLayout.tsx +19 -5
  28. package/src/components/ui/ThemeSwitcher.tsx +12 -3
  29. package/src/components/ui/interaction-primitives.test.tsx +52 -0
  30. package/src/styles/suite-skin.css +53 -1
  31. package/src/styles/themes.css +445 -1656
  32. package/src/suite/components/app-switcher.tsx +32 -25
  33. package/src/suite/components/command-palette-host.tsx +20 -8
  34. package/src/suite/components/suite-app-layout.tsx +31 -10
  35. package/src/suite/components/suite-bottom-nav.tsx +23 -18
  36. package/src/suite/components/suite-layout.test.tsx +44 -0
  37. package/src/suite/components/suite-layout.tsx +3 -3
  38. package/src/suite/components/suite-mobile-drawer.tsx +12 -0
  39. package/src/suite/components/suite-mobile-header.tsx +15 -9
  40. package/src/suite/components/suite-notification-bell.tsx +23 -11
  41. package/src/suite/components/suite-settings-mobile-nav.tsx +2 -0
  42. package/src/suite/components/suite-sidebar.tsx +54 -43
  43. package/src/suite/components/suite-user-menu.tsx +54 -26
  44. package/src/suite/components/today-page-frame.tsx +1 -1
  45. package/src/suite/icons/app-accents.ts +38 -43
  46. package/src/suite/icons/glyph-parts.tsx +16 -9
  47. package/src/suite/icons/glyphs.ts +130 -99
  48. package/src/suite/lib/apps.ts +1 -1
  49. package/src/suite/lib/injected.ts +12 -11
  50. package/src/suite/lib/use-sidebar-width.ts +23 -8
  51. package/src/components/charts/index.ts +0 -9
  52. package/src/components/charts/quill/BarChart.tsx +0 -85
  53. package/src/components/charts/quill/FunnelChart.tsx +0 -49
  54. package/src/components/charts/quill/InsightShell.tsx +0 -27
  55. package/src/components/charts/quill/MetricCard.tsx +0 -44
  56. package/src/components/charts/quill/PieChart.tsx +0 -81
  57. package/src/components/charts/quill/Sparkline.tsx +0 -57
  58. package/src/components/charts/quill/TimeSeriesLineChart.tsx +0 -62
  59. package/src/components/charts/quill/index.ts +0 -9
package/dist/ui.mjs CHANGED
@@ -108,7 +108,7 @@ import {
108
108
  ToggleButton,
109
109
  ToggleGroup,
110
110
  Widget
111
- } from "./chunk-RZXHZWUB.mjs";
111
+ } from "./chunk-SBBKXV35.mjs";
112
112
  import {
113
113
  ButtonChrome,
114
114
  DropdownButton,
@@ -120,7 +120,7 @@ import {
120
120
  IconByName,
121
121
  Tooltip,
122
122
  TooltipProvider
123
- } from "./chunk-G53YLHVE.mjs";
123
+ } from "./chunk-E5ZBQYEX.mjs";
124
124
  import "./chunk-FWCSY2DS.mjs";
125
125
  export {
126
126
  Accordion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design \u2014 optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "files": [
59
59
  "dist",
60
+ "scripts",
60
61
  "src/app/globals.css",
61
62
  "src/components/ui",
62
- "src/components/charts",
63
63
  "src/components/icons",
64
64
  "src/suite",
65
65
  "src/styles/themes.css",
@@ -87,14 +87,10 @@
87
87
  "react-dom": "^18.0.0 || ^19.0.0",
88
88
  "next": ">=16.2.7",
89
89
  "tailwindcss": "^3.4.0",
90
- "d3-scale": "^4.0.2",
91
90
  "iconoir-react": ">=7.11.1",
92
91
  "motion": ">=12"
93
92
  },
94
93
  "peerDependenciesMeta": {
95
- "d3-scale": {
96
- "optional": true
97
- },
98
94
  "iconoir-react": {
99
95
  "optional": true
100
96
  },
@@ -0,0 +1,60 @@
1
+ #!/bin/sh
2
+ # Dev-only: copy this package tree into an app's node_modules so Next.js
3
+ # Turbopack resolves CSS/exports from real files (not a broken file: link).
4
+ #
5
+ # Host must have built dist first: `bun run build:lib` in this repo.
6
+ # Safe to skip when THEME_DIR is missing (production images).
7
+ #
8
+ # Usage (from an app directory, Docker or local):
9
+ # THEME_DIR=/the-old-ui TARGET=/app/node_modules/@xenide-io/the-old-ui-theme \
10
+ # sh /the-old-ui/scripts/install-theme-dev.sh
11
+ # Or via each app's thin wrapper under scripts/install-theme-dev.sh.
12
+
13
+ set -e
14
+
15
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
16
+ THEME_DIR=${THEME_DIR:-$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)}
17
+
18
+ if [ -z "${TARGET:-}" ]; then
19
+ if [ -d /app/node_modules ]; then
20
+ TARGET=/app/node_modules/@xenide-io/the-old-ui-theme
21
+ elif [ -d ./node_modules ]; then
22
+ TARGET=$(pwd)/node_modules/@xenide-io/the-old-ui-theme
23
+ else
24
+ echo "install-theme-dev: set TARGET=.../node_modules/@xenide-io/the-old-ui-theme" >&2
25
+ exit 1
26
+ fi
27
+ fi
28
+
29
+ if [ ! -d "$THEME_DIR" ]; then
30
+ echo "install-theme-dev: $THEME_DIR not found, skipping"
31
+ exit 0
32
+ fi
33
+
34
+ if [ ! -d "$THEME_DIR/dist" ]; then
35
+ echo "install-theme-dev: $THEME_DIR/dist is missing."
36
+ echo "Run: cd \"$THEME_DIR\" && bun run build:lib"
37
+ exit 1
38
+ fi
39
+
40
+ echo "install-theme-dev: copying $THEME_DIR -> $TARGET"
41
+
42
+ rm -rf "$TARGET"
43
+ mkdir -p "$TARGET"
44
+
45
+ # tar preserves structure and is faster than cp for many small files.
46
+ tar -C "$THEME_DIR" -cf - \
47
+ --exclude='.git' \
48
+ --exclude='.next' \
49
+ --exclude='node_modules' \
50
+ --exclude='test-results' \
51
+ --exclude='playwright-report' \
52
+ --exclude='coverage' \
53
+ --exclude='out' \
54
+ --exclude='*.log' \
55
+ --exclude='.DS_Store' \
56
+ --exclude='.turbo' \
57
+ --exclude='*.tsbuildinfo' \
58
+ . | tar -C "$TARGET" -xf -
59
+
60
+ echo "install-theme-dev: done"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Compare local Lemon glyphs with PostHog open-source icons.tsx.
4
+ * Usage: node scripts/sync-posthog-icons.mjs
5
+ *
6
+ * Downloads: frontend/src/lib/lemon-ui/icons/icons.tsx
7
+ * Reports path mismatches — Material-only icons (bell, volume, …) are expected.
8
+ */
9
+
10
+ import { readFileSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+
13
+ const POSTHOG_URL =
14
+ "https://raw.githubusercontent.com/PostHog/posthog/master/frontend/src/lib/lemon-ui/icons/icons.tsx";
15
+ const LOCAL = resolve("src/components/icons/icons.tsx");
16
+ const CACHE = resolve("scripts/.posthog-icons.tsx");
17
+
18
+ function extractPhBlock(source, name) {
19
+ const start = source.indexOf(`export function ${name}(`);
20
+ if (start < 0) return null;
21
+ const next = source.indexOf("\nexport function Icon", start + 10);
22
+ return source.slice(start, next > 0 ? next : start + 4000);
23
+ }
24
+
25
+ function extractOurBlock(source, name) {
26
+ const start = source.indexOf(`export const ${name} =`);
27
+ if (start < 0) return null;
28
+ const next = source.indexOf("\nexport const Icon", start + 10);
29
+ const nextFn = source.indexOf("\nexport function Icon", start + 10);
30
+ const end = [next, nextFn].filter((x) => x > 0).sort((a, b) => a - b)[0] || start + 4000;
31
+ return source.slice(start, end);
32
+ }
33
+
34
+ function normPaths(block) {
35
+ if (!block) return "";
36
+ const fills = [...block.matchAll(/fill\(\s*\n?\s*"([^"]+)"/g)].map((m) => m[1]);
37
+ const ds = [...block.matchAll(/<path d="([^"]+)"/g)].map((m) => m[1]);
38
+ const attrs = [...block.matchAll(/\sd="([^"]+)"/g)].map((m) => m[1]);
39
+ return [...fills, ...ds, ...attrs].join("|").replace(/\s/g, "");
40
+ }
41
+
42
+ async function main() {
43
+ const response = await fetch(POSTHOG_URL);
44
+ if (!response.ok) {
45
+ throw new Error(`Failed to fetch PostHog icons: ${response.status}`);
46
+ }
47
+ const ph = await response.text();
48
+ writeFileSync(CACHE, ph);
49
+
50
+ const local = readFileSync(LOCAL, "utf8");
51
+ const phNames = new Set([...ph.matchAll(/export function (Icon\w+)/g)].map((m) => m[1]));
52
+ const ourNames = [...local.matchAll(/export const (Icon\w+)/g)]
53
+ .map((m) => m[1])
54
+ .filter((n) => n !== "IconSpinner");
55
+
56
+ const shared = ourNames.filter((n) => phNames.has(n));
57
+ const materialOnly = ourNames.filter((n) => !phNames.has(n));
58
+ const mismatches = shared.filter(
59
+ (n) => normPaths(extractPhBlock(ph, n)) !== normPaths(extractOurBlock(local, n)),
60
+ );
61
+
62
+ console.log(`Compared ${shared.length} shared icons with PostHog master.`);
63
+ if (mismatches.length) {
64
+ console.log("\nPath mismatches (update src/components/icons/icons.tsx):");
65
+ mismatches.forEach((n) => console.log(` - ${n}`));
66
+ } else {
67
+ console.log("All shared PostHog icons match.");
68
+ }
69
+ console.log(`\nMaterial-only icons (${materialOnly.length}): ${materialOnly.join(", ")}`);
70
+ }
71
+
72
+ main().catch((error) => {
73
+ console.error(error.message);
74
+ process.exit(1);
75
+ });
@@ -1,5 +1,6 @@
1
1
  @import "../styles/themes.css";
2
2
  @import "../styles/typography.css";
3
+ @import "../styles/suite-skin.css";
3
4
 
4
5
  @tailwind base;
5
6
  @tailwind components;
@@ -14,31 +14,49 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip({
14
14
  className,
15
15
  ...props
16
16
  }, ref) {
17
+ const chipClassName = cn(
18
+ "ph-chip",
19
+ selected && "ph-chip--selected",
20
+ className
21
+ );
22
+
23
+ if (onRemove) {
24
+ const removeLabel =
25
+ typeof children === "string" ? `Remove ${children}` : "Remove";
26
+
27
+ return (
28
+ <span className={chipClassName}>
29
+ <button
30
+ ref={ref}
31
+ type="button"
32
+ className="min-w-0 bg-transparent p-0 text-inherit"
33
+ {...props}
34
+ >
35
+ <span className="ph-chip__text">{children}</span>
36
+ </button>
37
+ <button
38
+ type="button"
39
+ onClick={onRemove}
40
+ disabled={props.disabled}
41
+ className="ph-chip__remove"
42
+ aria-label={removeLabel}
43
+ >
44
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
45
+ <path d="M2 2l6 6M8 2l-6 6" />
46
+ </svg>
47
+ </button>
48
+ </span>
49
+ );
50
+ }
51
+
17
52
  return (
18
53
  <button
19
54
  ref={ref}
20
55
  type="button"
21
- className={cn(
22
- "ph-chip",
23
- selected && "ph-chip--selected",
24
- className
25
- )}
56
+ className={chipClassName}
26
57
  {...props}
27
58
  >
28
59
  <span className="ph-chip__text">{children}</span>
29
- {onRemove && (
30
- <span
31
- role="button"
32
- tabIndex={-1}
33
- onClick={(e) => { e.stopPropagation(); onRemove(); }}
34
- className="ph-chip__remove"
35
- aria-label="Remove"
36
- >
37
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
38
- <path d="M2 2l6 6M8 2l-6 6" />
39
- </svg>
40
- </span>
41
- )}
42
60
  </button>
43
61
  );
44
62
  });
@@ -0,0 +1,41 @@
1
+ import { useState } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { CommandPalette } from "@/components/ui/CommandPalette";
6
+
7
+ describe("command palette", () => {
8
+ it("behaves as a modal dialog and restores focus when dismissed", async () => {
9
+ const user = userEvent.setup();
10
+ const onSelect = vi.fn();
11
+
12
+ function Fixture() {
13
+ const [open, setOpen] = useState(false);
14
+ return (
15
+ <>
16
+ <button type="button" onClick={() => setOpen(true)}>
17
+ Open commands
18
+ </button>
19
+ <CommandPalette
20
+ isOpen={open}
21
+ onClose={() => setOpen(false)}
22
+ items={[
23
+ { id: "reports", label: "Open reports", onSelect },
24
+ ]}
25
+ />
26
+ </>
27
+ );
28
+ }
29
+
30
+ render(<Fixture />);
31
+ const trigger = screen.getByRole("button", { name: "Open commands" });
32
+ await user.click(trigger);
33
+
34
+ expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument();
35
+ expect(screen.getByRole("textbox", { name: "Search commands" })).toHaveFocus();
36
+
37
+ await user.keyboard("{Escape}");
38
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
39
+ expect(trigger).toHaveFocus();
40
+ });
41
+ });
@@ -18,6 +18,10 @@ interface CommandPaletteProps {
18
18
  onClose: () => void;
19
19
  placeholder?: string;
20
20
  className?: string;
21
+ id?: string;
22
+ dataTest?: string;
23
+ inputId?: string;
24
+ inputDataTest?: string;
21
25
  }
22
26
 
23
27
  export function CommandPalette({
@@ -26,21 +30,32 @@ export function CommandPalette({
26
30
  onClose,
27
31
  placeholder = "Type a command or search...",
28
32
  className,
33
+ id,
34
+ dataTest,
35
+ inputId,
36
+ inputDataTest,
29
37
  }: CommandPaletteProps) {
30
38
  const [query, setQuery] = useState("");
31
39
  const [selectedIndex, setSelectedIndex] = useState(0);
32
40
  const inputRef = useRef<HTMLInputElement>(null);
41
+ const panelRef = useRef<HTMLDivElement>(null);
42
+ const previousFocusRef = useRef<HTMLElement | null>(null);
33
43
 
34
44
  const filtered = items.filter((item) =>
35
- item.label.toLowerCase().includes(query.toLowerCase())
45
+ item.label.toLowerCase().includes(query.toLowerCase()),
36
46
  );
37
47
 
38
48
  useEffect(() => {
39
- if (isOpen) {
40
- setQuery("");
41
- setSelectedIndex(0);
42
- inputRef.current?.focus();
43
- }
49
+ if (!isOpen) return;
50
+ previousFocusRef.current =
51
+ document.activeElement instanceof HTMLElement
52
+ ? document.activeElement
53
+ : null;
54
+ setQuery("");
55
+ setSelectedIndex(0);
56
+ inputRef.current?.focus();
57
+
58
+ return () => previousFocusRef.current?.focus();
44
59
  }, [isOpen]);
45
60
 
46
61
  useEffect(() => {
@@ -49,10 +64,12 @@ export function CommandPalette({
49
64
 
50
65
  switch (e.key) {
51
66
  case "ArrowDown":
67
+ if (!filtered.length) break;
52
68
  e.preventDefault();
53
69
  setSelectedIndex((i) => (i + 1) % filtered.length);
54
70
  break;
55
71
  case "ArrowUp":
72
+ if (!filtered.length) break;
56
73
  e.preventDefault();
57
74
  setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
58
75
  break;
@@ -64,6 +81,22 @@ export function CommandPalette({
64
81
  case "Escape":
65
82
  onClose();
66
83
  break;
84
+ case "Tab": {
85
+ const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
86
+ 'input, button, [href], [tabindex]:not([tabindex="-1"])',
87
+ );
88
+ if (!focusable?.length) break;
89
+ const first = focusable[0];
90
+ const last = focusable[focusable.length - 1];
91
+ if (e.shiftKey && document.activeElement === first) {
92
+ e.preventDefault();
93
+ last.focus();
94
+ } else if (!e.shiftKey && document.activeElement === last) {
95
+ e.preventDefault();
96
+ first.focus();
97
+ }
98
+ break;
99
+ }
67
100
  }
68
101
  };
69
102
 
@@ -74,18 +107,32 @@ export function CommandPalette({
74
107
  if (!isOpen) return null;
75
108
 
76
109
  return (
77
- <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[20vh] backdrop-blur-sm">
110
+ <div
111
+ id={id}
112
+ data-test={dataTest}
113
+ className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[20vh] backdrop-blur-sm"
114
+ onMouseDown={(event) => {
115
+ if (event.target === event.currentTarget) onClose();
116
+ }}
117
+ >
78
118
  <div
119
+ ref={panelRef}
120
+ role="dialog"
121
+ aria-modal="true"
122
+ aria-label="Command palette"
79
123
  className={cn(
80
124
  "w-full max-w-xl overflow-hidden rounded-xl border border-ph-border bg-ph-surface shadow-ph-md",
81
- className
125
+ className,
82
126
  )}
83
127
  >
84
128
  <div className="flex items-center gap-3 border-b border-ph-border px-4 py-3">
85
129
  <IconSearch className="h-5 w-5 text-ph-mutedtext" />
86
130
  <input
87
131
  ref={inputRef}
132
+ id={inputId}
133
+ data-test={inputDataTest}
88
134
  type="text"
135
+ aria-label="Search commands"
89
136
  value={query}
90
137
  onChange={(e) => {
91
138
  setQuery(e.target.value);
@@ -108,6 +155,8 @@ export function CommandPalette({
108
155
  <button
109
156
  key={item.id}
110
157
  type="button"
158
+ id={`command-${item.id}`}
159
+ data-test={`command-${item.id}`}
111
160
  onClick={() => {
112
161
  item.onSelect();
113
162
  onClose();
@@ -117,7 +166,7 @@ export function CommandPalette({
117
166
  "flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
118
167
  index === selectedIndex
119
168
  ? "bg-ph-muted text-ph-ink"
120
- : "text-ph-subtle"
169
+ : "text-ph-subtle",
121
170
  )}
122
171
  >
123
172
  {item.icon && <span className="h-5 w-5">{item.icon}</span>}
@@ -35,11 +35,14 @@ interface DropdownOpenProps {
35
35
  }
36
36
 
37
37
  export interface DropdownMenuProps
38
- extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
38
+ extends
39
+ Omit<HTMLAttributes<HTMLDivElement>, "children">,
39
40
  DropdownPlacementProps,
40
41
  DropdownOpenProps {
41
42
  /** Custom non-interactive trigger node, such as an avatar or icon chip. */
42
43
  trigger: ReactNode;
44
+ triggerId?: string;
45
+ triggerDataTest?: string;
43
46
  /** Accessible name when the trigger has no visible text. */
44
47
  "aria-label": string;
45
48
  children: ReactNode;
@@ -47,7 +50,8 @@ export interface DropdownMenuProps
47
50
  }
48
51
 
49
52
  export interface DropdownButtonProps
50
- extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
53
+ extends
54
+ Omit<HTMLAttributes<HTMLDivElement>, "children">,
51
55
  DropdownPlacementProps,
52
56
  DropdownOpenProps {
53
57
  /** Visible button label. */
@@ -67,8 +71,12 @@ export interface DropdownButtonProps
67
71
  }
68
72
 
69
73
  export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
70
- export type DropdownRadioGroupProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioGroup>;
71
- export type DropdownRadioItemProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioItem>;
74
+ export type DropdownRadioGroupProps = ComponentPropsWithoutRef<
75
+ typeof DropdownPrimitive.RadioGroup
76
+ >;
77
+ export type DropdownRadioItemProps = ComponentPropsWithoutRef<
78
+ typeof DropdownPrimitive.RadioItem
79
+ >;
72
80
 
73
81
  const sizeClass: Record<ButtonSize, string> = {
74
82
  xs: "ph-btn-xs",
@@ -106,7 +114,11 @@ function DropdownContent({
106
114
  sticky="partial"
107
115
  >
108
116
  {children}
109
- <DropdownPrimitive.Arrow className="ph-dropdown-arrow" width={12} height={6} />
117
+ <DropdownPrimitive.Arrow
118
+ className="ph-dropdown-arrow"
119
+ width={12}
120
+ height={6}
121
+ />
110
122
  </DropdownPrimitive.Content>
111
123
  </DropdownPrimitive.Portal>
112
124
  );
@@ -115,6 +127,8 @@ function DropdownContent({
115
127
  /** Collision-aware menu that flips sides and shifts its alignment to remain visible. */
116
128
  export function DropdownMenu({
117
129
  trigger,
130
+ triggerId,
131
+ triggerDataTest,
118
132
  children,
119
133
  align,
120
134
  side,
@@ -141,7 +155,13 @@ export function DropdownMenu({
141
155
  modal={modal}
142
156
  >
143
157
  <DropdownPrimitive.Trigger asChild disabled={disabled}>
144
- <button type="button" className="ph-dropdown-trigger" aria-label={ariaLabel}>
158
+ <button
159
+ type="button"
160
+ id={triggerId}
161
+ data-test={triggerDataTest}
162
+ className="ph-dropdown-trigger"
163
+ aria-label={ariaLabel}
164
+ >
145
165
  {trigger}
146
166
  </button>
147
167
  </DropdownPrimitive.Trigger>
@@ -187,7 +207,9 @@ export function DropdownButton({
187
207
  disabled,
188
208
  ...props
189
209
  }: DropdownButtonProps) {
190
- const trailing = sideIcon ?? <IconChevronDown className="h-4 w-4" aria-hidden />;
210
+ const trailing = sideIcon ?? (
211
+ <IconChevronDown className="h-4 w-4" aria-hidden />
212
+ );
191
213
 
192
214
  return (
193
215
  <div className={cn("ph-dropdown", className)} {...props}>
@@ -210,7 +232,12 @@ export function DropdownButton({
210
232
  )}
211
233
  aria-label={ariaLabel}
212
234
  >
213
- <ButtonChrome label={label} icon={icon} sideIcon={trailing} split={split} />
235
+ <ButtonChrome
236
+ label={label}
237
+ icon={icon}
238
+ sideIcon={trailing}
239
+ split={split}
240
+ />
214
241
  </button>
215
242
  </DropdownPrimitive.Trigger>
216
243
  <DropdownContent
@@ -229,7 +256,12 @@ export function DropdownButton({
229
256
  );
230
257
  }
231
258
 
232
- export function DropdownItem({ className, children, disabled, ...props }: DropdownItemProps) {
259
+ export function DropdownItem({
260
+ className,
261
+ children,
262
+ disabled,
263
+ ...props
264
+ }: DropdownItemProps) {
233
265
  return (
234
266
  <DropdownPrimitive.Item asChild disabled={disabled}>
235
267
  <button
@@ -248,9 +280,16 @@ export function DropdownRadioGroup(props: DropdownRadioGroupProps) {
248
280
  return <DropdownPrimitive.RadioGroup {...props} />;
249
281
  }
250
282
 
251
- export function DropdownRadioItem({ className, children, ...props }: DropdownRadioItemProps) {
283
+ export function DropdownRadioItem({
284
+ className,
285
+ children,
286
+ ...props
287
+ }: DropdownRadioItemProps) {
252
288
  return (
253
- <DropdownPrimitive.RadioItem className={cn("ph-dropdown-item ph-dropdown-radio-item", className)} {...props}>
289
+ <DropdownPrimitive.RadioItem
290
+ className={cn("ph-dropdown-item ph-dropdown-radio-item", className)}
291
+ {...props}
292
+ >
254
293
  <span className="ph-dropdown-item-indicator" aria-hidden>
255
294
  <DropdownPrimitive.ItemIndicator>
256
295
  <IconCheck className="h-3.5 w-3.5" />
@@ -27,9 +27,10 @@ export function SegmentedControl({ options, value, onChange, className }: Segmen
27
27
  <button
28
28
  key={option.value}
29
29
  type="button"
30
+ aria-pressed={value === option.value}
30
31
  onClick={() => onChange(option.value)}
31
32
  className={cn(
32
- "relative flex items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all",
33
+ "relative flex min-h-11 items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all sm:min-h-9",
33
34
  value === option.value
34
35
  ? "bg-ph-surface text-ph-ink shadow-ph"
35
36
  : "text-ph-subtle hover:text-ph-ink"
@@ -20,7 +20,10 @@ export interface SettingsNavProps extends ComponentPropsWithoutRef<"div"> {
20
20
  groups: readonly SettingsNavGroup[];
21
21
  }
22
22
 
23
- export interface SettingsLayoutProps extends Omit<ComponentPropsWithoutRef<"section">, "title"> {
23
+ export interface SettingsLayoutProps extends Omit<
24
+ ComponentPropsWithoutRef<"section">,
25
+ "title"
26
+ > {
24
27
  title: ReactNode;
25
28
  description?: ReactNode;
26
29
  actions?: ReactNode;
@@ -40,12 +43,21 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
40
43
  <a
41
44
  key={item.id}
42
45
  href={item.href}
43
- className={cn("ph-settings-nav__item", item.active && "ph-settings-nav__item--active")}
46
+ id={`settings-nav-${item.id}`}
47
+ data-test={`settings-nav-${item.id}`}
48
+ className={cn(
49
+ "ph-settings-nav__item",
50
+ item.active && "ph-settings-nav__item--active",
51
+ )}
44
52
  aria-current={item.active ? "page" : undefined}
45
53
  >
46
- {item.icon ? <span className="ph-settings-nav__icon">{item.icon}</span> : null}
54
+ {item.icon ? (
55
+ <span className="ph-settings-nav__icon">{item.icon}</span>
56
+ ) : null}
47
57
  <span>{item.label}</span>
48
- {item.badge ? <span className="ph-settings-nav__badge">{item.badge}</span> : null}
58
+ {item.badge ? (
59
+ <span className="ph-settings-nav__badge">{item.badge}</span>
60
+ ) : null}
49
61
  </a>
50
62
  ))}
51
63
  </div>
@@ -72,7 +84,9 @@ export function SettingsLayout({
72
84
  <h2>{title}</h2>
73
85
  {description ? <p>{description}</p> : null}
74
86
  </div>
75
- {actions ? <div className="ph-settings-layout__actions">{actions}</div> : null}
87
+ {actions ? (
88
+ <div className="ph-settings-layout__actions">{actions}</div>
89
+ ) : null}
76
90
  </header>
77
91
 
78
92
  <details className="ph-settings-layout__mobile-nav">
@@ -1,7 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { persistTheme, readStoredTheme, THEME_GROUPS, THEMES, type ThemeId } from "@/themes";
4
+ import {
5
+ DEFAULT_THEME_ID,
6
+ persistTheme,
7
+ readStoredTheme,
8
+ THEME_GROUPS,
9
+ THEMES,
10
+ type ThemeId,
11
+ } from "@/themes";
5
12
  import { cn } from "@/lib/cn";
6
13
 
7
14
  export interface ThemeSwitcherProps {
@@ -10,7 +17,7 @@ export interface ThemeSwitcherProps {
10
17
 
11
18
  /** Colour-only theme picker — persists to localStorage and sets html[data-theme]. */
12
19
  export function ThemeSwitcher({ className }: ThemeSwitcherProps) {
13
- const [themeId, setThemeId] = useState<ThemeId>("hedgehog-light");
20
+ const [themeId, setThemeId] = useState<ThemeId>(DEFAULT_THEME_ID);
14
21
 
15
22
  useEffect(() => {
16
23
  const id = readStoredTheme() as ThemeId;
@@ -20,7 +27,9 @@ export function ThemeSwitcher({ className }: ThemeSwitcherProps) {
20
27
 
21
28
  return (
22
29
  <label className={cn("flex min-w-0 items-center gap-2", className)}>
23
- <span className="shrink-0 text-xs font-medium text-ph-mutedtext sm:text-sm">Theme</span>
30
+ <span className="shrink-0 text-xs font-medium text-ph-mutedtext sm:text-sm">
31
+ Theme
32
+ </span>
24
33
  <select
25
34
  className="ph-select max-w-[11rem] !min-h-[2rem] py-1 pl-2.5 pr-8 text-xs sm:max-w-[14rem] sm:text-sm"
26
35
  value={themeId}