Refactor compare-view component to use observables for data loading, enhancing performance and responsiveness. Update compare service interfaces and methods for improved delta computation. Modify audit log component to handle optional event properties gracefully. Optimize Monaco editor worker loading to reduce bundle size. Introduce shared SCSS mixins for consistent styling across components. Add Gitea test instance setup and NuGet package publishing test scripts for CI/CD validation. Update documentation paths and ensure all references are accurate.
This commit is contained in:
@@ -44,16 +44,17 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
"maximumWarning": "750kb",
|
||||
"maximumError": "1.5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "12kb"
|
||||
"maximumWarning": "12kb",
|
||||
"maximumError": "20kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"namedChunks": true
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:stats": "ng build --stats-json",
|
||||
"analyze": "ng build --stats-json && npx esbuild-visualizer --metadata dist/stellaops-web/browser/stats.json --open",
|
||||
"analyze:source-map": "ng build --source-map && npx source-map-explorer dist/stellaops-web/browser/*.js",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "npm run verify:chromium && ng test --watch=false",
|
||||
"test:watch": "ng test --watch",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<span *ngIf="action.targetVersion"> → {{ action.targetVersion }}</span>
|
||||
</div>
|
||||
<div matListItemLine *ngIf="action.cveIds?.length" class="cve-list">
|
||||
CVEs: {{ action.cveIds.join(', ') }}
|
||||
CVEs: {{ action.cveIds?.join(', ') }}
|
||||
</div>
|
||||
<div matListItemLine *ngIf="action.estimatedEffort" class="effort-estimate">
|
||||
Estimated effort: {{ action.estimatedEffort }}
|
||||
|
||||
@@ -89,27 +89,30 @@ export class CompareViewComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async loadTarget(id: string, type: 'current' | 'baseline'): Promise<void> {
|
||||
const target = await this.compareService.getTarget(id);
|
||||
if (type === 'current') {
|
||||
this.currentTarget.set(target);
|
||||
} else {
|
||||
this.baselineTarget.set(target);
|
||||
// Load baseline rationale
|
||||
const rationale = await this.compareService.getBaselineRationale(id);
|
||||
this.baselineRationale.set(rationale);
|
||||
}
|
||||
this.loadDelta();
|
||||
loadTarget(id: string, type: 'current' | 'baseline'): void {
|
||||
this.compareService.getTarget(id).subscribe(target => {
|
||||
if (type === 'current') {
|
||||
this.currentTarget.set(target);
|
||||
} else {
|
||||
this.baselineTarget.set(target);
|
||||
// Load baseline rationale
|
||||
this.compareService.getBaselineRationale(id).subscribe(rationale => {
|
||||
this.baselineRationale.set(rationale.selectionReason);
|
||||
});
|
||||
}
|
||||
this.loadDelta();
|
||||
});
|
||||
}
|
||||
|
||||
async loadDelta(): Promise<void> {
|
||||
loadDelta(): void {
|
||||
const current = this.currentTarget();
|
||||
const baseline = this.baselineTarget();
|
||||
if (!current || !baseline) return;
|
||||
|
||||
const delta = await this.compareService.computeDelta(current.id, baseline.id);
|
||||
this.categories.set(delta.categories);
|
||||
this.items.set(delta.items);
|
||||
this.compareService.computeDelta(current.id, baseline.id).subscribe(delta => {
|
||||
this.categories.set(delta.categories);
|
||||
this.items.set(delta.items);
|
||||
});
|
||||
}
|
||||
|
||||
selectCategory(categoryId: string): void {
|
||||
@@ -123,17 +126,12 @@ export class CompareViewComponent implements OnInit {
|
||||
this.loadEvidence(item);
|
||||
}
|
||||
|
||||
async loadEvidence(item: DeltaItem): Promise<void> {
|
||||
const current = this.currentTarget();
|
||||
const baseline = this.baselineTarget();
|
||||
if (!current || !baseline) return;
|
||||
|
||||
const evidence = await this.compareService.getItemEvidence(
|
||||
item.id,
|
||||
baseline.id,
|
||||
current.id
|
||||
);
|
||||
this.evidence.set(evidence);
|
||||
loadEvidence(item: DeltaItem): void {
|
||||
this.compareService.getItemEvidence(item.id).subscribe(panes => {
|
||||
// Get the first pane or create a placeholder
|
||||
const evidence = panes.length > 0 ? panes[0] : null;
|
||||
this.evidence.set(evidence);
|
||||
});
|
||||
}
|
||||
|
||||
toggleViewMode(): void {
|
||||
@@ -142,24 +140,25 @@ export class CompareViewComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
getChangeIcon(changeType: 'added' | 'removed' | 'changed'): string {
|
||||
getChangeIcon(changeType: 'added' | 'removed' | 'changed' | undefined): string {
|
||||
switch (changeType) {
|
||||
case 'added': return 'add_circle';
|
||||
case 'removed': return 'remove_circle';
|
||||
case 'changed': return 'change_circle';
|
||||
default: return 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
getChangeClass(changeType: 'added' | 'removed' | 'changed'): string {
|
||||
return `change-${changeType}`;
|
||||
getChangeClass(changeType: 'added' | 'removed' | 'changed' | undefined): string {
|
||||
return changeType ? `change-${changeType}` : 'change-unknown';
|
||||
}
|
||||
|
||||
async exportReport(): Promise<void> {
|
||||
exportReport(): void {
|
||||
const current = this.currentTarget();
|
||||
const baseline = this.baselineTarget();
|
||||
if (!current || !baseline) return;
|
||||
|
||||
await this.exportService.exportJson(
|
||||
this.exportService.exportJson(
|
||||
current,
|
||||
baseline,
|
||||
this.categories(),
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface CompareSession {
|
||||
* Compare target (current or baseline scan).
|
||||
*/
|
||||
export interface CompareTarget {
|
||||
id: string;
|
||||
digest: string;
|
||||
imageRef: string;
|
||||
scanDate: string;
|
||||
@@ -59,21 +60,37 @@ export interface CompareTarget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta category for grouping changes.
|
||||
* Delta category type (string literal).
|
||||
*/
|
||||
export type DeltaCategory = 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
export type DeltaCategoryType = 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
|
||||
/**
|
||||
* Delta category for grouping changes with summary counts.
|
||||
*/
|
||||
export interface DeltaCategory {
|
||||
id: DeltaCategoryType;
|
||||
name: string;
|
||||
icon: string;
|
||||
added: number;
|
||||
removed: number;
|
||||
changed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta item representing a difference between scans.
|
||||
*/
|
||||
export interface DeltaItem {
|
||||
id: string;
|
||||
category: DeltaCategory;
|
||||
category: DeltaCategoryType;
|
||||
component: string;
|
||||
cve?: string;
|
||||
currentSeverity?: string;
|
||||
baselineSeverity?: string;
|
||||
description: string;
|
||||
// Export service expected properties
|
||||
changeType?: 'added' | 'removed' | 'changed';
|
||||
title?: string;
|
||||
severity?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +100,18 @@ export interface EvidencePane {
|
||||
digest: string;
|
||||
data: Record<string, unknown>;
|
||||
loading: boolean;
|
||||
// View-specific properties
|
||||
title?: string;
|
||||
beforeEvidence?: Record<string, unknown>;
|
||||
afterEvidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of computing delta between scans.
|
||||
*/
|
||||
export interface DeltaResult {
|
||||
categories: DeltaCategory[];
|
||||
items: DeltaItem[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -206,10 +235,10 @@ export class CompareService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes delta between current and baseline.
|
||||
* Result of computing a delta between scans.
|
||||
*/
|
||||
computeDelta(currentDigest: string, baselineDigest: string): Observable<DeltaItem[]> {
|
||||
return this.http.get<DeltaItem[]>(
|
||||
computeDelta(currentDigest: string, baselineDigest: string): Observable<DeltaResult> {
|
||||
return this.http.get<DeltaResult>(
|
||||
`${this.baseUrl}/delta?current=${currentDigest}&baseline=${baselineDigest}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,9 +122,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (event of paginatedEvents; track event.id) {
|
||||
@for (event of paginatedEvents; track event.id ?? event.eventType + event.occurredAt) {
|
||||
<tr>
|
||||
<td class="timestamp">{{ formatTimestamp(event.timestamp) }}</td>
|
||||
<td class="timestamp">{{ formatTimestamp(event.timestamp ?? event.occurredAt) }}</td>
|
||||
<td>
|
||||
<span class="event-badge" [class]="getEventClass(event.eventType)">
|
||||
{{ event.eventType }}
|
||||
@@ -182,7 +182,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Timestamp:</span>
|
||||
<span>{{ formatTimestamp(selectedEvent.timestamp) }}</span>
|
||||
<span>{{ formatTimestamp(selectedEvent.timestamp ?? selectedEvent.occurredAt) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Event Type:</span>
|
||||
@@ -208,7 +208,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Metadata:</span>
|
||||
<pre class="metadata-json">{{ formatMetadata(selectedEvent.metadata) }}</pre>
|
||||
<pre class="metadata-json">{{ formatMetadata(selectedEvent.metadata ?? {}) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,26 +46,21 @@ export class MonacoLoaderService {
|
||||
/**
|
||||
* Configure Monaco web workers for language services.
|
||||
* Ensures deterministic, offline-friendly loading (no CDN usage).
|
||||
*
|
||||
* OPTIMIZATION: Only load editor core + JSON worker.
|
||||
* Removed CSS/HTML/TypeScript workers to save ~3-4MB.
|
||||
* Stella DSL only needs basic editor + JSON-like validation.
|
||||
*/
|
||||
private async configureWorkers(monaco: MonacoNamespace): Promise<void> {
|
||||
const [editorWorker, cssWorker, htmlWorker, jsonWorker, tsWorker] = await Promise.all([
|
||||
// Only load essential workers - saves ~3-4MB
|
||||
const [editorWorker, jsonWorker] = await Promise.all([
|
||||
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'),
|
||||
]);
|
||||
|
||||
// Minimal worker mapping - all non-JSON languages use base editor worker
|
||||
const workerByLabel: Record<string, () => Worker> = {
|
||||
json: () => new (jsonWorker as any).default(),
|
||||
css: () => new (cssWorker as any).default(),
|
||||
scss: () => new (cssWorker as any).default(),
|
||||
less: () => new (cssWorker as any).default(),
|
||||
html: () => new (htmlWorker as any).default(),
|
||||
handlebars: () => new (htmlWorker as any).default(),
|
||||
razor: () => new (htmlWorker as any).default(),
|
||||
javascript: () => new (tsWorker as any).default(),
|
||||
typescript: () => new (tsWorker as any).default(),
|
||||
default: () => new (editorWorker as any).default(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Design system imports
|
||||
@import './styles/tokens/motion';
|
||||
@import './styles/mixins';
|
||||
|
||||
// Monaco Editor styles (lazy-loaded with editor)
|
||||
@import 'monaco-editor/min/vs/editor/editor.main.css';
|
||||
|
||||
/* Global motion helpers */
|
||||
|
||||
457
src/Web/StellaOps.Web/src/styles/_mixins.scss
Normal file
457
src/Web/StellaOps.Web/src/styles/_mixins.scss
Normal file
@@ -0,0 +1,457 @@
|
||||
// =============================================================================
|
||||
// Shared SCSS Mixins - Bundle Optimization
|
||||
// =============================================================================
|
||||
// These mixins consolidate common patterns to reduce component CSS size.
|
||||
// Import with: @use 'styles/mixins' as m;
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Design Tokens (CSS Custom Properties fallbacks)
|
||||
// -----------------------------------------------------------------------------
|
||||
$color-surface: #ffffff !default;
|
||||
$color-surface-secondary: #f8fafc !default;
|
||||
$color-border: #e2e8f0 !default;
|
||||
$color-text-primary: #1e293b !default;
|
||||
$color-text-secondary: #64748b !default;
|
||||
$color-text-muted: #94a3b8 !default;
|
||||
$color-brand: #4f46e5 !default;
|
||||
$color-brand-light: rgba(79, 70, 229, 0.1) !default;
|
||||
|
||||
// Severity colors
|
||||
$severity-critical: #dc2626 !default;
|
||||
$severity-high: #ea580c !default;
|
||||
$severity-medium: #f59e0b !default;
|
||||
$severity-low: #22c55e !default;
|
||||
$severity-info: #3b82f6 !default;
|
||||
|
||||
// Spacing
|
||||
$spacing-xs: 0.25rem !default;
|
||||
$spacing-sm: 0.5rem !default;
|
||||
$spacing-md: 1rem !default;
|
||||
$spacing-lg: 1.5rem !default;
|
||||
$spacing-xl: 2rem !default;
|
||||
|
||||
// Border radius
|
||||
$radius-sm: 0.375rem !default;
|
||||
$radius-md: 0.5rem !default;
|
||||
$radius-lg: 0.75rem !default;
|
||||
$radius-xl: 1rem !default;
|
||||
|
||||
// Shadows
|
||||
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05) !default;
|
||||
$shadow-md: 0 1px 3px rgba(0, 0, 0, 0.1) !default;
|
||||
$shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1) !default;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Layout Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Flex container with common settings
|
||||
@mixin flex-row($gap: $spacing-md, $align: center) {
|
||||
display: flex;
|
||||
align-items: $align;
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
@mixin flex-col($gap: $spacing-md) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
@mixin flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/// Grid with auto-fit columns
|
||||
@mixin auto-grid($min-width: 200px, $gap: $spacing-md) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax($min-width, 1fr));
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Component Base Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Card/Panel base styling
|
||||
@mixin card-base($padding: $spacing-md) {
|
||||
padding: $padding;
|
||||
background: $color-surface;
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid $color-border;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
/// Panel with header section
|
||||
@mixin panel-base {
|
||||
@include card-base($spacing-lg);
|
||||
}
|
||||
|
||||
/// Stat card styling
|
||||
@mixin stat-card {
|
||||
@include flex-col($spacing-xs);
|
||||
align-items: center;
|
||||
@include card-base;
|
||||
}
|
||||
|
||||
/// Toolbar container
|
||||
@mixin toolbar {
|
||||
@include flex-row;
|
||||
flex-wrap: wrap;
|
||||
@include card-base;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Form Element Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Base input styling
|
||||
@mixin input-base {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.875rem;
|
||||
background: $color-surface;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: $color-brand;
|
||||
box-shadow: 0 0 0 3px $color-brand-light;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select dropdown
|
||||
@mixin select-base {
|
||||
@include input-base;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/// Search box container
|
||||
@mixin search-box($max-width: 400px) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
max-width: $max-width;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/// Filter group (label + control)
|
||||
@mixin filter-group {
|
||||
@include flex-col($spacing-xs);
|
||||
|
||||
label,
|
||||
&__label {
|
||||
font-size: 0.75rem;
|
||||
color: $color-text-secondary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Typography Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@mixin heading-lg {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
@mixin heading-md {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
@mixin text-secondary {
|
||||
color: $color-text-secondary;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@mixin text-label {
|
||||
font-size: 0.75rem;
|
||||
color: $color-text-secondary;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@mixin text-mono {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Badge/Chip Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Base badge styling
|
||||
@mixin badge-base($bg: $color-surface-secondary, $color: $color-text-primary) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
/// Severity badge with color variants
|
||||
@mixin severity-badge($severity) {
|
||||
$colors: (
|
||||
'critical': $severity-critical,
|
||||
'high': $severity-high,
|
||||
'medium': $severity-medium,
|
||||
'low': $severity-low,
|
||||
'info': $severity-info,
|
||||
);
|
||||
|
||||
$color: map-get($colors, $severity);
|
||||
@if $color {
|
||||
@include badge-base(rgba($color, 0.1), $color);
|
||||
border: 1px solid rgba($color, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all severity badge classes
|
||||
@mixin severity-badge-variants {
|
||||
&--critical,
|
||||
&.critical {
|
||||
@include severity-badge('critical');
|
||||
}
|
||||
&--high,
|
||||
&.high {
|
||||
@include severity-badge('high');
|
||||
}
|
||||
&--medium,
|
||||
&.medium {
|
||||
@include severity-badge('medium');
|
||||
}
|
||||
&--low,
|
||||
&.low {
|
||||
@include severity-badge('low');
|
||||
}
|
||||
&--info,
|
||||
&.info {
|
||||
@include severity-badge('info');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Message/Alert Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@mixin message-base {
|
||||
padding: $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@mixin message-info {
|
||||
@include message-base;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border: 1px solid #7dd3fc;
|
||||
}
|
||||
|
||||
@mixin message-success {
|
||||
@include message-base;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
@mixin message-warning {
|
||||
@include message-base;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
@mixin message-error {
|
||||
@include message-base;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Button Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@mixin btn-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, opacity 0.15s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-primary {
|
||||
@include btn-base;
|
||||
background: $color-brand;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: darken($color-brand, 8%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-secondary {
|
||||
@include btn-base;
|
||||
background: $color-surface-secondary;
|
||||
color: $color-text-primary;
|
||||
border: 1px solid $color-border;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: darken($color-surface-secondary, 3%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-ghost {
|
||||
@include btn-base;
|
||||
background: transparent;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $color-surface-secondary;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-icon {
|
||||
@include btn-ghost;
|
||||
padding: $spacing-sm;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Table Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@mixin table-base {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: $color-surface;
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@mixin table-header {
|
||||
background: $color-surface-secondary;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: $color-text-secondary;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@mixin table-cell {
|
||||
padding: $spacing-md;
|
||||
border-bottom: 1px solid $color-border;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@mixin table-row-hover {
|
||||
&:hover {
|
||||
background: $color-surface-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Scrollbar Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@mixin custom-scrollbar($width: 8px) {
|
||||
&::-webkit-scrollbar {
|
||||
width: $width;
|
||||
height: $width;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $color-border;
|
||||
border-radius: $width;
|
||||
|
||||
&:hover {
|
||||
background: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utility Mixins
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// Truncate text with ellipsis
|
||||
@mixin truncate($max-width: 100%) {
|
||||
max-width: $max-width;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/// Visually hidden but accessible
|
||||
@mixin visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/// Loading skeleton
|
||||
@mixin skeleton {
|
||||
background: linear-gradient(90deg, $color-surface-secondary 25%, $color-border 50%, $color-surface-secondary 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty state container
|
||||
@mixin empty-state {
|
||||
@include flex-col;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl * 2;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
Reference in New Issue
Block a user