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:
@@ -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).
|
||||
@@ -21,3 +21,57 @@ body {
|
||||
button {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,13 +86,13 @@
|
||||
oninput={onQuantityInput}
|
||||
placeholder="Qty"
|
||||
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
|
||||
value={composite}
|
||||
onchange={onUnitChange}
|
||||
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>
|
||||
{#each visible as unit}
|
||||
|
||||
@@ -89,12 +89,12 @@
|
||||
bind:value={newName}
|
||||
placeholder="List name"
|
||||
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
|
||||
bind:value={newStoreId}
|
||||
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>
|
||||
{#each stores as store}
|
||||
@@ -103,7 +103,7 @@
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary py-2 font-semibold text-white"
|
||||
class="btn-primary w-full"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
bind:value={newItemName}
|
||||
placeholder="Add an item..."
|
||||
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}
|
||||
onProductChange={onItemProductChange}
|
||||
/>
|
||||
@@ -288,7 +288,7 @@
|
||||
type="text"
|
||||
bind:value={newItemQuantityNote}
|
||||
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}
|
||||
<QuantityInput
|
||||
@@ -299,7 +299,7 @@
|
||||
{#if sections.length > 0}
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value={null}>No section</option>
|
||||
@@ -310,7 +310,7 @@
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary px-4 py-2 font-semibold text-white"
|
||||
class="btn-primary"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@@ -353,7 +353,7 @@
|
||||
<select
|
||||
value={item.sectionId ?? ''}
|
||||
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}"
|
||||
>
|
||||
<option value="">No section</option>
|
||||
|
||||
@@ -97,13 +97,13 @@
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="mt-10 border-t border-gray-100 pt-4">
|
||||
<button
|
||||
onclick={deleteRecipe}
|
||||
class="text-sm text-danger hover:underline"
|
||||
class="btn-danger-link"
|
||||
>
|
||||
Delete this recipe
|
||||
</button>
|
||||
|
||||
@@ -209,14 +209,14 @@
|
||||
bind:value={title}
|
||||
placeholder="Recipe title"
|
||||
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
|
||||
bind:value={description}
|
||||
placeholder="Short description (optional)"
|
||||
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>
|
||||
|
||||
<div>
|
||||
@@ -227,7 +227,7 @@ placeholder="Short description (optional)"
|
||||
bind:value={servings}
|
||||
min={1}
|
||||
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>
|
||||
</div>
|
||||
@@ -242,7 +242,7 @@ placeholder="Short description (optional)"
|
||||
type="text"
|
||||
bind:value={ingredient.quantityNote}
|
||||
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}
|
||||
<QuantityInput
|
||||
@@ -256,7 +256,7 @@ placeholder="Short description (optional)"
|
||||
initialSelectedName={ingredient.initialName}
|
||||
placeholder="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)}
|
||||
/>
|
||||
</div>
|
||||
@@ -293,7 +293,7 @@ placeholder="Short description (optional)"
|
||||
bind:value={instructions}
|
||||
placeholder="Instructions (optional)"
|
||||
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>
|
||||
|
||||
{#if saveError}
|
||||
@@ -305,14 +305,14 @@ placeholder="Short description (optional)"
|
||||
type="button"
|
||||
onclick={() => goto(`/recipes/${recipeId}`)}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -112,14 +112,14 @@
|
||||
bind:value={title}
|
||||
placeholder="Recipe title"
|
||||
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
|
||||
bind:value={description}
|
||||
placeholder="Short description (optional)"
|
||||
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>
|
||||
|
||||
<div>
|
||||
@@ -130,7 +130,7 @@
|
||||
bind:value={servings}
|
||||
min={1}
|
||||
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>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@
|
||||
type="text"
|
||||
bind:value={ingredient.quantityNote}
|
||||
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}
|
||||
<QuantityInput
|
||||
@@ -158,7 +158,7 @@
|
||||
bind:value={ingredient.name}
|
||||
placeholder="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)}
|
||||
/>
|
||||
</div>
|
||||
@@ -195,13 +195,13 @@
|
||||
bind:value={instructions}
|
||||
placeholder="Instructions (optional)"
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
|
||||
@@ -160,9 +160,9 @@
|
||||
bind:value={newName}
|
||||
placeholder="New store name"
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user