diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 636f2c9..494e663 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(Format-Table FullName)", "Bash(dir /b /s)", "Bash(findstr \"^app\")", - "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)" + "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", + "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)" ] } } diff --git a/.env.example b/.env.example index 7d2bd1c..1f72a6f 100644 --- a/.env.example +++ b/.env.example @@ -5,15 +5,15 @@ DB_HOST=sqlserver DB_PORT=1433 DB_DATABASE=Campaign_Tracker DB_USERNAME=sa -DB_PASSWORD=Dev_Password123! +DB_PASSWORD= # ── Keycloak ─────────────────────────────────────────────────────────────────── # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. # Keycloak 17+ (no /auth prefix): http://localhost:8080 # Keycloak < 17 (has /auth prefix): http://localhost:8080/auth -KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/ -KEYCLOAK_REALM=KCI -KEYCLOAK_CLIENT_ID=canopy-web -KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y +KEYCLOAK_BASE_URL= +KEYCLOAK_REALM= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login diff --git a/AGENTS.md b/AGENTS.md index 6a2d1a2..3f6550b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -997,5 +997,318 @@ Visual rhythm: - Dense-data readability: - Preserve minimum font sizes and row heights that remain readable during long operational sessions -# UI desgin -- Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html \ No newline at end of file +# UI Design +- Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html + +--- + +## UI/UX Component Rules + +These rules define the established UI patterns for this project. Follow them exactly when building new pages, views, or components. Do not invent new patterns — extend the existing ones. + +--- + +### Navigation + +**Link structure** +Every nav link must be an `` with: +- An inline SVG icon (14×14, `fill="none"`, `stroke="currentColor"`, `stroke-width="1.75"`, Heroicons-style path). +- The label text immediately after the icon. +- `aria-hidden="true"` on the SVG. + +```html + + + Campaigns + +``` + +**Group separators** +Nav links are divided into logical groups separated by ``: +- Group 1: Home +- Group 2: Campaigns, Campaign Types +- Group 3: Jobs, Job Types +- Then a separator before the logged-in user name and logout button. + +Add a separator between groups and before the auth area. Never put a separator at the very start or end of the nav. + +**Active state** +Add `is-active` class to the current page's link. Use exact match for `/`, prefix match (`str_starts_with`) for all others. + +**Logout button** +The logout button must use `class="nav-logout-btn"` — never `button button-secondary`. It is a ghost outlined button styled for the dark header background. Do not use the standard button classes here. + +**Contrast** +Nav link text uses `rgba(255, 255, 255, 0.88)`. Never go below `0.85` — the minimum for WCAG AA on the `#1F4E79` header. + +**Focus rings** +All nav links and the logout button have `outline: 2px solid rgba(255, 255, 255, 0.6)` on `:focus-visible`. Do not remove this. + +--- + +### Page Layout + +**Every page content area** uses a `.content-stack` as its root element. This is a CSS grid with `gap: 24px` that stacks the page toolbar, panels, and other sections vertically. + +```html +
+
...
+
...
+
+``` + +**Page toolbar** (`.page-toolbar`) always contains: +- A `.section-heading` div with an `

` and optional `

` description on the left. +- A primary action button (or back link) on the right. + +```html +

+
+

Page Title

+

One-line description of what this page manages.

+
+ + New Resource +
+``` + +**Page h1 size** is 32px / 40px line-height (set in `.section-heading h1`). Do not override this inline. + +--- + +### Section Panels + +Every content block on a page must be wrapped in `
`. Key rules: + +- The left edge always has `border-left: 3px solid var(--primary)` — this is set globally in CSS and must not be removed or overridden. +- Every panel that has a header must use `.panel-header`, which automatically adds a `border-bottom` separator below it. +- `.panel-header` contains a left `
` with `

` + optional `

`, and a right side for action buttons. +- Panel `

` is 18px. Do not make it larger than the page `

`. + +```html +
+
+
+

Section Title

+

