/* =========================================== HTMX LOADING INDICATORS =========================================== */ .htmx-indicator { display: none; animation: htmx-spin 1s ease-in-out infinite; } .htmx-request .htmx-indicator { display: inline; } .htmx-request.htmx-indicator { display: inline; } /* Loading spinner animation. Applied on .htmx-indicator above. */ @keyframes htmx-spin { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } } /* Disable buttons during request */ .htmx-request button[type="submit"] { opacity: 0.6; cursor: wait; } /* =========================================== TOAST NOTIFICATIONS =========================================== */ .toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: var(--z-toast); display: flex; flex-direction: column; gap: var(--gap-peer); } .toast { background: var(--primary-dark); color: var(--primary-light); padding: var(--gap-section); font-family: var(--font-mono); font-size: var(--text-note); box-shadow: var(--elevation-overlay); animation: toast-in var(--motion-fade) ease-out; max-width: 300px; display: flex; align-items: center; gap: var(--gap-section); } .toast-dismiss { background: none; border: none; color: inherit; font-size: var(--text-lead); cursor: pointer; opacity: 0.7; padding: 0; line-height: 1; flex-shrink: 0; } .toast-dismiss:hover { opacity: 1; } .toast-success { background: var(--success); } .toast-error { background: var(--danger); } .toast-warning { background: var(--warning); } .toast-info { background: var(--primary-dark); } .toast-retry-btn { background: none; border: 1px solid currentColor; color: inherit; font-family: var(--font-mono); font-size: var(--text-fine); font-weight: 600; padding: var(--gap-bound) var(--gap-peer); margin-left: var(--gap-section); cursor: pointer; } .toast-retry-btn:hover { background: color-mix(in oklch, var(--primary-light) 15%, transparent); } .toast.fade-out { /* `Motion::Fade`, the same number the script waits before removing the node. A literal here and a token there is the two parting silently, and reduced motion zeroes the token. */ animation: toast-out var(--motion-fade) ease-in forwards; } @keyframes toast-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes toast-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } /* =========================================== RESTART WARNING BANNER =========================================== */ /* Page-top behavior extensions on top of .banner.banner--warning. Color / typography come from the .banner primitive. */ #restart-banner { position: fixed; top: 0; left: 0; right: 0; z-index: var(--z-banner); padding: var(--gap-group) var(--gap-section); animation: banner-slide-down 0.3s ease-out; } @keyframes banner-slide-down { from { transform: translateY(-100%); } to { transform: translateY(0); } } /* =========================================== SAVE STATUS INDICATORS =========================================== */ /* Username availability status */ .username-status { display: block; margin-top: var(--gap-bound); font-size: var(--text-note); } .username-status.available { color: var(--success); } .username-status.taken { color: var(--danger); } /* =========================================== HTMX TRANSITIONS =========================================== */ /* Fade in for swapped content */ .htmx-added { animation: htmx-fade-in 0.2s ease-out; } @keyframes htmx-fade-in { from { opacity: 0; } to { opacity: 1; } } /* Settling animation for swapped content */ .htmx-settling { opacity: 0.8; } /* Swap animation */ .htmx-swapping { opacity: 0.5; transition: opacity 0.2s ease-out; } /* =========================================== ACCESSIBILITY - FOCUS STYLES =========================================== */ /* Focus-visible for keyboard navigation */ *:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } /* Remove default focus outline since we use focus-visible */ *:focus:not(:focus-visible) { outline: none; } /* Ensure buttons and links are keyboard accessible */ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } /* Tab focus is layout.css's, same ring as everything else at outline-offset 2px. The local rule inset it to -2px, which is only worth doing where a strip clips its overflow; `.tabs` is flex/nowrap with no overflow set, so there was nothing to clip and the tabs read differently from every other focusable thing on the page for no reason. */ /* Canonical "selected" recipe (charter: docs/design-system.md). Apply `.is-selected` to any interactive container to mark it as the currently-selected option. A badge never takes it: a badge reports a status and nobody selects "Enabled", so badges take a `data-tone` instead. Visibility toggles (`.tab-content.active`, `.section-panel.active`) also keep `.active` since they're not selection state. */ .is-selected { background: var(--highlight-faint); border-color: var(--focus-ring); } /* Skip to main content link (for screen readers) */ .skip-to-main { position: absolute; top: -100px; left: 0; background: var(--primary-dark); color: var(--primary-light); padding: 8px; z-index: var(--z-skip-link); transition: top 0.2s ease; } .skip-to-main:focus { top: 0; } /* Visually hidden but accessible to screen readers */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Status indicator with text for accessibility */ .status-indicator::after { content: attr(data-status); position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* =========================================== PAYWALL =========================================== */ .paywall-section { margin: var(--gap-page) 0; padding: var(--gap-page); background: var(--surface-sunken); text-align: center; } .paywall-box h2 { font-family: var(--font-display); font-weight: normal; margin-bottom: var(--gap-peer); } .paywall-price { font-family: var(--font-mono); font-size: var(--text-head); margin-bottom: var(--gap-peer); } .paywall-desc { font-family: var(--font-sans); color: var(--content-muted); margin-bottom: var(--gap-pane); } .paywall-note { font-family: var(--font-mono); font-size: var(--text-note); color: var(--content-muted); margin-top: var(--gap-section); } .paywall-note a { color: var(--action); } /* =========================================== MODALS =========================================== */ .modal-overlay { position: fixed; inset: 0; background: var(--overlay); display: flex; align-items: center; justify-content: center; z-index: var(--z-modal); } .modal { background: var(--surface-page); padding: var(--gap-page); max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; } .modal h2 { margin-bottom: var(--gap-pane); } .modal .form-actions { display: flex; gap: var(--gap-section); justify-content: flex-end; margin-top: var(--gap-pane); } /* =========================================== LINK ROWS (Dashboard Links Section) =========================================== */ .link-row { display: flex; gap: var(--gap-section); align-items: center; margin-bottom: var(--gap-section); padding: var(--gap-section); background: var(--surface-sunken); transition: opacity 0.2s ease, background 0.2s ease; } .link-row input[type="text"], .link-row input[type="url"] { flex: 1; min-width: 0; } .link-row .btn-danger { padding: var(--gap-peer) var(--gap-section); font-size: var(--text-note); } /* Link order buttons */ .link-order-buttons { display: flex; flex-direction: column; gap: 2px; } .order-btn { background: var(--surface-sunken); border: 1px solid var(--border); padding: var(--gap-bound) var(--gap-peer); font-size: var(--text-fine); line-height: 1; cursor: pointer; opacity: 0.6; transition: opacity 0.15s ease, background 0.15s ease; } .order-btn:hover { opacity: 1; background: var(--border); } .order-btn:active { box-shadow: var(--shadow-inset); } /* =========================================== TEXT EDITOR =========================================== */ .text-editor { width: 100%; } .editor-footer { display: flex; justify-content: space-between; padding: var(--gap-peer) var(--gap-section); background: var(--surface-sunken); font-size: var(--text-note); opacity: 0.7; } /* =========================================== RICH FIELD (crate::quasi::rich_field) =========================================== */ /* The wrapper is this app's; the group inside it is the renderer's, and `static/layout.css` carries every rule that decides what is showing. What is left for this file is the two things a description does not say: how tall the editor stands, and how the rendered preview reads. Selected through the `[data-format="markdown"]` mark rather than through makeover's class names, so nothing here overlaps the generated sheet. */ .rich-field [data-format="markdown"] > textarea { padding: var(--gap-section); font-family: var(--font-mono); font-size: var(--text-note); line-height: 1.6; resize: vertical; border: none; background: var(--surface-sunken); } /* Three sizes for four surfaces: the item editor and the blog editor were already the same surface written two ways (400px of CSS, and rows="20"). */ .rich-field--compact [data-format="markdown"] > textarea, .rich-field--compact [data-editor-preview] { min-height: 9rem; } .rich-field--standard [data-format="markdown"] > textarea, .rich-field--standard [data-editor-preview] { min-height: 15rem; } .rich-field--tall [data-format="markdown"] > textarea, .rich-field--tall [data-editor-preview] { min-height: 400px; } /* The pane stands where the control stood, so it reads as prose rather than as source. The rules below are the retired `.markdown-preview` set, unchanged except for what selects them. */ .rich-field [data-editor-preview] { padding: var(--gap-pane); font-family: var(--font-sans); line-height: 1.8; } .rich-field [data-editor-preview] h1, .rich-field [data-editor-preview] h2, .rich-field [data-editor-preview] h3 { margin-top: var(--gap-pane); margin-bottom: var(--gap-peer); } .rich-field [data-editor-preview] h1 { font-size: var(--text-title); } .rich-field [data-editor-preview] h2 { font-size: var(--text-head); } .rich-field [data-editor-preview] h3 { font-size: var(--text-lead); } .rich-field [data-editor-preview] p { margin-bottom: var(--gap-section); } .rich-field [data-editor-preview] code { background: var(--surface-sunken); padding: var(--gap-bound); font-family: var(--font-mono); font-size: var(--text-note); } /* =========================================== AUDIO UPLOAD =========================================== */ .audio-upload { width: 100%; } .current-audio { display: flex; justify-content: space-between; align-items: center; padding: var(--gap-section); background: var(--surface-sunken); margin-bottom: var(--gap-section); } .audio-info { display: flex; align-items: center; gap: var(--gap-section); } .audio-icon { font-size: var(--text-head); opacity: 0.6; } .audio-details { display: flex; flex-direction: column; } .audio-filename { font-family: var(--font-mono); font-size: var(--text-note); } .audio-duration { font-size: var(--text-note); opacity: 0.6; } .upload-area .file-upload-area { padding: var(--gap-page); } .upload-icon { font-size: var(--text-title); opacity: 0.4; margin-bottom: var(--gap-peer); } .upload-text { font-size: var(--text-lead); margin-bottom: var(--gap-peer); } .upload-hint { font-size: var(--text-note); opacity: 0.6; } .upload-progress { padding: var(--gap-pane); background: var(--surface-sunken); } .progress-info { display: flex; justify-content: space-between; margin-bottom: var(--gap-section); font-size: var(--text-note); } /* Canonical progress bar primitive. One of these in the codebase. - .progress-bar-container is the track (8px default; --slim = 6px) - .progress-bar is the fill (success-green default; --highlight = purple) - Add .progress-bar-container--rounded for the 3px-radius pill variant Spacing (mb-4 / mt-2 etc.) is added by the caller, not baked in. */ .progress-bar-container { height: 8px; background: var(--border); overflow: hidden; } .progress-bar-container--slim { height: 6px; } .progress-bar-container--rounded { border-radius: var(--radius-control); } /* Fill proportion comes from the template as --fill, because it is per-render data. The property that reads it lives here. */ .progress-bar { width: var(--fill, 0%); height: 100%; background: var(--success); transition: width 0.2s ease; } .progress-bar--highlight { background: var(--action); } .progress-bar--warn { background: var(--warning); } .progress-bar--danger { background: var(--danger); } /* Upload status block (filename + percent label + progress bar). One of these in the codebase. Used by wizard upload flows and standalone audio/video upload partials. */ .upload-status { margin-top: var(--gap-peer); } .upload-status-row { display: flex; align-items: center; gap: var(--gap-peer); } .upload-status-row span { font-size: var(--text-note); } .upload-status-msg { margin-top: var(--gap-peer); } .upload-status-msg.is-success { color: var(--success); } .upload-status-msg.is-error { color: var(--danger); } /* Inline progress cell rendered inside a table-row status cell (batch uploads). */ .upload-progress-row { display: flex; align-items: center; gap: var(--gap-peer); } .upload-progress-row .progress-bar-container { flex: 1; min-width: 60px; } .upload-progress-pct { font-size: var(--text-fine); min-width: 2.5em; text-align: right; } .upload-success { padding: var(--gap-pane); background: var(--surface-sunken); text-align: center; } .upload-error { padding: var(--gap-pane); background: var(--surface-sunken); text-align: center; } .hidden { display: none !important; } /* =========================================== VIEW TOGGLE (Grid/List) =========================================== */ .view-controls { display: flex; gap: var(--gap-bound); margin-left: auto; } .view-btn { background: var(--surface-sunken); border: none; padding: var(--gap-peer) var(--gap-group); font-family: var(--font-mono); font-size: var(--text-note); cursor: pointer; opacity: 0.5; transition: opacity 0.2s ease; } .view-btn:hover { opacity: 0.8; } .view-btn.is-selected { opacity: 1; background: var(--primary-dark); color: var(--primary-light); } .results-container.results-list .results-table { display: block; } .results-container.results-list .results-grid { display: none; } .results-container.results-grid .results-table { display: none; } .results-container.results-grid .results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--gap-pane); border: 1px solid var(--border); border-top: none; padding: var(--gap-pane); background: var(--surface-overlay); } /* Grid cards */ .grid-card { background: var(--surface-overlay); text-decoration: none; color: var(--content); display: flex; flex-direction: column; transition: background 0.2s ease; } .grid-card:hover { background: var(--surface-sunken); text-decoration: none; } .grid-card-thumbnail { height: 120px; background: var(--border); display: flex; align-items: center; justify-content: center; font-size: var(--text-fine); opacity: 0.5; text-transform: uppercase; } .grid-card-content { padding: var(--gap-section); display: flex; flex-direction: column; gap: var(--gap-bound); } .grid-card-title { font-family: var(--font-display); font-size: var(--text-lead); } .grid-card-meta { font-size: var(--text-note); opacity: 0.6; } .grid-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--gap-peer); font-size: var(--text-note); } .grid-card-category { opacity: 0.6; } .grid-card-price { font-weight: bold; } .grid-card-stat { opacity: 0.6; } /* =========================================== PROJECT PAGE VIEW TOGGLE =========================================== */ .items-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--gap-pane); } .items-header .section-header { margin-bottom: 0; border-bottom: none; padding-bottom: 0; } /* Grid view is default on project page */ .items-container.items-grid-view .items-grid { display: grid; } .items-container.items-grid-view .items-list { display: none; } .items-container.items-list-view .items-grid { display: none; } .items-container.items-list-view .items-list { display: block; } /* List items styling */ .items-list { display: none; } .list-item { display: flex; justify-content: space-between; align-items: center; padding: var(--gap-section); background: var(--surface-overlay); margin-bottom: var(--gap-peer); text-decoration: none; color: var(--content); transition: background 0.2s ease; } .list-item:hover { background: var(--surface-sunken); text-decoration: none; } .list-item-info { display: flex; flex-direction: column; gap: var(--gap-bound); } .list-item-title { font-family: var(--font-display); font-size: var(--text-lead); } .list-item-meta { font-size: var(--text-note); opacity: 0.6; } .list-item-price { font-size: var(--text-lead); font-weight: bold; }