Replace ad-hoc Tailwind strings with design tokens

Define .field, .field-lg, .select, .select-lg, .btn-primary,
.btn-secondary, and .btn-danger-link in app.css as @layer components.
The .select tokens use appearance-none + an inline SVG chevron so
all dropdowns look identical to text inputs (no OS-level gray gradient).

Apply tokens across every in-app form: QuantityInput, shopping list
add form and item rows, recipe new/edit pages, recipe detail, stores,
and lists create form. Drop the STYLE_GUIDE.md doc in favour of the
tokens themselves as the source of truth, and update CLAUDE.md to
document the token names and usage rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-14 20:25:57 -05:00
parent a398f8cf44
commit bf01063c3a
10 changed files with 90 additions and 102 deletions
+6 -1
View File
@@ -88,7 +88,12 @@ 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. **UI tokens:** form controls, buttons, and select elements use named CSS component classes defined in `src/frontend/src/app.css`. Always use these instead of raw Tailwind strings:
- Inputs / textareas: `.field` (standard, `py-2 text-sm`) or `.field-lg` (prominent, `py-2.5 text-base`)
- Selects: `.select` or `.select-lg` — these add `appearance-none` + a consistent chevron so dropdowns look identical to text inputs
- Buttons: `.btn-primary`, `.btn-secondary`, `.btn-danger-link`
- Width, margin, and flex utilities are added alongside the token class (e.g. `class="field w-full"`)
- Never hardcode the border, background, padding, or focus ring on a new control — always start from a token
- `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')`.
-71
View File
@@ -1,71 +0,0 @@
# 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).
+54
View File
@@ -21,3 +21,57 @@ body {
button { button {
@apply cursor-pointer; @apply cursor-pointer;
} }
/* ── Design tokens ── */
/* Use .field for standard single-line text inputs and textareas inside the app shell. */
@layer components {
.field {
@apply rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none;
}
/* Larger variant for prominent standalone inputs (recipe title, item name search bar). */
.field-lg {
@apply rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-base focus:border-primary focus:outline-none;
}
/*
* Use .select / .select-lg for <select> elements. Removes native OS appearance
* (which can add a grey gradient even when bg-white is set) and injects a
* consistent chevron so all dropdowns look identical to text inputs.
* Note: @apply cannot compose other component classes in Tailwind v4, so
* the base field styles are duplicated here.
*/
.select {
@apply appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.4rem center;
background-repeat: no-repeat;
background-size: 1.1rem 1.1rem;
padding-right: 1.75rem;
}
.select-lg {
@apply appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-base focus:border-primary focus:outline-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.4rem center;
background-repeat: no-repeat;
background-size: 1.1rem 1.1rem;
padding-right: 1.75rem;
}
/* Standard action button — primary green. */
.btn-primary {
@apply rounded-lg bg-primary px-4 py-2 font-semibold text-white disabled:opacity-50;
}
/* Outlined button — secondary / cancel. Same height as btn-primary. */
.btn-secondary {
@apply rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 disabled:opacity-50;
}
/* Low-prominence destructive link — use at the bottom of detail pages. */
.btn-danger-link {
@apply text-sm text-danger hover:underline;
}
}
+2 -2
View File
@@ -86,13 +86,13 @@
oninput={onQuantityInput} oninput={onQuantityInput}
placeholder="Qty" placeholder="Qty"
aria-label={ariaLabel} aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none" class="field w-20 px-2"
/> />
<select <select
value={composite} value={composite}
onchange={onUnitChange} onchange={onUnitChange}
aria-label="Unit" aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 bg-white px-1 py-2 text-sm focus:border-primary focus:outline-none" class="select w-24 px-1"
> >
<option value="">Unit</option> <option value="">Unit</option>
{#each visible as unit} {#each visible as unit}
+3 -3
View File
@@ -89,12 +89,12 @@
bind:value={newName} bind:value={newName}
placeholder="List name" placeholder="List name"
required required
class="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none" class="field mb-3 w-full"
/> />
<select <select
bind:value={newStoreId} bind:value={newStoreId}
required required
class="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none" class="select mb-3 w-full"
> >
<option value={null} disabled>Select store</option> <option value={null} disabled>Select store</option>
{#each stores as store} {#each stores as store}
@@ -103,7 +103,7 @@
</select> </select>
<button <button
type="submit" type="submit"
class="w-full rounded-lg bg-primary py-2 font-semibold text-white" class="btn-primary w-full"
> >
Create Create
</button> </button>
@@ -278,7 +278,7 @@
bind:value={newItemName} bind:value={newItemName}
placeholder="Add an item..." placeholder="Add an item..."
ariaLabel="Item name" ariaLabel="Item name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none" inputClass="field-lg w-full"
onsubmit={addItem} onsubmit={addItem}
onProductChange={onItemProductChange} onProductChange={onItemProductChange}
/> />
@@ -288,7 +288,7 @@
type="text" type="text"
bind:value={newItemQuantityNote} bind:value={newItemQuantityNote}
placeholder="e.g. to taste" placeholder="e.g. to taste"
class="w-36 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none" class="field w-36 px-2"
/> />
{:else} {:else}
<QuantityInput <QuantityInput
@@ -299,7 +299,7 @@
{#if sections.length > 0} {#if sections.length > 0}
<select <select
bind:value={newItemSectionId} bind:value={newItemSectionId}
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="select max-w-36 px-2"
aria-label="Section" aria-label="Section"
> >
<option value={null}>No section</option> <option value={null}>No section</option>
@@ -310,7 +310,7 @@
{/if} {/if}
<button <button
type="submit" type="submit"
class="rounded-lg bg-primary px-4 py-2 font-semibold text-white" class="btn-primary"
> >
Add Add
</button> </button>
@@ -353,7 +353,7 @@
<select <select
value={item.sectionId ?? ''} value={item.sectionId ?? ''}
onchange={(e) => setItemSection(item.id, (e.currentTarget.value === '' ? null : Number(e.currentTarget.value)))} onchange={(e) => setItemSection(item.id, (e.currentTarget.value === '' ? null : Number(e.currentTarget.value)))}
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="select shrink-0 rounded-md border-gray-200 px-1 py-0.5 text-xs text-gray-500"
aria-label="Section for {item.name}" aria-label="Section for {item.name}"
> >
<option value="">No section</option> <option value="">No section</option>
@@ -97,13 +97,13 @@
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
<button <button
onclick={() => (showAddToList = !showAddToList)} onclick={() => (showAddToList = !showAddToList)}
class="flex-1 rounded-lg bg-primary py-2.5 font-semibold text-white" class="btn-primary flex-1 py-2.5"
> >
Add to list Add to list
</button> </button>
<button <button
onclick={() => goto(`/recipes/${recipeId}/edit`)} onclick={() => goto(`/recipes/${recipeId}/edit`)}
class="rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-700" class="btn-secondary py-2.5"
> >
Edit Edit
</button> </button>
@@ -160,7 +160,7 @@
<div class="mt-10 border-t border-gray-100 pt-4"> <div class="mt-10 border-t border-gray-100 pt-4">
<button <button
onclick={deleteRecipe} onclick={deleteRecipe}
class="text-sm text-danger hover:underline" class="btn-danger-link"
> >
Delete this recipe Delete this recipe
</button> </button>
@@ -209,14 +209,14 @@
bind:value={title} bind:value={title}
placeholder="Recipe title" placeholder="Recipe title"
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none" class="field-lg w-full"
/> />
<textarea <textarea
bind:value={description} bind:value={description}
placeholder="Short description (optional)" placeholder="Short description (optional)"
rows={2} rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="field-lg w-full"
></textarea> ></textarea>
<div> <div>
@@ -227,7 +227,7 @@ placeholder="Short description (optional)"
bind:value={servings} bind:value={servings}
min={1} min={1}
placeholder="e.g. 4" placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none" class="field mt-1 block w-24"
/> />
</label> </label>
</div> </div>
@@ -242,7 +242,7 @@ placeholder="Short description (optional)"
type="text" type="text"
bind:value={ingredient.quantityNote} bind:value={ingredient.quantityNote}
placeholder="e.g. to taste" placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none" class="field w-44 px-2"
/> />
{:else} {:else}
<QuantityInput <QuantityInput
@@ -256,7 +256,7 @@ placeholder="Short description (optional)"
initialSelectedName={ingredient.initialName} initialSelectedName={ingredient.initialName}
placeholder="Ingredient name" placeholder="Ingredient name"
ariaLabel="Ingredient name" ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none" inputClass="field w-full"
onProductChange={(p) => onIngredientProductChange(idx, p)} onProductChange={(p) => onIngredientProductChange(idx, p)}
/> />
</div> </div>
@@ -293,7 +293,7 @@ placeholder="Short description (optional)"
bind:value={instructions} bind:value={instructions}
placeholder="Instructions (optional)" placeholder="Instructions (optional)"
rows={6} rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="field-lg w-full"
></textarea> ></textarea>
{#if saveError} {#if saveError}
@@ -305,14 +305,14 @@ placeholder="Short description (optional)"
type="button" type="button"
onclick={() => goto(`/recipes/${recipeId}`)} onclick={() => goto(`/recipes/${recipeId}`)}
disabled={saving} disabled={saving}
class="flex-1 rounded-lg border border-gray-300 py-3 font-semibold text-gray-700 disabled:opacity-50" class="btn-secondary flex-1 py-3"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
class="flex-[2] rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50" class="btn-primary flex-[2] py-3"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</button> </button>
@@ -112,14 +112,14 @@
bind:value={title} bind:value={title}
placeholder="Recipe title" placeholder="Recipe title"
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none" class="field-lg w-full"
/> />
<textarea <textarea
bind:value={description} bind:value={description}
placeholder="Short description (optional)" placeholder="Short description (optional)"
rows={2} rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="field-lg w-full"
></textarea> ></textarea>
<div> <div>
@@ -130,7 +130,7 @@
bind:value={servings} bind:value={servings}
min={1} min={1}
placeholder="e.g. 4" placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none" class="field mt-1 block w-24"
/> />
</label> </label>
</div> </div>
@@ -145,7 +145,7 @@
type="text" type="text"
bind:value={ingredient.quantityNote} bind:value={ingredient.quantityNote}
placeholder="e.g. to taste" placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none" class="field w-44 px-2"
/> />
{:else} {:else}
<QuantityInput <QuantityInput
@@ -158,7 +158,7 @@
bind:value={ingredient.name} bind:value={ingredient.name}
placeholder="Ingredient name" placeholder="Ingredient name"
ariaLabel="Ingredient name" ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none" inputClass="field w-full"
onProductChange={(p) => onIngredientProductChange(idx, p)} onProductChange={(p) => onIngredientProductChange(idx, p)}
/> />
</div> </div>
@@ -195,13 +195,13 @@
bind:value={instructions} bind:value={instructions}
placeholder="Instructions (optional)" placeholder="Instructions (optional)"
rows={6} rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="field-lg w-full"
></textarea> ></textarea>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50" class="btn-primary w-full py-3"
> >
{saving ? 'Saving...' : 'Save Recipe'} {saving ? 'Saving...' : 'Save Recipe'}
</button> </button>
+2 -2
View File
@@ -160,9 +160,9 @@
bind:value={newName} bind:value={newName}
placeholder="New store name" placeholder="New store name"
required required
class="flex-1 rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="field flex-1 py-2.5"
/> />
<button type="submit" class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"> <button type="submit" class="btn-primary py-2.5">
Add Add
</button> </button>
</form> </form>