diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2acfc81..cc113d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
-Nothing yet.
+### Fixed
+- The security note in the ID generator rendered as overlapping fragments. It
+ was a paragraph with `display: flex`, which makes every inline child — both
+ text runs and the inline `` — a separate flex item laid out in a row.
+- Dropdowns had cramped padding and the native arrow overlapped the border at
+ our control height. The native arrow is now suppressed and replaced with a
+ chevron drawn inside the padding box, with room reserved for it.
+- The preset dropdown always displayed "Preset…" regardless of the settings in
+ use. It now shows whichever preset the current options match, derived from
+ those options rather than stored, so it survives a reload and falls back to
+ "Custom" as soon as any field changes.
+
+### Changed
+- The sidebar collapse control moved from the bottom of the tool list to the
+ top, where it is visible without scrolling, and the collapse now animates.
+ Both respect `prefers-reduced-motion`.
## [0.2.2] — 2026-09-24
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index ff2cdce..64d9c3d 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -54,7 +54,11 @@ function ToolLinks({
}
>
- {!collapsed && {tool.title}}
+ {!collapsed && (
+
+ {tool.title}
+
+ )}
))}
@@ -70,35 +74,40 @@ export function Sidebar() {
return (
)
}
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx
index c8aabc2..35ae8d3 100644
--- a/src/components/ui/Select.tsx
+++ b/src/components/ui/Select.tsx
@@ -1,12 +1,25 @@
import type { ReactNode, SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/util/cn'
-export function Select({ className, ...rest }: SelectHTMLAttributes) {
+/**
+ * The native arrow sits outside the padding box and cannot be styled, so it
+ * collides with the border at our control height. We suppress it with
+ * appearance-none and draw our own chevron as a background image, leaving
+ * explicit room for it on the right.
+ */
+const CHEVRON =
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%238b8d98' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E\")"
+
+export function Select({ className, style, ...rest }: SelectHTMLAttributes) {
return (
-
-
- Generated with crypto.getRandomValues and unbiased
- sampling. Your settings are saved; the generated values never are.
+ {/* Deliberately not a flex container: flex would make each inline
+ child -- both text runs and the -- a separate flex item,
+ which is what made this overlap. The icon is inline instead. */}
+
+
+ Generated with{' '}
+
+ crypto.getRandomValues
+ {' '}
+ and unbiased sampling. Your settings are saved; the generated values never are.
diff --git a/src/tools/id-gen/core/generate.test.ts b/src/tools/id-gen/core/generate.test.ts
index ed92fa4..bc0515c 100644
--- a/src/tools/id-gen/core/generate.test.ts
+++ b/src/tools/id-gen/core/generate.test.ts
@@ -9,6 +9,7 @@ import {
PRESETS,
entropyBits,
generateIds,
+ matchingPreset,
resolveAlphabet,
strength,
type IdOptions,
@@ -232,3 +233,27 @@ describe('properties', () => {
expect(counts.size).toBe(16)
})
})
+
+describe('matchingPreset', () => {
+ it('names the preset whose settings the options match', () => {
+ const stripe = PRESETS.find((p) => p.name === 'Stripe secret key')!
+ expect(matchingPreset(opts(stripe.options))).toBe('Stripe secret key')
+ })
+
+ it('returns null once any field is changed', () => {
+ const stripe = PRESETS.find((p) => p.name === 'Stripe secret key')!
+ expect(matchingPreset(opts({ ...stripe.options, length: 25 }))).toBeNull()
+ })
+
+ it('identifies every preset from its own options', () => {
+ for (const preset of PRESETS) {
+ expect(matchingPreset(opts(preset.options)), preset.name).toBe(preset.name)
+ }
+ })
+
+ it('ignores fields the preset does not specify', () => {
+ const stripe = PRESETS.find((p) => p.name === 'Stripe secret key')!
+ // count is not part of that preset, so changing it must not unmatch.
+ expect(matchingPreset(opts({ ...stripe.options, count: 99 }))).toBe('Stripe secret key')
+ })
+})
diff --git a/src/tools/id-gen/core/generate.ts b/src/tools/id-gen/core/generate.ts
index cb63ae4..5549522 100644
--- a/src/tools/id-gen/core/generate.ts
+++ b/src/tools/id-gen/core/generate.ts
@@ -217,3 +217,18 @@ export const PRESETS: readonly Preset[] = [
options: { prefix: '', separator: '', length: 10, alphabet: 'base58', excludeAmbiguous: true },
},
]
+
+/**
+ * Name the preset whose settings the current options match, if any.
+ *
+ * Derived rather than stored: the dropdown then shows the right thing after a
+ * reload, and falls back to "Custom" the moment any field is changed, with no
+ * state to keep in sync.
+ */
+export function matchingPreset(options: IdOptions): string | null {
+ for (const preset of PRESETS) {
+ const keys = Object.keys(preset.options) as (keyof IdOptions)[]
+ if (keys.every((k) => options[k] === preset.options[k])) return preset.name
+ }
+ return null
+}