Menu

Change language

GitHub repo

Features — 2×2 Spotlight

blockmarketing

Four large feature panels in a two-by-two grid, each with a generous illustration slot built from primitives, a headline, supporting copy, and a quiet inline link.

Installation

$npx uipkge-ng@latest add features-2x2-spotlight
Manifest:https://uipkge.dev/r/angular/features-2x2-spotlight.jsonInstalls to:src/app/components/blocks/features-2x2-spotlight/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SpotlightPanel
interface SpotlightPanel {
  eyebrow: string
  title: string
  body: string
  link: string
  kind: 'lineage' | 'access' | 'budget' | 'release'
}

Files installed (2)

  • src/app/components/blocks/features-2x2-spotlight/features-2x2-spotlight.component.ts7.9 kB
    // Four large feature panels in a two-by-two grid, each with a generous illustration
    // slot built from primitives, a headline, supporting copy, and a quiet inline link.
    // Port of the React block 1:1 — same four panels, same lineage/access/budget/release
    // illustration slots, same copy.
    import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
    import { ArrowRight, LucideAngularModule } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export interface SpotlightPanel {
      eyebrow: string
      title: string
      body: string
      link: string
      kind: 'lineage' | 'access' | 'budget' | 'release'
    }
    
    const defaultPanels: SpotlightPanel[] = [
      {
        eyebrow: 'Modelling',
        title: 'One definition, every surface',
        body: 'Dashboards, exports, and the API all resolve the same certified metric, so the number cannot disagree with itself.',
        link: 'See the metric layer',
        kind: 'lineage',
      },
      {
        eyebrow: 'Governance',
        title: 'Permissions you can prove',
        body: 'Scope resolves per query from your identity provider, and every evaluation lands in an exportable audit trail.',
        link: 'Read the access model',
        kind: 'access',
      },
      {
        eyebrow: 'Performance',
        title: 'Budgeted, not throttled',
        body: 'Each team gets a query budget enforced at run time, with materialised aggregates carrying the frequent load.',
        link: 'See the caching rules',
        kind: 'budget',
      },
      {
        eyebrow: 'Delivery',
        title: 'Ship without a redeploy',
        body: 'Publishing a definition change rolls out to every consumer immediately, and reverting is the same one click.',
        link: 'How releases work',
        kind: 'release',
      },
    ]
    
    const lineage = ['warehouse.orders', 'metrics.revenue_net', 'dashboard.exec_summary']
    
    const access = [
      { label: 'EMEA analyst', value: 'Region-scoped' },
      { label: 'Finance lead', value: 'Full ledger' },
      { label: 'Contractor', value: 'No payroll' },
    ]
    
    const budgets = [
      { team: 'Finance', used: 62 },
      { team: 'RevOps', used: 38 },
      { team: 'Product', used: 81 },
    ]
    
    const releases = [
      { version: 'v128', note: 'revenue_net → trailing 28d' },
      { version: 'v127', note: 'added margin_by_channel' },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-features-2x2-spotlight, [ui-features-2x2-spotlight]',
      standalone: true,
      // React renders the root <section> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiProgressComponent,
        UiSeparatorComponent,
      ],
      template: `
        <section data-slot="features-2x2-spotlight" [class]="rootClass">
          <div class="mx-auto max-w-6xl px-6 py-20 lg:py-28">
            <div class="max-w-2xl">
              <span ui-badge variant="secondary">Platform</span>
              <h2 class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Four things it does properly</h2>
              <p class="text-muted-foreground mt-3 text-lg">
                Each panel is a mechanism, not a promise — the illustration is the real shape of the feature.
              </p>
            </div>
    
            <div class="mt-12 grid gap-5 md:grid-cols-2">
              @for (panel of panels; track panel.title) {
                <div ui-card class="group overflow-hidden">
                  <div ui-card-content class="flex h-full flex-col p-0">
                    <!-- Illustration slot: built from primitives so it themes with the
                         page instead of shipping a baked screenshot. -->
                    <div class="bg-muted/40 border-border border-b p-6">
                      @if (panel.kind === 'lineage') {
                        <div class="space-y-2">
                          @for (node of lineage; track node; let i = $index) {
                            <div
                              class="border-border bg-card rounded-md border px-3 py-2 font-mono text-xs"
                              [class.ml-8]="i === lineage.length - 1"
                              [class.ml-4]="i === 1"
                            >
                              {{ node }}
                            </div>
                          }
                        </div>
                      } @else if (panel.kind === 'access') {
                        <div class="divide-border border-border bg-card divide-y rounded-md border">
                          @for (row of access; track row.label) {
                            <div class="flex items-center justify-between gap-3 px-3 py-2">
                              <span class="text-xs font-medium">{{ row.label }}</span>
                              <span class="text-muted-foreground font-mono text-xs">{{ row.value }}</span>
                            </div>
                          }
                        </div>
                      } @else if (panel.kind === 'budget') {
                        <div class="space-y-3">
                          @for (row of budgets; track row.team) {
                            <div>
                              <div class="flex items-baseline justify-between gap-3">
                                <span class="text-xs font-medium">{{ row.team }}</span>
                                <span class="text-muted-foreground font-mono text-xs">{{ row.used }}%</span>
                              </div>
                              <div
                                ui-progress
                                [value]="row.used"
                                [aria-label]="row.team + ' budget used'"
                                class="mt-1.5 h-1.5"
                              ></div>
                            </div>
                          }
                        </div>
                      } @else {
                        <div class="space-y-3">
                          @for (release of releases; track release.version) {
                            <div class="flex items-baseline gap-3">
                              <span class="text-primary font-mono text-xs">{{ release.version }}</span>
                              <span class="text-muted-foreground min-w-0 truncate text-xs">{{ release.note }}</span>
                            </div>
                          }
                          <ui-separator />
                          <p class="text-muted-foreground text-xs">Rolled out to 41 consumers, no redeploy.</p>
                        </div>
                      }
                    </div>
    
                    <div class="flex flex-1 flex-col p-6">
                      <p class="text-muted-foreground font-mono text-xs tracking-[0.14em] uppercase">
                        {{ panel.eyebrow }}
                      </p>
                      <h3 class="mt-2 text-lg font-semibold tracking-tight">{{ panel.title }}</h3>
                      <p class="text-muted-foreground mt-2 text-sm leading-relaxed">{{ panel.body }}</p>
                      <button ui-button variant="link" class="mt-auto h-auto justify-start p-0 pt-4 text-sm">
                        {{ panel.link }}
                        <lucide-icon
                          [img]="ArrowRight"
                          class="ml-1.5 size-3.5 transition-transform group-hover:translate-x-0.5"
                          aria-hidden="true"
                        />
                      </button>
                    </div>
                  </div>
                </div>
              }
            </div>
          </div>
        </section>
      `,
    })
    export class UiFeatures2x2SpotlightComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly lineage = lineage
      protected readonly access = access
      protected readonly budgets = budgets
      protected readonly releases = releases
    
      @Input() panels: SpotlightPanel[] = defaultPanels.map((p) => ({ ...p }))
      @Input('class') className?: string
    
      get rootClass(): string {
        return cn('bg-background', this.className)
      }
    }
    
  • src/app/components/blocks/features-2x2-spotlight/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/features-2x2-spotlight.json