docs(design): redesign game-folder button as icon + label + dot

The previous design squeezed the full game-directory path into the
top-bar button as truncated `ui-monospace` (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading-ellipsis
truncation rarely showed the meaningful part of the path on real-world
configurations, ate horizontal space the new 3-zone top bar needs for
its actual primary controls, and competed with the filter / search /
sort cluster for attention.

Replace the inline path with an icon + short label + colored status
dot. The full path moves into the tooltip and `aria-label`, where it's
still one mouseover (and screen-reader friendly) away. The button now
communicates the *state* of the configuration at a glance — which is
what users actually need.

Two visual states, both 36 px tall with the same surface as the other
top-bar controls:

- **Set & valid** — label `Game folder`, green dot (`--ok`) with a
  soft glow, default border, tooltip = full path.
- **Not set / invalid** — label `Set game folder`, red dot (`--danger`)
  with a soft glow, a red-tinted border, and a faint red wash on
  hover so the bad state reads as "this is what you need to fix".
  Tooltip = `Please select a game folder`.

"Invalid" (a path is stored but doesn't exist on disk) is collapsed
into the same visual state as "not set" — the user's required action
is identical (open the picker, pick a folder), so a third state
isn't worth the visual budget yet. If we later want to surface a
*last-known* path so the user can re-attach an external drive,
introduce a distinct missing state then.

Implementation notes:

- `DirectoryButton` now takes a single `path: string | null` prop and
  picks state from `!!(path && path.trim())`. Children are
  `Icon.folder`, the label, and an 8 px `.dirbtn-status-dot` sibling
  — the dot is an inline flex sibling, not a corner badge, because
  the button is now wider than tall and a corner pin would feel
  misplaced.
- `.dirbtn` is `inline-flex` with `padding: 0 14px 0 12px`, gap 8 px,
  `white-space: nowrap`, and `flex-shrink: 0`. The `max-width: 360px`
  cap from the path-truncation era is gone — the button is now
  intrinsically sized.
- Dot glow uses `box-shadow: 0 0 6px color-mix(...)` so it still
  reads through the launcher's translucent top-bar background.
- The Tweaks panel grows a dev-only `Game folder set` toggle (under
  the new *Library* section) that flips a `gameFolderSet` flag wired
  into the Launcher, so reviewers can see both states without
  fiddling with real filesystem state. The README explicitly calls
  this out as **dev-only** — production state comes from the
  settings store, not a user-facing toggle.

The README gains a new *Game-folder button* section with the full
spec, a state table, and a rationale paragraph; the "Changes since v2"
list and the interactions list are updated to reflect the new label
and behavior.

Test Plan

- Open `design_reference/SoftLAN Launcher.html` and locate the Tweaks
  panel's *Library → Game folder set* toggle.
  - With the toggle **on**: the top-bar button shows `Game folder`
    with a green dot; hovering the button reveals the full mock path
    in the native tooltip.
  - With the toggle **off**: the label switches to `Set game folder`,
    the dot turns red, the border picks up a red tint, and hovering
    the button reveals `Please select a game folder`.
- Inspect the button with a screen reader / DevTools accessibility
  pane: the `aria-label` should read `Game folder: <path>` when set
  and `Set game folder` when unset.
This commit is contained in:
2026-05-21 18:11:07 +02:00
parent 6e28f736e8
commit b169a05c31
4 changed files with 83 additions and 24 deletions
+38 -2
View File
@@ -40,6 +40,11 @@ The HTML mock includes two chrome variants — **A (single-row)** and **B (two-r
- **Right:** game-folder button + kebab menu. - **Right:** game-folder button + kebab menu.
- Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet. - Below ~1100 px of launcher width (container query), the three zones collapse into a single left-to-right flowing row (no wrap, no centering). Implement via container query on the launcher root; viewport media query is acceptable if your codebase doesn't use container queries yet.
- See "Top bar (variant A)" below for the full spec and rationale. - See "Top bar (variant A)" below for the full spec and rationale.
- **Game-directory button redesigned.** No more inline path display. The button is now an icon + short label + status dot, with the full path moved to the tooltip. Two states:
- **Set & valid:** label `Game folder`, green status dot, tooltip = full configured path.
- **Not set / invalid path:** label `Set game folder`, red status dot, subtle red border, slightly red-tinted hover, tooltip = `Please select a game folder`. "Invalid" (a path is stored but doesn't exist on disk) is treated identically to "not set" — no separate warning state for now.
- Click behavior unchanged — opens the native folder picker via Tauri.
- See "Game-folder button" below for the full spec.
## Changes since v1 ## Changes since v1
@@ -77,7 +82,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
- **Right zone (col 3, flex space-between):** - **Right zone (col 3, flex space-between):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. - **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`.
- **Game directory button** (pinned right of itself, before the kebab) — 36 px button, max-width 360 px. Folder icon, "Game directory" label (600 weight `--t-1`), then the current path in `ui-monospace` 11.5 px `--t-3` truncated with leading ellipsis when long (e.g. `…s/Desktop/eti_games_AFTER_LAN_2025`). - **Game-folder button** (pinned right of itself, before the kebab) — see "Game-folder button" below.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. - **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → game-folder → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet. **Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → game-folder → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
@@ -211,6 +216,37 @@ Persisted settings (write through to local storage / Tauri config):
--- ---
## Game-folder button
The top-bar control that exposes the user's currently-configured game folder (the parent directory under which all per-game subfolders live). Sits in the right zone of the top bar, between the sort menu and the kebab menu. Click anywhere on it → native folder picker via Tauri; on selection, rescan library.
Two visual states, driven by whether `settings.gameFolder` resolves to an accessible directory:
| State | Trigger | Label | Status dot | Border | Tooltip |
|---|---|---|---|---|---|
| **Set & valid** | path is configured and exists on disk | `Game folder` | green (`--ok` `#22c55e`) | default `--bd-1` | full configured path |
| **Not set / invalid** | path is `null`/empty, or path is set but the directory no longer exists | `Set game folder` | red (`--danger` `#ef4444`) | tinted red (`color-mix(in srgb, var(--danger) 35%, var(--bd-1))`) | `Please select a game folder` |
"Invalid" is intentionally collapsed into the same visual state as "not set" — the user's job is identical (open the picker and pick a folder), so we don't differentiate. If we later need a distinct "missing" state (e.g. to show the *last known* path so the user can re-attach an external drive), introduce a third state then; for now, keep it simple.
**Anatomy:** `inline-flex`, `height: 36px`, `padding: 0 14px 0 12px`, `gap: 8px`. `background: var(--bg-2)`, `border-radius: 8px`. Children, left to right:
1. **Folder icon**`Icon.folder` from `components.jsx`, 14×14, `currentColor`.
2. **Label** — 12.5 px / 600, `var(--t-1)`, `white-space: nowrap`, `line-height: 1`. Text content depends on state (see table above).
3. **Status dot**`8×8` px circle, `border-radius: 999px`, `margin-left: 2px`. Background `var(--ok)` (set) or `var(--danger)` (unset). Glow via `box-shadow: 0 0 6px color-mix(in srgb, <dot-color> 70%, transparent)`. The dot is **inline** (a flex sibling next to the label), not corner-pinned over the icon — because the button is now wider than tall, a corner badge would feel misplaced.
**Hover:** border darkens to `--bd-2` (set state) or to `color-mix(in srgb, var(--danger) 55%, var(--bd-2))` with a faint `color-mix(in srgb, var(--danger) 8%, var(--bg-2))` background tint (unset state) so the bad-state hover reads as "this is the thing you need to fix".
**Accessibility:** the `aria-label` carries the full state in words — `"Game folder: <path>"` when set, `"Set game folder"` when unset — so screen readers don't have to interpret the colored dot.
**Why no inline path anymore?** The previous design squeezed the full path into the button as truncated monospace (`…/eti_games_AFTER_LAN_2025`). It rarely showed the meaningful part of the path on real-world configurations, ate horizontal space, and competed with the actual primary controls (filter / search / sort) for the top bar's attention budget. The information is still one mouseover away in the tooltip; on the surface, the dot + short label communicate the *state* of the configuration, which is what users actually need to glance at.
**Data:** the component takes a `path: string | null` prop. `null` (or empty/whitespace string) renders the unset state; any non-empty string renders the set state. In production, derive `path` from your settings store; if you want to additionally validate existence, do the `fs.metadata` check in the store / a hook and pass `null` when the directory is missing.
**Dev preview:** the prototype's Tweaks panel exposes a `Game folder set` toggle (under the *Library* section) that flips the `gameFolderSet` flag wired into the Launcher. This is dev-only — in the real app the state comes from the settings store, **not** from a user-facing toggle. Don't ship it.
---
## Game card ## Game card
The unit element of the library grid. The unit element of the library grid.
@@ -389,7 +425,7 @@ Implement only if you decide variant A doesn't work after building.
- **Click filter tab / segmented pill** → change filter. - **Click filter tab / segmented pill** → change filter.
- **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it. - **Click sort button** → opens dropdown; click an option → re-sort grid; clicking outside the menu closes it.
- **Hover game card** → lift + accent border glow + cover image scale 1.03. - **Hover game card** → lift + accent border glow + cover image scale 1.03.
- **Click "Game directory" button** → open native folder picker via Tauri; on selection, rescan library. - **Click "Game folder" button** → open native folder picker via Tauri; on selection, rescan library. The button itself indicates whether a valid folder is currently configured (green dot + `Game folder`) or not (red dot + `Set game folder`) — see "Game-folder button" above.
- **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes). - **Click "Settings"** in kebab → open Settings dialog. Changes apply live and persist immediately (no Apply button — Done just closes).
- **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design). - **Click "Unpack logs"** in kebab → opens a logs viewer (separate window or modal — out of scope for this design).
- **Click "Refresh library"** in kebab → re-runs the library scan. - **Click "Refresh library"** in kebab → re-runs the library scan.
@@ -37,7 +37,8 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"aspect": "square", "aspect": "square",
"bg": "gradient", "bg": "gradient",
"username": "d", "username": "d",
"language": "en" "language": "en",
"gameFolderSet": true
}/*EDITMODE-END*/; }/*EDITMODE-END*/;
const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444']; const ACCENTS = ['#3b82f6', '#22d3ee', '#a855f7', '#22c55e', '#f59e0b', '#ef4444'];
@@ -113,6 +114,10 @@ function App() {
<TweakRadio label="Cover aspect" value={t.aspect} <TweakRadio label="Cover aspect" value={t.aspect}
options={['box', 'square', 'banner']} options={['box', 'square', 'banner']}
onChange={(v) => setTweak('aspect', v)}/> onChange={(v) => setTweak('aspect', v)}/>
<TweakSection label="Library"/>
<TweakToggle label="Game folder set" value={t.gameFolderSet}
onChange={(v) => setTweak('gameFolderSet', v)}/>
</TweaksPanel> </TweaksPanel>
</React.Fragment> </React.Fragment>
); );
+10 -4
View File
@@ -354,12 +354,18 @@ function StorageMeter({ accent, compact = false }) {
// Directory button (shows path) // Directory button (shows path)
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
function DirectoryButton({ path }) { function DirectoryButton({ path }) {
const short = path.length > 36 ? '…' + path.slice(-34) : path; const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder';
const tooltip = isSet ? path : 'Please select a game folder';
return ( return (
<button className="dirbtn" title={path}> <button
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
>
<Icon.folder/> <Icon.folder/>
<span className="dirbtn-label">Game directory</span> <span className="dirbtn-label">{label}</span>
<span className="dirbtn-path">{short}</span> <span className="dirbtn-status-dot" aria-hidden="true"/>
</button> </button>
); );
} }
+29 -17
View File
@@ -359,29 +359,41 @@
/* ─── Directory button ─── */ /* ─── Directory button ─── */
.dirbtn { .dirbtn {
position: relative;
display: inline-flex; align-items: center; gap: 8px; display: inline-flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px; height: 36px; padding: 0 14px 0 12px;
background: var(--bg-2); background: var(--bg-2);
border: 1px solid var(--bd-1); border: 1px solid var(--bd-1);
border-radius: 8px; border-radius: 8px;
color: var(--t-2); color: var(--t-1);
font: inherit; font-size: 12.5px; font: inherit; font-size: 12.5px; font-weight: 600;
cursor: pointer; cursor: pointer;
max-width: 360px; transition: border-color .15s, color .15s, background .15s;
transition: border-color .15s, color .15s; flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
}
.dirbtn:hover { border-color: var(--bd-2); color: var(--t-1); }
.dirbtn-label { color: var(--t-1); font-weight: 600; flex-shrink: 0; }
.dirbtn-path {
color: var(--t-3);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; }
text-overflow: ellipsis; .dirbtn:hover { border-color: var(--bd-2); }
min-width: 0; .dirbtn-label { line-height: 1; }
.dirbtn-status-dot {
width: 8px; height: 8px;
margin-left: 2px;
border-radius: 999px;
flex-shrink: 0;
}
.dirbtn-set .dirbtn-status-dot {
background: var(--ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 70%, transparent);
}
.dirbtn-unset .dirbtn-status-dot {
background: var(--danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 70%, transparent);
}
.dirbtn-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
}
.dirbtn-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
} }
/* ─── Kebab menu ─── */ /* ─── Kebab menu ─── */