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
82 changes: 82 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,88 @@ The site is **public by default**. Authentication is optional and unlocks additi
@endauth
```

## Form Component Standards

**Raw HTML form elements are forbidden in Blade templates.** Every form control must use the corresponding Blade component so visual changes can be made in one place.

### Available components

| Element | Component | Notes |
|---|---|---|
| `<input type="text/number/…">` | `<x-input>` | Accepts all input attributes; `type` prop defaults to `'text'` |
| `<input type="radio">` + label | `<x-radio>` | Wraps input in a label — pass text in the slot |
| `<input type="checkbox">` + label | `<x-checkbox>` | Wraps input in a label — pass text in the slot |
| `<select>` | `<x-select>` | Accepts `disabled`; pass `class="w-full"` to stretch |
| `<button>` | `<x-button>` | See variants and sizes below |
| Form field label | `<x-input-label>` | Use `compact` prop for xs/uppercase section labels |
| Field error message | `<x-input-error>` | |
| Search input with icon | `<x-search-input>` | |

### `<x-button>` variants and sizes

```blade
{{-- Variants --}}
<x-button variant="primary">Save</x-button> {{-- solid accent, default --}}
<x-button variant="secondary">Cancel</x-button> {{-- bordered, surface-raised --}}
<x-button variant="danger">Delete</x-button> {{-- solid red --}}
<x-button variant="success">Confirm</x-button> {{-- solid green --}}
<x-button variant="ghost">Close</x-button> {{-- transparent + hover bg --}}
<x-button variant="link">Reset map</x-button> {{-- accent text, no shape --}}
<x-button variant="muted-link">← Back</x-button> {{-- muted text, no shape --}}

{{-- Sizes (ignored by link / muted-link) --}}
<x-button size="sm">+ Save</x-button> {{-- px-2.5 py-1 text-xs --}}
<x-button size="md">Search</x-button> {{-- px-4 py-2 text-sm (default) --}}
<x-button size="lg">Submit</x-button> {{-- px-5 py-2.5 text-base --}}

{{-- link/muted-link inherit font size from context; pass class="text-xs" when needed --}}
<x-button variant="link" class="text-xs" @click="...">Reset map</x-button>
```

### `<x-input-label>` compact variant

```blade
{{-- Standard form field label (text-sm text-text) --}}
<x-input-label for="email" class="mb-1.5">Email</x-input-label>

{{-- Compact section label (text-xs uppercase muted) — for map panel headers, etc. --}}
<x-input-label for="state" compact class="mb-1.5">State</x-input-label>
```

### Documented exceptions where raw `<button>` is acceptable

These patterns cannot cleanly be expressed as `<x-button>` without compromising layout:

- **Table sort buttons** — `<button>` inside `<th>` elements; structural table controls with flex-aligned sort arrows
- **Interactive card-row buttons** — full-width `w-full text-left` buttons styled as list item rows (e.g. flood alert list rows)
- **Icon-only action buttons** — `p-1` square buttons containing only an SVG icon (e.g. dashboard delete/remove buttons)

Use `<a>` (not `<x-button>`) for links that navigate — `<x-button>` always renders a `<button>` element.

### Alpine bindings on Blade components

When passing Alpine reactive bindings to a Blade component, use the full `x-bind:attr` form — **never the `:attr` shorthand**. Blade intercepts `:attr` and evaluates it as PHP, which throws "Undefined constant" for Alpine JS variables.

```blade
{{-- Wrong — Blade evaluates "unit === 'km'" as PHP --}}
<x-radio :checked="unit === 'km'">Kilometers</x-radio>

{{-- Correct — Blade ignores x-bind:*, Alpine picks it up --}}
<x-radio x-bind:checked="unit === 'km'">Kilometers</x-radio>
```

This applies to any Alpine binding (`:disabled`, `:class`, `:value`, etc.) passed as a Blade component attribute.

Similarly, **never use `@disabled(expr)` inside a Blade component tag** — it is compiled as a PHP control structure, which corrupts the component attribute list and causes parse errors. Use `:disabled="$phpVar"` instead (valid for PHP/Livewire variables):

```blade
{{-- Wrong — @disabled inside a component tag breaks compilation --}}
<x-button @disabled($listPage <= 1)>...</x-button>