Brief description of this section's content.

+
+ +
+ + +
+``` + +--- + +### Stat Cards (Dashboard Metrics) + +Use `.stat-card` for any numeric summary. Rules: + +- When a stat card navigates somewhere, use `` — not a `
`. The CSS `a.stat-card` handles hover lift and the "View all →" affordance automatically. +- When a stat card is purely informational (no destination), use `
`. +- Numbers display at 38px / font-weight 700 in `var(--primary)`. +- Labels use the `.stat-card span` style: 11px uppercase, `var(--text-secondary)`. +- Never put more than one number in a stat card. +- Place stat cards in a `.stats-grid` container. For 4 cards use `.stats-grid.stats-grid-4`. For 3 use `.stats-grid` alone (default 3-column). + +```html + +``` + +--- + +### Dashboard Pages + +When a page is an overview/metrics page (like the home dashboard): + +1. Start with a `.stats-grid` of stat cards covering the main record counts. +2. Follow with a `.dashboard-panels` grid of two side-by-side `.section-panel` blocks. +3. Left panel: a recent-records list using `.dashboard-table`. +4. Right panel: a breakdown or summary using `.type-breakdown`. + +**Dashboard table** (`.dashboard-table`) rules: +- Columns: ID (`.dashboard-table-id`), name/type, date (`.dashboard-table-date`), action link (`.dashboard-table-action`). +- No JavaScript — server-rendered, static HTML table. +- Always include a link column to the resource's edit page. +- Always include an empty state (`.empty-state`) with a helpful create link for when there is no data. + +**Type breakdown** (`.type-breakdown`) rules: +- Each row is a `.type-breakdown-row` showing: label, proportional bar, count. +- When the rows link somewhere use ``. The CSS handles hover and focus-visible. +- Calculate the bar width as `round(count / max * 100)%`. Pre-calculate `$max` before the loop. + +--- + +### Forms + +**Structure** +All multi-section forms use `.ct-form` (a CSS grid with `gap: 24px`) containing one or more `.form-section` blocks, ending with `.form-actions`. + +```html +
+ + +
+ +
+ +
+
+``` + +**Sticky save bar** +`.ct-form .form-actions` is `position: sticky; bottom: 0` — the save bar sticks to the viewport bottom when the form is taller than the screen. This is set globally in CSS. Do not override or move form-actions outside `.ct-form`. + +**Native validation** +Use HTML5 `required`, `maxlength`, `pattern`, `min`, `max` on inputs. The CSS rule `.input:invalid:not(:placeholder-shown)` automatically shows an error border without JavaScript. Use `novalidate` on the form tag to prevent browser-native popups while still benefiting from the CSS pseudo-class. + +**Required mark** +Mark required fields with `*` inside the label. Color is `var(--error)` via CSS. + +**Error messages** +Server-side validation errors go in `` immediately after the input. Add `input-error` class to the input when it has a server error. + +--- + +### Attribute Builder (Drag-Reorder Lists) + +When a form includes a reorderable list of items (like campaign type attributes): + +- Each row is `.attribute-row` with `draggable="true"` and Alpine.js drag event handlers. +- The first element of every row must be ``. + The CSS hides the unicode character with `font-size: 0` and replaces it with a `::before` radial-gradient dot grid — this is automatic. Do not change the HTML to use an SVG or other icon. +- `is-dragging` and `is-drag-over` classes are applied by Alpine and styled by CSS — do not add custom drag styles. + +--- + +### Loading States + +**Never use plain text** for loading indicators in content areas. Use `.skeleton-rows` with `.skeleton-row` children. + +```html +
+
+
+
+
+
+
+``` + +Rules: +- Use 5 skeleton rows for full-page table loaders. +- Use 3 skeleton rows for smaller/drilldown table loaders. +- Always pair with `x-cloak` and `x-show` on Alpine-controlled sections so the skeleton only shows while loading. +- The shimmer animation and fade-out opacity are set automatically by CSS via `:nth-child` selectors. + +--- + +### Interactive Link Patterns + +**Cards, rows, and panels that navigate** must be `` elements, not `
` elements with `onclick`. This applies to: +- Stat cards with a destination +- Type breakdown rows +- Any list row that is entirely clickable + +Use the appropriate CSS modifier class (`a.stat-card`, `a.type-breakdown-row`) — the hover and focus styles are already defined. + +**Inline table action links** (Edit, Delete) in Tabulator formatter functions must use these conventions: +- Edit: `class="button button-secondary button-sm"` +- Delete: `class="button button-danger button-sm"` with a `window.confirmXxx()` guard function +- Jobs/drill-down: `class="button button-primary button-sm"` + +--- + +### Accessibility + +**Focus rings** +- All content-area links get `outline: 2px solid var(--accent); outline-offset: 2px` on `:focus-visible` — set globally in CSS via `.page-content a:focus-visible`. Do not suppress this. +- All buttons already have `outline: 2px solid var(--accent); outline-offset: 2px` via `.button:focus-visible`. +- Nav links and logout button have white outline rings — see the Navigation section above. + +**Contrast minimums** +- Normal body text: minimum 4.5:1 against its background. +- Nav link text: minimum `rgba(255,255,255, 0.85)` against `#1F4E79`. +- Secondary text (`var(--text-secondary): #4B5563`) on white: ~7.2:1 — acceptable. +- Do not use `var(--text-muted)` (`#6B7280`) for body text — only for supplementary labels and counts. + +**ARIA** +- All SVG icons in nav links must have `aria-hidden="true"`. +- All `.nav-sep` elements must have `aria-hidden="true"`. +- Empty states must be descriptive — include both what is missing and a create action link. + +--- + +### Repository Patterns for Metrics + +When a page needs aggregate data (counts, recent records, breakdowns), add methods to the relevant repository. Do not inline aggregate SQL in the controller. + +**Count method pattern** +```php +public function count(): int +{ + $row = $this->database->first('SELECT COUNT(*) AS total FROM table_name'); + return (int) ($row['total'] ?? 0); +} +``` + +**Recent records pattern** +Inline the `TOP` limit directly — SQL Server does not accept bound parameters in `TOP` expressions. +```php +public function recentWithType(int $limit = 5): array +{ + return $this->database->query( + "SELECT TOP ({$limit}) t.id, t.created_at, ref.name AS type_name + FROM main_table t + INNER JOIN ref_table ref ON t.ref_id = ref.id + ORDER BY t.id DESC" + ); +} +``` + +**Grouped count pattern** +Use `LEFT JOIN` so that reference rows with zero records still appear. +```php +public function countByType(): array +{ + return $this->database->query( + 'SELECT ref.name AS type_name, COUNT(t.id) AS record_count + FROM ref_table ref + LEFT JOIN main_table t ON t.ref_id = ref.id + GROUP BY ref.id, ref.name + ORDER BY record_count DESC, ref.name ASC' + ); +} +``` + +--- + +### ViewModels for Dashboard Pages + +Dashboard pages use a dedicated ViewModel. Metric fields use typed defaults so the view is never working with `null`. + +```php +class MyDashboardViewModel +{ + public int $totalThings = 0; + public int $totalTypes = 0; + public array $recentItems = []; + public array $itemsByType = []; +} +``` + +Populate it in the controller using private `repo()` methods or inline `new Repository(database())` calls — consistent with how other controllers are written in this project. \ No newline at end of file diff --git a/app/Views/campaigns/index.php b/app/Views/campaigns/index.php index fad8b79..d000a01 100644 --- a/app/Views/campaigns/index.php +++ b/app/Views/campaigns/index.php @@ -29,7 +29,13 @@
-
Loading campaigns...
+
+
+
+
+
+
+

