Fix form control consistency; add frontend style guide
- QuantityInput unit select: add bg-white (browser defaults to gray without it) and capitalize default option to "Unit" - Shopping list section dropdowns: rename "Uncategorized" → "No section" for consistent title-case phrasing across all default options - src/frontend/STYLE_GUIDE.md: documents form control classes, button variants, text casing rules, icon usage, color tokens, and spacing rhythm so all future UI work stays consistent - CLAUDE.md: link to the style guide so it is always consulted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,8 @@ When adding a feature: create `Features/<Name>/<Name>Endpoints.cs` with a `Map<N
|
|||||||
|
|
||||||
### Frontend — SvelteKit with runes
|
### Frontend — SvelteKit with runes
|
||||||
|
|
||||||
|
**UI consistency:** all form controls, buttons, colors, icons, spacing, and text casing are governed by `src/frontend/STYLE_GUIDE.md`. Read it before adding or modifying any UI component — deviating without a documented reason is a bug.
|
||||||
|
|
||||||
- `svelte.config.js` forces `runes: true` for all non-`node_modules` files. Use Svelte 5 runes (`$state`, `$derived`, `$effect`); do not use legacy reactive `$:` syntax.
|
- `svelte.config.js` forces `runes: true` for all non-`node_modules` files. Use Svelte 5 runes (`$state`, `$derived`, `$effect`); do not use legacy reactive `$:` syntax.
|
||||||
- `src/lib/api.ts` — `api<T>(path, opts)` helper. Stores the JWT in `localStorage` under `token`, attaches `Authorization: Bearer …`, and on 401 it clears the token and `goto('/login')`.
|
- `src/lib/api.ts` — `api<T>(path, opts)` helper. Stores the JWT in `localStorage` under `token`, attaches `Authorization: Bearer …`, and on 401 it clears the token and `goto('/login')`.
|
||||||
- `src/lib/auth.svelte.ts` — runes-based auth state.
|
- `src/lib/auth.svelte.ts` — runes-based auth state.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Frontend Style Guide
|
||||||
|
|
||||||
|
Rules that apply to every component. Deviating from these requires an explicit reason.
|
||||||
|
|
||||||
|
## Form controls
|
||||||
|
|
||||||
|
All interactive inputs, selects, and textareas share one baseline class set so they look identical regardless of type:
|
||||||
|
|
||||||
|
```
|
||||||
|
rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm
|
||||||
|
focus:border-primary focus:outline-none
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variant | When to use | Size note |
|
||||||
|
|---|---|---|
|
||||||
|
| **Standard** (`px-3 py-2`) | Inline rows (qty, unit, section dropdowns, filters) | `text-sm` |
|
||||||
|
| **Large** (`px-3 py-2.5 text-base`) | Full-width, standalone inputs (item name, recipe title) | `text-base` |
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- `bg-white` is **always explicit** on `<select>` — browsers default to gray without it.
|
||||||
|
- Never mix `py-2` and `py-2.5` controls in the same flex row; they will be different heights.
|
||||||
|
- Fixed-width selects (unit dropdown: `w-24`, qty: `w-20`). Unconstrained selects get `max-w-36` so long option text doesn't push other controls off screen.
|
||||||
|
|
||||||
|
## Buttons
|
||||||
|
|
||||||
|
| Style | Class | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| Primary | `rounded-lg bg-primary px-4 py-2 font-semibold text-white` | Main action (Add, Save Changes) |
|
||||||
|
| Secondary | `rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700` | Peer to primary (Cancel, Edit) |
|
||||||
|
| Danger (inline) | `rounded-lg border border-danger px-4 py-2 text-sm text-danger` | Destructive, same visual weight as secondary |
|
||||||
|
| Danger (footer) | `text-sm text-danger hover:underline` | Low-prominence delete at the bottom of a detail page |
|
||||||
|
| Ghost | `text-sm text-gray-500` | Back links, minor navigation |
|
||||||
|
|
||||||
|
Primary and secondary buttons that appear in the same row must use the same `py-*` value.
|
||||||
|
|
||||||
|
## Text casing
|
||||||
|
|
||||||
|
| Context | Rule | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Button labels | Title Case | `Save Changes`, `Add`, `Cancel` |
|
||||||
|
| Placeholder text | Sentence case | `Add an item...`, `Recipe title` |
|
||||||
|
| Select default option | Title Case | `Unit`, `No section` |
|
||||||
|
| Section/group headings | UPPERCASE via `uppercase` CSS class | `UNCATEGORIZED` |
|
||||||
|
| Navigation labels | Title Case | `Lists`, `Recipes` |
|
||||||
|
| Error / hint text | Sentence case | `~ approximate (e.g. to taste)` |
|
||||||
|
|
||||||
|
Never use ALL CAPS in raw text — apply the `uppercase` Tailwind class to let CSS handle it.
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
- Use Heroicons outline style, 16×16 (`h-4 w-4`), `stroke-width="1.75"`.
|
||||||
|
- Always include `aria-hidden="true"` on decorative SVGs; the wrapping button carries the `aria-label`.
|
||||||
|
- Trash icon for destructive remove actions. No other icon for that purpose.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
Defined as CSS custom properties in `app.css`. Never hard-code hex values in component classes.
|
||||||
|
|
||||||
|
| Token | Use |
|
||||||
|
|---|---|
|
||||||
|
| `primary` | Brand green — CTAs, active states, quantity highlights |
|
||||||
|
| `danger` | Destructive actions, error text |
|
||||||
|
| `gray-300` | Default border on all form controls |
|
||||||
|
| `gray-400` | Secondary / hint text |
|
||||||
|
| `gray-500` | Ghost button text, tertiary labels |
|
||||||
|
|
||||||
|
## Spacing rhythm
|
||||||
|
|
||||||
|
- Gap between inline form controls in a row: `gap-2`.
|
||||||
|
- Gap between stacked form sections: `space-y-2` or `space-y-4`.
|
||||||
|
- Card / list item padding: `px-3 py-3` (shopping list) or `px-3 py-2` (compact).
|
||||||
@@ -92,9 +92,9 @@
|
|||||||
value={composite}
|
value={composite}
|
||||||
onchange={onUnitChange}
|
onchange={onUnitChange}
|
||||||
aria-label="Unit"
|
aria-label="Unit"
|
||||||
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
|
class="w-24 rounded-lg border border-gray-300 bg-white px-1 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">unit</option>
|
<option value="">Unit</option>
|
||||||
{#each visible as unit}
|
{#each visible as unit}
|
||||||
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -302,7 +302,7 @@
|
|||||||
class="max-w-36 rounded-lg border border-gray-300 bg-white px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
class="max-w-36 rounded-lg border border-gray-300 bg-white px-2 py-2 text-sm focus:border-primary focus:outline-none"
|
||||||
aria-label="Section"
|
aria-label="Section"
|
||||||
>
|
>
|
||||||
<option value={null}>Uncategorized</option>
|
<option value={null}>No section</option>
|
||||||
{#each sections as section (section.id)}
|
{#each sections as section (section.id)}
|
||||||
<option value={section.id}>{section.name}</option>
|
<option value={section.id}>{section.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
class="shrink-0 rounded border border-gray-200 bg-white px-1 py-0.5 text-xs text-gray-500 focus:border-primary focus:outline-none"
|
class="shrink-0 rounded border border-gray-200 bg-white px-1 py-0.5 text-xs text-gray-500 focus:border-primary focus:outline-none"
|
||||||
aria-label="Section for {item.name}"
|
aria-label="Section for {item.name}"
|
||||||
>
|
>
|
||||||
<option value="">Uncategorized</option>
|
<option value="">No section</option>
|
||||||
{#each sections as section (section.id)}
|
{#each sections as section (section.id)}
|
||||||
<option value={section.id}>{section.name}</option>
|
<option value={section.id}>{section.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user