{{-- Correct — :disabled with a PHP expression --}}
<x-button :disabled="$listPage <= 1">...</x-button>
```

## Documentation

Whenever a migration is created or modified, or a model is added or changed, update `docs/data-model.md` to reflect the current schema — columns, types, relationships, and any index or constraint changes.
Expand Down
10 changes: 1 addition & 9 deletions resources/views/auth/login.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,7 @@
<x-input-error id="password-error" :messages="$errors->get('password')" role="alert" />
</div>

<div class="flex items-center gap-2">
<input
id="remember"
type="checkbox"
name="remember"
class="rounded border-border text-accent focus-visible:ring-[var(--color-border-focus)]"
>
<label for="remember" class="text-sm text-muted">Remember me</label>
</div>
<x-checkbox name="remember">Remember me</x-checkbox>

<x-button type="submit" variant="primary" class="w-full">
Sign in
Expand Down
57 changes: 47 additions & 10 deletions resources/views/components/button.blade.php
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
{{--
Themed button with multiple visual variants.
Themed button with multiple visual variants and sizes.

Props:
variant — 'primary' | 'secondary' | 'danger' | 'success' | 'ghost' (default: 'primary')
variant — 'primary' | 'secondary' | 'danger' | 'success' | 'ghost'
| 'link' | 'muted-link'
(default: 'primary')
size — 'sm' | 'md' | 'lg' (default: 'md')
Ignored by 'link' and 'muted-link' — those variants are
shape-less and inherit font size from context.
type — button type attribute (default: 'button')

Passes all other attributes through to the <button> element.

Variant guide:
primary — solid accent fill; primary call-to-action
secondary — bordered, surface-raised; secondary actions
danger — solid red fill; destructive actions
success — solid green fill; confirmations
ghost — transparent with hover bg; subtle actions inside panels
link — accent-coloured text, no shape; inline text links
muted-link — muted-coloured text, no shape; dismiss / back / close links

Documented exceptions where raw <button> is acceptable:
- Table sort buttons inside <th> elements (structural table controls)
- Full-width interactive card rows (styled list items, e.g. flood alert rows)
- Icon-only action buttons (e.g. delete/close with p-1 padding and SVG icon)
— these have specific sizing constraints that don't map to named sizes
Use <a> (not <x-button>) for link-styled navigation that changes the URL.
--}}
@props(['variant' => 'primary', 'type' => 'button'])
@props(['variant' => 'primary', 'type' => 'button', 'size' => 'md'])

@php
$base = 'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50';
// Base: layout, typography weight, transitions, focus ring, disabled state.
// No padding or border-radius here — those live in $sizes so link/muted-link
// variants remain shape-less and flow inline with surrounding text.
$base = 'inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50';

$sizes = [
'sm' => 'rounded-md px-2.5 py-1 text-xs',
'md' => 'rounded-md px-4 py-2 text-sm',
'lg' => 'rounded-md px-5 py-2.5 text-base',
];

$variants = [
'primary' => 'bg-accent text-[var(--color-text-on-accent)] hover:bg-accent-hover',
'secondary' => 'border border-border bg-surface-raised text-text hover:bg-surface-hover',
'danger' => 'bg-[var(--color-danger)] text-white hover:bg-[var(--color-danger-hover)]',
'success' => 'bg-[var(--color-success)] text-white hover:bg-[var(--color-success-hover)]',
'ghost' => 'text-muted hover:bg-surface-hover hover:text-text',
'primary' => 'bg-accent text-[var(--color-text-on-accent)] hover:bg-accent-hover',
'secondary' => 'border border-border bg-surface-raised text-text hover:bg-surface-hover',
'danger' => 'bg-[var(--color-danger)] text-white hover:bg-[var(--color-danger-hover)]',
'success' => 'bg-[var(--color-success)] text-white hover:bg-[var(--color-success-hover)]',
'ghost' => 'text-muted hover:bg-surface-hover hover:text-text',
'link' => 'text-accent hover:underline',
'muted-link' => 'text-muted hover:text-text',
];

$classes = $base . ' ' . ($variants[$variant] ?? $variants['primary']);
// link and muted-link are shape-less — they carry no padding or border-radius.
$shapeless = in_array($variant, ['link', 'muted-link'], true);
$sizeClasses = $shapeless ? '' : ($sizes[$size] ?? $sizes['md']);
$variantClasses = $variants[$variant] ?? $variants['primary'];

$classes = trim($base . ' ' . $sizeClasses . ' ' . $variantClasses);
@endphp

<button type="{{ $type }}" {{ $attributes->merge(['class' => $classes]) }}>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">Log out</button>
<x-button type="submit" variant="secondary">Log out</x-button>
</form>
</x-layouts.guest>
48 changes: 19 additions & 29 deletions resources/views/livewire/hydro/flood-alerts.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,12 @@

{{-- State selector for the map panel --}}
<div class="w-52 shrink-0">
<label for="flood-state" class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted">
Map state
</label>
<select
id="flood-state"
wire:model.live="stateCd"
class="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text focus:border-accent focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/20"
>
<x-input-label for="flood-state" compact class="mb-1.5">Map state</x-input-label>
<x-select id="flood-state" wire:model.live="stateCd" class="w-full">
@foreach (\App\Livewire\Hydro\FloodAlerts::US_STATES as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</select>
</x-select>
</div>
</div>

Expand All @@ -60,13 +54,9 @@ class="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-
{{-- Detail panel --}}
<div class="flex-1 overflow-y-auto rounded-xl border border-border bg-surface p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<button
type="button"
wire:click="$set('selectedAlertId', null)"
class="flex items-center gap-1 text-xs text-muted hover:text-text focus:outline-none"
>
<x-button variant="muted-link" class="text-xs" wire:click="$set('selectedAlertId', null)">
← Back to list
</button>
</x-button>
</div>

<div class="mb-4">
Expand Down Expand Up @@ -167,23 +157,23 @@ class="mt-0.5 inline-block shrink-0 rounded-full px-2 py-0.5 text-xs font-semibo
{{-- Pagination --}}
@if ($totalPages > 1)
<div class="mt-3 flex items-center justify-between text-xs text-muted">
<button
type="button"
<x-button
variant="link"
class="text-xs"
wire:click="previousPage"
@class(['text-accent hover:underline focus:outline-none' => $listPage > 1, 'cursor-not-allowed opacity-40' => $listPage <= 1])
@disabled($listPage <= 1)
:disabled="$listPage <= 1"
>
← Previous
</button>
</x-button>
<span>{{ $listPage }} / {{ $totalPages }} ({{ $totalAlerts }} alerts)</span>
<button
type="button"
<x-button
variant="link"
class="text-xs"
wire:click="nextPage"
@class(['text-accent hover:underline focus:outline-none' => $listPage < $totalPages, 'cursor-not-allowed opacity-40' => $listPage >= $totalPages])
@disabled($listPage >= $totalPages)
:disabled="$listPage >= $totalPages"
>
Next →
</button>
</x-button>
</div>
@else
<p class="mt-2 text-xs text-muted">{{ $totalAlerts }} alert{{ $totalAlerts !== 1 ? 's' : '' }} nationally</p>
Expand Down Expand Up @@ -214,13 +204,13 @@ class="mt-0.5 inline-block shrink-0 rounded-full px-2 py-0.5 text-xs font-semibo
{{ count($mapAlerts ?? []) }} alert{{ count($mapAlerts ?? []) !== 1 ? 's' : '' }}
in {{ \App\Livewire\Hydro\FloodAlerts::US_STATES[$stateCd] ?? strtoupper($stateCd) }}
</span>
<button
type="button"
class="cursor-pointer text-accent hover:underline focus:outline-none"
<x-button
variant="link"
class="text-xs"
@click="window.dispatchEvent(new CustomEvent('flood-alerts-map-reset'))"
>
Reset map
</button>
</x-button>
</div>

</div>
Expand Down
44 changes: 13 additions & 31 deletions resources/views/livewire/hydro/stream-gauge.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,12 @@
</div>

<div class="w-44">
<label for="stream-state" class="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted">
State
</label>
<select
id="stream-state"
wire:model.live="stateCd"
class="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text focus:border-accent focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/20"
>
<x-input-label for="stream-state" compact class="mb-1.5">State</x-input-label>
<x-select id="stream-state" wire:model.live="stateCd" class="w-full">
@foreach (\App\Livewire\Hydro\StreamGauge::US_STATES as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</select>
</x-select>
</div>
</div>

Expand Down Expand Up @@ -62,13 +56,13 @@ class="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-
@if ($sites !== null)
<div class="mt-2 flex items-center justify-between text-xs text-muted">
<span>{{ count($sites) }} active sites</span>
<button
type="button"
class="cursor-pointer text-accent hover:underline focus:outline-none"
<x-button
variant="link"
class="text-xs"
@click="window.dispatchEvent(new CustomEvent('stream-gauge-map-reset'))"
>
Reset map
</button>
</x-button>
</div>
@endif
</div>
Expand Down Expand Up @@ -144,30 +138,18 @@ class="cursor-pointer border-b border-border/50 transition-colors hover:bg-surfa
<div class="flex shrink-0 items-center gap-3">
@auth
@if (in_array($selectedSiteCode, $savedSiteCodes, true))
<button
type="button"
wire:click="unsaveStation"
class="inline-flex items-center gap-1 rounded-lg border border-border bg-surface-raised px-2.5 py-1 text-xs font-medium text-muted transition hover:bg-surface-hover focus:outline-none"
>
<x-button variant="secondary" size="sm" wire:click="unsaveStation">
✓ Saved
</button>
</x-button>
@else
<button
type="button"
wire:click="saveStation"
class="inline-flex items-center gap-1 rounded-lg border border-accent bg-accent px-2.5 py-1 text-xs font-medium text-white transition hover:bg-accent-hover focus:outline-none"
>
<x-button variant="primary" size="sm" wire:click="saveStation">
+ Save
</button>
</x-button>
@endif
@endauth
<button
type="button"
wire:click="$set('selectedSiteCode', null)"
class="text-xs text-muted hover:text-text focus:outline-none"
>
<x-button variant="muted-link" class="text-xs" wire:click="$set('selectedSiteCode', null)">
✕ Close
</button>
</x-button>
</div>
</div>

Expand Down
Loading
Loading