@@ -46,7 +52,11 @@ -
Loading jobs...
+
+
+
+
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php index 91eb9df..ac63ec3 100644 --- a/app/Views/home/index.php +++ b/app/Views/home/index.php @@ -16,22 +16,22 @@ foreach ($model->campaignsByType as $row) {
@@ -92,13 +92,13 @@ foreach ($model->campaignsByType as $row) {
campaignsByType as $row): ?> 0 ? round((int) $row['campaign_count'] / $maxCount * 100) : 0; ?> -
+ -
+
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index 0c19165..50f0e49 100644 --- a/app/Views/partials/header.php +++ b/app/Views/partials/header.php @@ -44,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); diff --git a/core/Http/Session.php b/core/Http/Session.php index 0e36732..38fb2ea 100644 --- a/core/Http/Session.php +++ b/core/Http/Session.php @@ -12,6 +12,8 @@ class Session return; } + session_save_path('/tmp'); + $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; session_set_cookie_params([ diff --git a/core/Response.php b/core/Response.php index afd3a0c..63ba0b0 100644 --- a/core/Response.php +++ b/core/Response.php @@ -43,6 +43,12 @@ class Response public function send(): void { + // Flush session to disk before sending headers so it is persisted before + // the browser follows any redirect (critical for OAuth state survival). + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + http_response_code($this->status); foreach ($this->headers as $name => $value) { diff --git a/docker-publish.ps1 b/docker-publish.ps1 index 6b307c3..2236463 100644 --- a/docker-publish.ps1 +++ b/docker-publish.ps1 @@ -1,14 +1,14 @@ #Requires -Version 5.1 <# .SYNOPSIS - Builds the Campaign Tracker Docker image and pushes it to the private registry. + Copies .env to the server, then SSHes in to pull the repo and start the container. .EXAMPLE .\docker-publish.ps1 - .\docker-publish.ps1 -SkipBuild # push a previously built image + .\docker-publish.ps1 -SshKey "~/.ssh/id_rsa" #> param( - [switch]$SkipBuild + [string]$SshKey = "" ) Set-StrictMode -Version Latest @@ -17,9 +17,10 @@ $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- -$REGISTRY_HOST = "192.168.1.200" # change port if needed, e.g. "192.168.1.200:5000" -$IMAGE_NAME = "campaign-tracker" -$FULL_TAG = "$REGISTRY_HOST/$IMAGE_NAME`:latest" +$SSH_HOST = "192.168.1.200" +$SSH_USER = "root" +$REPO_PATH = "/root/campaign-tracker" +$REPO_URL = "https://onefortheroadgit.sytes.net/dcovington/KCI-CAMPAIGN-TRACKER.git" # --------------------------------------------------------------------------- # Helpers @@ -28,26 +29,44 @@ function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } -function Assert-DockerRunning { - docker info > $null 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Error "Docker is not running or not reachable. Start Docker Desktop and retry." - } +function Get-BaseArgs { + $a = @("-o", "StrictHostKeyChecking=accept-new") + if ($SshKey -ne "") { $a += @("-i", $SshKey) } + return $a } # --------------------------------------------------------------------------- -# Main +# Step 1 — copy .env (first password prompt) # --------------------------------------------------------------------------- -Assert-DockerRunning +Write-Step "Copying .env to $SSH_USER@$SSH_HOST" +$scpArgs = Get-BaseArgs +$scpArgs += ".env", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env" +scp @scpArgs +if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." } -if (-not $SkipBuild) { - Write-Step "Building image: $FULL_TAG" - docker build --tag $FULL_TAG . - if ($LASTEXITCODE -ne 0) { Write-Error "docker build failed (exit $LASTEXITCODE)." } -} +# --------------------------------------------------------------------------- +# Step 2 — deploy (second password prompt) +# --------------------------------------------------------------------------- +Write-Step "Deploying on $SSH_USER@$SSH_HOST" + +# Deploy whichever branch is currently checked out locally. +$BRANCH = (git rev-parse --abbrev-ref HEAD).Trim() +Write-Host "Branch: $BRANCH" -ForegroundColor Yellow + +# Build as a single semicolon-separated string — no newlines, no CRLF risk. +$remoteCmd = "set -e; " +$remoteCmd += "if [ -d '$REPO_PATH/.git' ]; then " +$remoteCmd += "cd '$REPO_PATH' && git fetch origin && git checkout $BRANCH && git pull origin $BRANCH; " +$remoteCmd += "else " +$remoteCmd += "mkdir -p '$REPO_PATH' && git clone --branch $BRANCH '$REPO_URL' '$REPO_PATH'; " +$remoteCmd += "fi; " +$remoteCmd += "docker ps -aq | xargs -r docker rm -f; " +$remoteCmd += "docker run --rm -v '$REPO_PATH':/app -w /app composer:latest install --no-dev --optimize-autoloader --no-interaction; " +$remoteCmd += "cd '$REPO_PATH' && docker compose up -d" -Write-Step "Pushing image: $FULL_TAG" -docker push $FULL_TAG -if ($LASTEXITCODE -ne 0) { Write-Error "docker push failed (exit $LASTEXITCODE)." } +$sshArgs = Get-BaseArgs +$sshArgs += "$SSH_USER@$SSH_HOST", $remoteCmd +ssh @sshArgs +if ($LASTEXITCODE -ne 0) { Write-Error "Deployment failed (exit $LASTEXITCODE)." } -Write-Host "`nDone. Image available at $FULL_TAG" -ForegroundColor Green +Write-Host "`nDone. Campaign Tracker is running on $SSH_HOST." -ForegroundColor Green diff --git a/public/css/site.css b/public/css/site.css index b272f2f..12085ab 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -179,8 +179,11 @@ h4 { font-size: 16px; line-height: 24px; } } .nav-link { + display: inline-flex; + align-items: center; + gap: 6px; text-decoration: none; - color: rgba(255, 255, 255, 0.75); + color: rgba(255, 255, 255, 0.88); font-size: 13px; font-weight: 600; padding: 6px 12px; @@ -188,10 +191,16 @@ h4 { font-size: 16px; line-height: 24px; } transition: background-color 120ms ease, color 120ms ease; } -.nav-link:hover, +.nav-link:hover { + color: #fff; + background: rgba(255, 255, 255, 0.12); +} + .nav-link:focus-visible { color: #fff; background: rgba(255, 255, 255, 0.12); + outline: 2px solid rgba(255, 255, 255, 0.6); + outline-offset: 2px; } .nav-link.is-active { @@ -199,6 +208,41 @@ h4 { font-size: 16px; line-height: 24px; } background: rgba(255, 255, 255, 0.18); } +.nav-sep { + width: 1px; + height: 18px; + background: rgba(255, 255, 255, 0.2); + margin: 0 4px; + flex-shrink: 0; +} + +.nav-logout-btn { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-xs); + font-family: inherit; + font-size: 12px; + font-weight: 600; + line-height: 20px; + cursor: pointer; + background: transparent; + color: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(255, 255, 255, 0.22); + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.nav-logout-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.4); + color: #fff; +} + +.nav-logout-btn:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.6); + outline-offset: 2px; +} + /* ── Page Content ────────────────────────────────────────────────────── */ .page-content { flex: 1; @@ -217,8 +261,8 @@ h4 { font-size: 16px; line-height: 24px; } .section-heading h1 { margin: 4px 0 8px; - font-size: 28px; - line-height: 36px; + font-size: 32px; + line-height: 40px; } .section-heading p { @@ -393,6 +437,7 @@ h4 { font-size: 16px; line-height: 24px; } padding: 24px; border-radius: var(--radius-lg); min-width: 0; + border-left: 3px solid var(--primary); } .panel-header { @@ -401,7 +446,9 @@ h4 { font-size: 16px; line-height: 24px; } justify-content: space-between; gap: 16px; flex-wrap: wrap; - margin-bottom: 16px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); } .panel-actions { @@ -499,6 +546,11 @@ h4 { font-size: 16px; line-height: 24px; } box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); } +.input:invalid:not(:placeholder-shown) { + border-color: var(--error); + box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.08); +} + .field-error { color: var(--error); font-size: 12px; @@ -512,6 +564,17 @@ h4 { font-size: 16px; line-height: 24px; } gap: 8px; } +.ct-form .form-actions { + position: sticky; + bottom: 0; + z-index: 2; + background: var(--surface); + margin: 8px -24px -24px; + padding: 12px 24px; + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + /* ── Buttons ─────────────────────────────────────────────────────────── */ .button { display: inline-flex; @@ -630,12 +693,48 @@ h4 { font-size: 16px; line-height: 24px; } .stat-card strong { display: block; - margin-top: 6px; - font-size: 28px; + margin-top: 8px; + font-size: 38px; line-height: 1; + font-weight: 700; color: var(--primary); } +a.stat-card { + text-decoration: none; + cursor: pointer; + transition: transform 100ms ease, box-shadow 100ms ease, border-color 100ms ease; +} + +a.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); + border-color: var(--border-strong); +} + +a.stat-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +a.stat-card::after { + content: 'View all →'; + display: block; + margin-top: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-muted); + opacity: 0; + transform: translateX(-4px); + transition: opacity 150ms ease, transform 150ms ease; +} + +a.stat-card:hover::after { + opacity: 1; + transform: translateX(0); +} + .summary-feature { margin-top: 16px; padding: 14px 16px; @@ -1032,17 +1131,37 @@ h4 { font-size: 16px; line-height: 24px; } .attr-drag-handle { cursor: grab; - padding: 0 4px; - color: var(--text-muted); - font-size: 16px; user-select: none; align-self: flex-end; padding-bottom: 8px; - line-height: 1; + font-size: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 34px; + flex-shrink: 0; + opacity: 0.5; + transition: opacity 100ms ease; +} + +.attr-drag-handle::before { + content: ''; + display: block; + width: 6px; + height: 16px; + background-image: radial-gradient(circle, var(--text-muted) 1.5px, transparent 1.5px); + background-size: 3px 4px; + background-repeat: repeat; +} + +.attr-drag-handle:hover { + opacity: 1; } .attr-drag-handle:active { cursor: grabbing; + opacity: 1; } .attribute-row.is-dragging { @@ -1158,6 +1277,58 @@ h4 { font-size: 16px; line-height: 24px; } width: 100%; } +/* ── Global Focus Visible ────────────────────────────────────────────── */ +.page-content a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; +} + +/* ── Skeleton Loading ────────────────────────────────────────────────── */ +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton-rows { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0; +} + +.skeleton-row { + height: 38px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--surface-raised) 25%, var(--border) 50%, var(--surface-raised) 75%); + background-size: 400% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; +} + +.skeleton-row:nth-child(2) { animation-delay: 0.1s; opacity: 0.85; } +.skeleton-row:nth-child(3) { animation-delay: 0.2s; opacity: 0.7; } +.skeleton-row:nth-child(4) { animation-delay: 0.3s; opacity: 0.55; } +.skeleton-row:nth-child(5) { animation-delay: 0.4s; opacity: 0.4; } + +/* ── Type Breakdown Links ────────────────────────────────────────────── */ +a.type-breakdown-row { + text-decoration: none; + color: inherit; + padding: 4px 6px; + margin: 0 -6px; + border-radius: var(--radius-sm); + transition: background-color 100ms ease; +} + +a.type-breakdown-row:hover { + background: var(--surface-raised); +} + +a.type-breakdown-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* ── Responsive ──────────────────────────────────────────────────────── */ @media (max-width: 860px) { .header-inner, @@ -1218,6 +1389,10 @@ h4 { font-size: 16px; line-height: 24px; } text-align: center; } + .nav-sep { + display: none; + } + .hero h1 { font-size: 22px; }