Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<code>` — 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

Expand Down
55 changes: 32 additions & 23 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ function ToolLinks({
}
>
<tool.icon size={15} aria-hidden className="shrink-0" />
{!collapsed && <span className="truncate">{tool.title}</span>}
{!collapsed && (
<span className="truncate opacity-0 animate-[fadeIn_150ms_ease-out_forwards]">
{tool.title}
</span>
)}
</NavLink>
))}
</div>
Expand All @@ -70,35 +74,40 @@ export function Sidebar() {
return (
<aside
className={cn(
'hidden shrink-0 flex-col border-r border-border bg-surface md:flex',
'hidden shrink-0 flex-col overflow-hidden border-r border-border bg-surface md:flex',
// Animating width needs overflow-hidden above, or the labels spill
// across the main pane mid-transition. Respects reduced-motion.
'transition-[width] duration-200 ease-out motion-reduce:transition-none',
sidebarCollapsed ? 'w-[52px]' : 'w-[220px]',
)}
>
<div className="min-h-0 flex-1 overflow-y-auto scroll-thin">
<ToolLinks collapsed={sidebarCollapsed} />
</div>

<button
type="button"
onClick={toggleSidebar}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!sidebarCollapsed}
{/* At the top, where it is actually visible, and aligned with the header
row rather than buried under the tool list. */}
<div
className={cn(
'flex min-h-9 shrink-0 items-center gap-2 border-t border-border px-3 text-[12px]',
'text-faint hover:bg-surface-2 hover:text-fg',
sidebarCollapsed && 'justify-center px-0',
'flex h-9 shrink-0 items-center border-b border-border',
sidebarCollapsed ? 'justify-center px-0' : 'justify-end px-2',
)}
>
{sidebarCollapsed ? (
<PanelLeftOpen size={15} aria-hidden />
) : (
<>
<button
type="button"
onClick={toggleSidebar}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!sidebarCollapsed}
className="flex size-7 items-center justify-center rounded-[4px] text-faint hover:bg-surface-2 hover:text-fg"
>
{sidebarCollapsed ? (
<PanelLeftOpen size={15} aria-hidden />
) : (
<PanelLeftClose size={15} aria-hidden />
<span>Collapse</span>
</>
)}
</button>
)}
</button>
</div>

<div className="min-h-0 flex-1 overflow-y-auto scroll-thin">
<ToolLinks collapsed={sidebarCollapsed} />
</div>
</aside>
)
}
Expand Down
20 changes: 16 additions & 4 deletions src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import type { ReactNode, SelectHTMLAttributes } from 'react'
import { cn } from '@/lib/util/cn'

export function Select({ className, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
/**
* 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<HTMLSelectElement>) {
return (
<select
style={{ backgroundImage: CHEVRON, ...style }}
className={cn(
'min-h-11 shrink-0 rounded-[4px] border border-border bg-bg px-1.5 text-fg',
'hover:border-border-strong md:min-h-7',
'min-h-11 shrink-0 appearance-none rounded-[4px] border border-border bg-bg',
// Room for the chevron on the right, comfortable text inset on the left.
'py-1 pl-2.5 pr-8 text-fg',
'bg-[length:14px_14px] bg-[right_8px_center] bg-no-repeat',
'hover:border-border-strong md:min-h-7 md:py-0',
className,
)}
{...rest}
Expand All @@ -29,7 +42,6 @@ export function Toggle({
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
// 14px box, inverse fill when checked, per the design's checkbox spec.
className="size-3.5 appearance-none rounded-[3px] border border-border bg-bg
checked:border-fg checked:bg-fg
checked:bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22><path d=%22M3.5 8.5l3 3 6-6%22 fill=%22none%22 stroke=%22%23121316%22 stroke-width=%222.5%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/></svg>')]
Expand Down
18 changes: 18 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,21 @@ textarea:focus-visible,
.cm-mergeView .cm-editor {
height: 100%;
}

/* Labels fade in as the sidebar expands, so they do not appear fully formed
before the width animation has finished. */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@media (prefers-reduced-motion: reduce) {
[class*='animate-'] {
animation: none !important;
opacity: 1 !important;
}
}
23 changes: 17 additions & 6 deletions src/tools/id-gen/IdGenTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { copyText } from '@/lib/util/clipboard'
import { formatCount } from '@/lib/util/bytes'
import {
DEFAULT_OPTIONS,
matchingPreset,
MAX_COUNT,
MAX_LENGTH,
PRESETS,
Expand Down Expand Up @@ -99,14 +100,14 @@ export default function IdGenTool() {
actions={
<>
<Select
value=""
value={matchingPreset(state) ?? ''}
onChange={(e) => {
const preset = PRESETS.find((p) => p.name === e.target.value)
if (preset) applyOptions((p) => ({ ...p, ...preset.options }))
}}
title="Load a preset"
>
<option value="">Preset…</option>
<option value="">Custom</option>
{PRESETS.map((p) => (
<option key={p.name} value={p.name} title={p.description}>
{p.name}
Expand Down Expand Up @@ -249,10 +250,20 @@ export default function IdGenTool() {
</p>
</div>

<p className="mt-3 flex items-start gap-1.5 text-[11px] leading-snug text-faint">
<ShieldCheck size={13} className="mt-px shrink-0 text-add" aria-hidden />
Generated with <code className="font-mono">crypto.getRandomValues</code> 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 <code> -- a separate flex item,
which is what made this overlap. The icon is inline instead. */}
<p className="mt-3 text-[11px] leading-[1.6] text-faint">
<ShieldCheck
size={12}
className="mr-1 inline-block shrink-0 align-[-1px] text-add"
aria-hidden
/>
Generated with{' '}
<code className="rounded-[3px] bg-surface-2 px-1 py-px font-mono text-[10px] text-muted">
crypto.getRandomValues
</code>{' '}
and unbiased sampling. Your settings are saved; the generated values never are.
</p>
</div>

Expand Down
25 changes: 25 additions & 0 deletions src/tools/id-gen/core/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
PRESETS,
entropyBits,
generateIds,
matchingPreset,
resolveAlphabet,
strength,
type IdOptions,
Expand Down Expand Up @@ -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')
})
})
15 changes: 15 additions & 0 deletions src/tools/id-gen/core/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading