Menu

Change language

GitHub repo

Newsletter

blockmarketing

Newsletter growth quartet: a slim inline signup band, a filterable issue archive with featured spotlight, centered and split signup forms, and a split section pairing the pitch with a preview of the last issue.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/newsletter.json
Manifest:https://uipkge.dev/r/lit/newsletter.jsonInstalls to:src/uip/blocks/newsletter/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
variantNewsletterVariantinline-bandoptional
publication-namestringThe Unbundled Engineeroptional
subtitlestringWeekly architectural teardowns of modern design systems, web performance, and component registries.optional
subscriber-countstring42,500+ Subscribers · Top 1% on Substackoptional

Schema

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

ChannelOption
interface ChannelOption {
  id: string
  title: string
  frequency: string
  description: string
  badge: string
}
ArchiveIssue
interface ArchiveIssue {
  id: string
  issueNumber: number
  volumeDate: string
  title: string
  excerpt: string
  category: 'Architecture' | 'Design Tokens' | 'Performance' | 'Interviews'
  tags: string[]
  readTime: string
  date: string
  commentsCount: number
  likesCount: number
  featured?: boolean
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-newsletter': UipNewsletter
  }
}

Files installed (1)

  • src/uip/blocks/newsletter/newsletter.ts43.1 kB
    import { LitElement, css, html, nothing } from 'lit'
    import {
      ArrowRight,
      BellRing,
      BookOpen,
      Bookmark,
      Calendar,
      Check,
      Clock,
      Heart,
      Mail,
      MessageSquare,
      Newspaper,
      Rss,
      Search,
      Send,
      Users,
      X,
      Zap,
    } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/card/card'
    import '../../components/input/input'
    import type { UipInput } from '../../components/input/input'
    import '../../components/separator/separator'
    
    export type NewsletterVariant = 'inline-band' | 'archive' | 'signup-centered' | 'signup-split' | 'split-preview'
    
    interface ChannelOption {
      id: string
      title: string
      frequency: string
      description: string
      badge: string
    }
    
    const channels: ChannelOption[] = [
      {
        id: 'architecture',
        title: 'Architecture & Token Deep Dives',
        frequency: 'Bi-Weekly',
        description: 'Technical breakdowns on OKLCH color science, headless AST transformers, and zero-lockin paradigms.',
        badge: 'Flagship',
      },
      {
        id: 'releases',
        title: 'Registry Ship Notes & RFCs',
        frequency: 'Monthly',
        description: 'Direct changelogs, new workbench releases, breaking primitive refactors, and roadmap discussions.',
        badge: 'Changelog',
      },
      {
        id: 'security',
        title: 'Security & Dependency Bulletins',
        frequency: 'As-Needed',
        description: 'Zero-day vulnerability alerts, upstream headless primitive fixes, and patch notifications.',
        badge: 'Critical',
      },
    ]
    
    const signupIssues = [
      {
        id: 48,
        date: 'Aug 2026',
        title: 'The Death of Monolithic npm UI Packages',
        reads: '4 min read',
        tags: ['Architecture', 'Registry', 'AST'],
        snippet:
          'Why the next decade of frontend engineering belongs to unbundled registries where the consumer owns the source code.',
      },
      {
        id: 47,
        date: 'Jul 2026',
        title: 'OKLCH Theming in Tailwind CSS v4',
        reads: '6 min read',
        tags: ['Tailwind v4', 'Color Science', 'Tokens'],
        snippet:
          'Mastering perceptual lightness, chroma gamut mapping, and dynamic theme switching without runtime CSS bloat.',
      },
    ]
    
    const lastIssue = {
      subject: 'Why we version metric definitions instead of dashboards',
      date: 'Feb 18, 2026',
      readTime: '9 min',
      opening:
        'Dashboards are the output. The definition is the thing teams argue about, so that is what belongs under review — with a diff, an owner, and a revert path. This issue walks through what changed when we moved ours into the repo.',
    }
    
    const archiveCategories = ['All Issues', 'Architecture', 'Design Tokens', 'Performance', 'Interviews'] as const
    
    interface ArchiveIssue {
      id: string
      issueNumber: number
      volumeDate: string
      title: string
      excerpt: string
      category: 'Architecture' | 'Design Tokens' | 'Performance' | 'Interviews'
      tags: string[]
      readTime: string
      date: string
      commentsCount: number
      likesCount: number
      featured?: boolean
    }
    
    const featuredIssue: ArchiveIssue = {
      id: 'issue-43',
      issueNumber: 43,
      volumeDate: 'Aug 2026',
      title: 'The Death of npm Component Libraries: Why Unbundled Registry Architecture Won',
      excerpt:
        'How unbundled UI registries replaced monolithic node_modules packages, eliminated dependency hell, and gave full code ownership back to frontend engineering teams across the industry.',
      category: 'Architecture',
      tags: ['#architecture', '#registries', '#future-of-web', '#tooling'],
      readTime: '9 min read',
      date: 'Aug 25, 2026',
      commentsCount: 128,
      likesCount: 1420,
      featured: true,
    }
    
    const archiveIssues: ArchiveIssue[] = [
      {
        id: 'issue-42',
        issueNumber: 42,
        volumeDate: 'Aug 2026',
        title: 'Why Zero-Dependency Registries Are Winning',
        excerpt:
          'A deep dive into why enterprise engineering teams are abandoning monolithic UI packages in favor of composable own-your-code registry models that eliminate breaking upgrade cascades.',
        category: 'Architecture',
        tags: ['#architecture', '#web-performance', '#dx'],
        readTime: '6 min read',
        date: 'Aug 18, 2026',
        commentsCount: 84,
        likesCount: 642,
      },
      {
        id: 'issue-41',
        issueNumber: 41,
        volumeDate: 'Aug 2026',
        title: 'Mastering Tailwind v4: OKLCH Colors & Dynamic Themes',
        excerpt:
          'How modern CSS color spaces and inline theme definitions unlock mathematically perceptually uniform light/dark transitions without CSS variables explosion.',
        category: 'Design Tokens',
        tags: ['#design-tokens', '#css', '#theming'],
        readTime: '8 min read',
        date: 'Aug 11, 2026',
        commentsCount: 56,
        likesCount: 519,
      },
      {
        id: 'issue-40',
        issueNumber: 40,
        volumeDate: 'Aug 2026',
        title: 'Zero-CLS Island Hydration in Modern Web Frameworks',
        excerpt:
          'Eliminating Cumulative Layout Shift when server-rendered islands hydrate in Astro and Nuxt. Practical patterns distilled from serving 1M+ monthly pageviews.',
        category: 'Performance',
        tags: ['#performance', '#core-web-vitals', '#astro'],
        readTime: '5 min read',
        date: 'Aug 04, 2026',
        commentsCount: 92,
        likesCount: 730,
      },
      {
        id: 'issue-39',
        issueNumber: 39,
        volumeDate: 'Jul 2026',
        title: 'Interview: Building High-Craft Interfaces with Paco Coursey',
        excerpt:
          'The creator of cmdk and sonner breaks down spring physics, popover placement math, and why 60fps micro-interactions define software brand trust.',
        category: 'Interviews',
        tags: ['#interviews', '#craft', '#animation'],
        readTime: '11 min read',
        date: 'Jul 28, 2026',
        commentsCount: 147,
        likesCount: 1205,
      },
      {
        id: 'issue-38',
        issueNumber: 38,
        volumeDate: 'Jul 2026',
        title: 'Building Resilient Component APIs with Polymorphic Slots',
        excerpt:
          'Why the asChild composition pattern beat standard prop drilling for accessible primitives, and how Reka UI implements headless polymorphism without DOM overhead.',
        category: 'Architecture',
        tags: ['#architecture', '#vue', '#reka-ui'],
        readTime: '7 min read',
        date: 'Jul 21, 2026',
        commentsCount: 63,
        likesCount: 488,
      },
      {
        id: 'issue-37',
        issueNumber: 37,
        volumeDate: 'Jul 2026',
        title: 'Designing for Multi-Tenant White-Labeling at Scale',
        excerpt:
          'Architecting a headless token graph that dynamically maps corporate brand identities and custom color palettes across 500+ enterprise subdomains in real time.',
        category: 'Design Tokens',
        tags: ['#design-tokens', '#architecture', '#enterprise'],
        readTime: '9 min read',
        date: 'Jul 14, 2026',
        commentsCount: 71,
        likesCount: 610,
      },
    ]
    
    /**
     * <uip-newsletter> — the registry Newsletter block collection as a web component.
     *
     * One block, one install unit: `variant` selects the section ('inline-band' |
     * 'archive' | 'signup-centered' | 'signup-split' | 'split-preview'); the
     * archive takes `publication-name`, `subtitle` and `subscriber-count`.
     * Class strings are React's verbatim per file. React state (useState) becomes
     * @state fields; every form dispatches a `subscribe` CustomEvent (detail:
     * { email }, bubbles, composed) — React has no submit callback. The search
     * field's prefix icon moves into <uip-input>'s prefix slot (same visual
     * result, no overlay positioning to maintain).
     *
     * NOTE: React's `variant` prop on NewsletterSignup ('centered' | 'split')
     * never changes its output — both stories render the same channel-selector
     * layout. Preserved here 1:1.
     */
    export class UipNewsletter extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        variant: { reflect: true },
        publicationName: { attribute: 'publication-name' },
        subtitle: {},
        subscriberCount: { attribute: 'subscriber-count' },
        inlineEmail: { state: true },
        inlineDone: { state: true },
        archiveEmail: { state: true },
        archiveDone: { state: true },
        archiveQuery: { state: true },
        archiveCategory: { state: true },
        signupEmail: { state: true },
        signupChannel: { state: true },
        signupDone: { state: true },
        signupPreview: { state: true },
        signupSubmitting: { state: true },
        previewEmail: { state: true },
        previewDone: { state: true },
      }
    
      variant: NewsletterVariant = 'inline-band'
      publicationName = 'The Unbundled Engineer'
      subtitle = 'Weekly architectural teardowns of modern design systems, web performance, and component registries.'
      subscriberCount = '42,500+ Subscribers · Top 1% on Substack'
      private inlineEmail = ''
      private inlineDone = false
      private archiveEmail = ''
      private archiveDone = false
      private archiveQuery = ''
      private archiveCategory = 'All Issues'
      private signupEmail = ''
      private signupChannel = 'architecture'
      private signupDone = false
      private signupPreview = 0
      private signupSubmitting = false
      private previewEmail = ''
      private previewDone = false
      private timer?: number
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'newsletter')
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.timer) window.clearTimeout(this.timer)
      }
    
      private emitSubscribe(email: string) {
        this.dispatchEvent(new CustomEvent('subscribe', { detail: { email }, bubbles: true, composed: true }))
      }
    
      private get filteredIssues() {
        const query = this.archiveQuery.trim().toLowerCase()
        return archiveIssues.filter((issue) => {
          const matchesCategory = this.archiveCategory === 'All Issues' || issue.category === this.archiveCategory
          const matchesSearch =
            !query ||
            issue.title.toLowerCase().includes(query) ||
            issue.excerpt.toLowerCase().includes(query) ||
            issue.tags.some((tag) => tag.toLowerCase().includes(query)) ||
            `issue #${issue.issueNumber}`.toLowerCase().includes(query)
          return matchesCategory && matchesSearch
        })
      }
    
      private categoryCount(cat: string) {
        if (cat === 'All Issues') return archiveIssues.length
        return archiveIssues.filter((issue) => issue.category === cat).length
      }
    
      private clearArchiveFilters() {
        this.archiveQuery = ''
        this.archiveCategory = 'All Issues'
      }
    
      render() {
        switch (this.variant) {
          case 'archive':
            return this.renderArchive()
          case 'signup-centered':
          case 'signup-split':
            return this.renderSignup()
          case 'split-preview':
            return this.renderSplitPreview()
          default:
            return this.renderInlineBand()
        }
      }
    
      private renderInlineBand() {
        return html`
          <section data-slot="newsletter-inline-band" class="border-border bg-muted/40 border-y">
            <div class="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-10 lg:flex-row lg:items-center lg:gap-12">
              <div class="min-w-0 lg:flex-1">
                <div class="flex items-center gap-2">
                  ${icon(Mail, 'mail', 'text-muted-foreground size-4')}
                  <uip-badge variant="secondary">Monthly</uip-badge>
                </div>
                <p class="mt-2.5 text-lg leading-snug font-medium text-balance">
                  Engineering notes on metric modelling, access control, and warehouse cost.
                </p>
                <p class="text-muted-foreground mt-1.5 text-sm">
                  Read by 8,400 data and finance engineers. One email a month, no product announcements.
                </p>
              </div>
    
              <form
                class="lg:w-[26rem] lg:shrink-0"
                @submit=${(e: Event) => {
                  e.preventDefault()
                  if (!this.inlineEmail.trim()) return
                  this.inlineDone = true
                  this.emitSubscribe(this.inlineEmail)
                }}
              >
                <div class="flex gap-2">
                  <uip-input
                    .value=${this.inlineEmail}
                    type="email"
                    required
                    placeholder="you@company.com"
                    autocomplete="email"
                    aria-label="Email address"
                    @input=${(e: Event) => {
                      this.inlineEmail = (e.target as UipInput).value
                    }}
                  ></uip-input>
                  <uip-button type="submit" class="shrink-0">Subscribe</uip-button>
                </div>
                <p class="mt-2 min-h-5 text-xs" aria-live="polite">
                  ${this.inlineDone
                    ? html`<span class="text-success inline-flex items-center gap-1.5">
                        ${icon(Check, 'check', 'size-3')} Check your inbox to confirm.
                      </span>`
                    : html`<span class="text-muted-foreground">Unsubscribe in one click. We never share the list.</span>`}
                </p>
              </form>
            </div>
          </section>
        `
      }
    
      private renderSignup() {
        return html`
          <section
            data-slot="newsletter-signup"
            class="bg-background relative overflow-hidden px-4 py-16 sm:px-6 lg:px-8"
          >
            <div class="mx-auto max-w-6xl space-y-10">
              <div class="max-w-3xl space-y-3">
                <uip-badge variant="secondary" class="gap-1.5 px-3 py-1 font-mono text-xs shadow-xs">
                  ${icon(Mail, 'mail', 'text-primary size-3.5')} Engineering Dispatch & Architecture Radar
                </uip-badge>
                <h2 class="text-foreground text-3xl font-bold tracking-tight sm:text-4xl">
                  Ship notes, not marketing fluff.
                </h2>
                <p class="text-muted-foreground text-sm leading-relaxed sm:text-base">
                  Join 18,400+ frontend architects, design engineers, and systems builders. Plain-text insights into
                  component architecture, token systems, and dual-framework engineering.
                </p>
              </div>
    
              <div class="grid grid-cols-1 items-start gap-8 lg:grid-cols-12">
                <div class="space-y-6 lg:col-span-7">
                  <uip-card class="border-border bg-card overflow-hidden shadow-sm">
                    <uip-card-header class="bg-muted/20 border-border/60 border-b pb-4">
                      <div class="flex items-center justify-between">
                        <uip-card-title class="flex items-center gap-2 text-base font-semibold">
                          ${icon(BellRing, 'bell-ring', 'text-primary size-4')} Select Dispatch Track
                        </uip-card-title>
                        <span class="text-muted-foreground font-mono text-xs">Zero Spam Guarantee</span>
                      </div>
                      <uip-card-description class="text-xs">
                        Choose the telemetry streams you want delivered to your inbox.
                      </uip-card-description>
                    </uip-card-header>
    
                    <uip-card-content class="space-y-5 p-6">
                      <div class="space-y-2.5">
                        ${channels.map((ch) => {
                          const isSelected = this.signupChannel === ch.id
                          return html`
                            <div
                              class=${cn(
                                'group cursor-pointer rounded-xl border p-3.5 transition-colors',
                                isSelected
                                  ? 'border-primary bg-primary/5 ring-primary/20 shadow-xs ring-1'
                                  : 'border-border bg-background hover:bg-muted/40',
                              )}
                              @click=${() => {
                                this.signupChannel = ch.id
                              }}
                            >
                              <div class="flex items-center justify-between gap-2">
                                <div class="flex items-center gap-2">
                                  <div
                                    class=${cn(
                                      'flex size-3.5 items-center justify-center rounded-full border transition-colors',
                                      isSelected ? 'border-primary bg-primary' : 'border-muted-foreground/40',
                                    )}
                                  >
                                    ${isSelected
                                      ? html`<div class="bg-primary-foreground size-1.5 rounded-full"></div>`
                                      : nothing}
                                  </div>
                                  <span class="text-foreground text-xs font-semibold">${ch.title}</span>
                                </div>
                                <uip-badge variant="outline" class="font-mono text-xs">${ch.frequency}</uip-badge>
                              </div>
                              <p class="text-muted-foreground mt-1.5 pl-5.5 text-xs leading-relaxed">${ch.description}</p>
                            </div>
                          `
                        })}
                      </div>
    
                      <uip-separator></uip-separator>
    
                      ${!this.signupDone
                        ? html`
                            <form
                              class="space-y-3"
                              @submit=${(e: Event) => {
                                e.preventDefault()
                                if (!this.signupEmail || !this.signupEmail.includes('@')) return
                                this.signupSubmitting = true
                                const email = this.signupEmail
                                this.timer = window.setTimeout(() => {
                                  this.signupSubmitting = false
                                  this.signupDone = true
                                  this.emitSubscribe(email)
                                }, 400)
                              }}
                            >
                              <div class="flex flex-col gap-2 sm:flex-row">
                                <uip-input
                                  type="email"
                                  placeholder="architect@company.com"
                                  .value=${this.signupEmail}
                                  required
                                  class="h-10 flex-1 font-mono text-xs"
                                  aria-label="Email address for dispatch"
                                  @input=${(e: Event) => {
                                    this.signupEmail = (e.target as UipInput).value
                                  }}
                                ></uip-input>
                                <uip-button
                                  type="submit"
                                  ?disabled=${this.signupSubmitting}
                                  class="h-10 gap-1.5 px-5 text-xs font-semibold"
                                >
                                  <span>Subscribe to Stream</span>
                                  ${icon(Send, 'send', 'size-3.5')}
                                </uip-button>
                              </div>
                              <div class="text-muted-foreground flex items-center justify-between pt-1 font-mono text-xs">
                                <span>Plain text &bull; 1-click unsubscribe</span>
                                <span class="text-success font-semibold">18,412 Active Subscribers</span>
                              </div>
                            </form>
                          `
                        : html`
                            <div class="border-success/20 bg-success/10 space-y-2 rounded-xl border p-5 text-center">
                              <div class="bg-success/20 text-success mx-auto flex size-9 items-center justify-center rounded-full">
                                ${icon(Check, 'check', 'size-5')}
                              </div>
                              <p class="text-foreground text-sm font-semibold">Dispatches Activated!</p>
                              <p class="text-muted-foreground text-xs">
                                Verification sent to <strong class="text-foreground font-mono">${this.signupEmail}</strong>.
                                Check your inbox to confirm delivery.
                              </p>
                            </div>
                          `}
                    </uip-card-content>
                  </uip-card>
                </div>
    
                <div class="space-y-4 lg:col-span-5">
                  <div class="flex items-center justify-between">
                    <p class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs tracking-wider uppercase">
                      ${icon(BookOpen, 'book-open', 'text-primary size-3.5')} Archive Sampler
                    </p>
                    <span class="text-muted-foreground font-mono text-xs">
                      Issue #${signupIssues[this.signupPreview].id}
                    </span>
                  </div>
    
                  <div class="space-y-3">
                    ${signupIssues.map(
                      (issue, idx) => html`
                        <div
                          class=${cn(
                            'bg-card cursor-pointer rounded-xl border p-4 transition-colors',
                            this.signupPreview === idx
                              ? 'border-primary/50 shadow-xs'
                              : 'border-border hover:border-border/80',
                          )}
                          @click=${() => {
                            this.signupPreview = idx
                          }}
                        >
                          <div class="text-muted-foreground mb-1.5 flex items-center justify-between font-mono text-xs">
                            <span> ${issue.date} &bull; Issue #${issue.id} </span>
                            <span class="text-foreground font-medium">${issue.reads}</span>
                          </div>
                          <h3 class="text-foreground mb-1.5 text-sm leading-snug font-bold">${issue.title}</h3>
                          <p class="text-muted-foreground text-xs leading-relaxed">${issue.snippet}</p>
    
                          <div class="mt-3 flex flex-wrap gap-1.5">
                            ${issue.tags.map(
                              (tag) => html`<uip-badge variant="secondary" class="px-1.5 py-0 font-mono text-xs">
                                #${tag}
                              </uip-badge>`,
                            )}
                          </div>
                        </div>
                      `,
                    )}
                  </div>
    
                  <div
                    class="border-border bg-muted/20 text-muted-foreground flex items-center justify-between rounded-lg border p-3.5 text-xs"
                  >
                    <span class="font-mono">Prefer RSS feeds?</span>
                    <a
                      href="https://uipkge.dev/rss.xml"
                      target="_blank"
                      rel="noreferrer"
                      class="text-primary inline-flex items-center gap-1 font-mono hover:underline"
                    >
                      uipkge.dev/rss.xml ${icon(ArrowRight, 'arrow-right', 'size-3')}
                    </a>
                  </div>
                </div>
              </div>
            </div>
          </section>
        `
      }
    
      private renderSplitPreview() {
        return html`
          <section data-slot="newsletter-split-preview" class="bg-background">
            <div class="mx-auto max-w-6xl px-6 py-20 lg:py-28">
              <div class="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
                <div>
                  <uip-badge variant="secondary" class="gap-1.5">
                    ${icon(Mail, 'mail', 'size-3')} Monthly
                  </uip-badge>
                  <h2 class="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
                    Notes from the people who build it
                  </h2>
                  <p class="text-muted-foreground mt-3 text-lg">
                    One email a month on metric modelling, access control, and warehouse cost. No product announcements.
                  </p>
    
                  <form
                    class="mt-6 max-w-md"
                    @submit=${(e: Event) => {
                      e.preventDefault()
                      if (!this.previewEmail.trim()) return
                      this.previewDone = true
                      this.emitSubscribe(this.previewEmail)
                    }}
                  >
                    <div class="flex gap-2">
                      <uip-input
                        .value=${this.previewEmail}
                        type="email"
                        required
                        placeholder="you@company.com"
                        autocomplete="email"
                        aria-label="Email address"
                        @input=${(e: Event) => {
                          this.previewEmail = (e.target as UipInput).value
                        }}
                      ></uip-input>
                      <uip-button type="submit" class="shrink-0">Subscribe</uip-button>
                    </div>
                    <p class="mt-2 min-h-5 text-xs" aria-live="polite">
                      ${this.previewDone
                        ? html`<span class="text-success inline-flex items-center gap-1.5">
                            ${icon(Check, 'check', 'size-3')} Check your inbox to confirm.
                          </span>`
                        : html`<span class="text-muted-foreground"
                            >8,400 subscribers · unsubscribe in one click.</span
                          >`}
                    </p>
                  </form>
                </div>
    
                <uip-card>
                  <uip-card-content class="p-6">
                    <div class="flex flex-wrap items-center gap-3">
                      <uip-badge variant="outline">Last issue</uip-badge>
                      <span class="text-muted-foreground text-xs"> ${lastIssue.date} · ${lastIssue.readTime} </span>
                    </div>
    
                    <h3 class="mt-4 text-lg leading-snug font-semibold tracking-tight text-balance">${lastIssue.subject}</h3>
    
                    <uip-separator class="[&::part(base)]:my-4"></uip-separator>
    
                    <p class="text-muted-foreground text-sm leading-relaxed">${lastIssue.opening}</p>
    
                    <uip-button variant="link" class="mt-4 h-auto p-0 text-sm">Read the full issue</uip-button>
                  </uip-card-content>
                </uip-card>
              </div>
            </div>
          </section>
        `
      }
    
      private renderArchive() {
        const filtered = this.filteredIssues
        return html`
          <div data-slot="newsletter-issue-archive" class="mx-auto w-full max-w-5xl space-y-12 px-4 py-8 sm:px-6 sm:py-12">
            <header class="mx-auto max-w-3xl space-y-4 text-center">
              <div
                class="border-primary/20 bg-primary/5 text-primary inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1 text-xs font-medium shadow-xs"
              >
                ${icon(Users, 'users', 'size-3.5 shrink-0')}
                <span>${this.subscriberCount}</span>
              </div>
    
              <h1 class="text-foreground text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
                ${this.publicationName}
              </h1>
    
              <p class="text-muted-foreground mx-auto max-w-2xl text-base leading-relaxed sm:text-lg">${this.subtitle}</p>
    
              <div class="mx-auto w-full max-w-md pt-2">
                ${!this.archiveDone
                  ? html`
                      <div>
                        <form
                          class="flex flex-col gap-2 sm:flex-row sm:gap-0"
                          @submit=${(e: Event) => {
                            e.preventDefault()
                            if (!this.archiveEmail || !this.archiveEmail.includes('@')) return
                            this.archiveDone = true
                            this.emitSubscribe(this.archiveEmail)
                          }}
                        >
                          <uip-input
                            id="newsletter-archive-email-lit"
                            .value=${this.archiveEmail}
                            type="email"
                            placeholder="Enter your work email..."
                            required
                            class="bg-card h-11 text-sm shadow-xs sm:rounded-r-none sm:[&::part(control)]:rounded-r-none"
                            aria-label="Work email address"
                            @input=${(e: Event) => {
                              this.archiveEmail = (e.target as UipInput).value
                            }}
                          ></uip-input>
                          <uip-button
                            type="submit"
                            class="h-11 shrink-0 px-6 font-medium sm:rounded-l-none sm:[&::part(base)]:rounded-l-none"
                          >
                            Subscribe for Free
                          </uip-button>
                        </form>
                        <p
                          class="text-muted-foreground mt-2.5 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs"
                        >
                          <span>Free weekly issues</span>
                          <span>Zero spam</span>
                          <span>1-click unsubscribe</span>
                        </p>
                      </div>
                    `
                  : html`
                      <div
                        class="border-success/30 bg-success/10 text-success inline-flex items-center gap-2.5 rounded-lg border px-4 py-3 text-sm font-medium shadow-xs"
                      >
                        ${icon(Check, 'check', 'size-4 shrink-0')}
                        <span>You&rsquo;re subscribed! Check your inbox for confirmation.</span>
                      </div>
                    `}
              </div>
            </header>
    
            <section aria-labelledby="featured-issue-heading-lit" class="space-y-3">
              <div class="flex items-center justify-between px-1">
                <h2
                  id="featured-issue-heading-lit"
                  class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"
                >
                  Pinned Spotlight Edition
                </h2>
                <uip-badge variant="outline" class="text-primary border-primary/30 gap-1 text-xs">
                  ${icon(Zap, 'zap', 'size-3')} Latest Release
                </uip-badge>
              </div>
    
              <uip-card class="border-border/80 bg-card hover:border-primary/40 overflow-hidden shadow-xs transition-colors">
                <div class="grid grid-cols-1 gap-6 p-6 sm:p-8 lg:grid-cols-12">
                  <div class="flex flex-col justify-between space-y-4 lg:col-span-7">
                    <div class="space-y-3">
                      <div class="flex flex-wrap items-center gap-2">
                        <uip-badge variant="default" class="font-mono text-xs">
                          Issue #${featuredIssue.issueNumber} · ${featuredIssue.volumeDate}
                        </uip-badge>
                        <uip-badge variant="secondary" class="text-xs">${featuredIssue.category}</uip-badge>
                        <span class="text-muted-foreground ml-auto inline-flex items-center gap-1 text-xs">
                          ${icon(Clock, 'clock', 'size-3')} ${featuredIssue.readTime}
                        </span>
                      </div>
    
                      <h3
                        class="text-foreground hover:text-primary cursor-pointer text-xl font-bold tracking-tight transition-colors sm:text-2xl"
                      >
                        ${featuredIssue.title}
                      </h3>
    
                      <p class="text-muted-foreground text-sm leading-relaxed sm:text-base">${featuredIssue.excerpt}</p>
                    </div>
    
                    <div class="space-y-4 pt-2">
                      <div class="flex flex-wrap gap-1.5">
                        ${featuredIssue.tags.map(
                          (tag) => html`<uip-badge
                            variant="outline"
                            class="text-muted-foreground font-mono text-xs font-normal"
                          >
                            ${tag}
                          </uip-badge>`,
                        )}
                      </div>
    
                      <div class="border-border/60 flex flex-wrap items-center justify-between gap-4 border-t pt-3">
                        <div class="text-muted-foreground flex items-center gap-4 text-xs">
                          <span class="inline-flex items-center gap-1.5">
                            ${icon(Calendar, 'calendar', 'size-3.5')} ${featuredIssue.date}
                          </span>
                          <span class="inline-flex items-center gap-1.5">
                            ${icon(MessageSquare, 'message-square', 'size-3.5')} ${featuredIssue.commentsCount} comments
                          </span>
                          <span class="inline-flex items-center gap-1.5">
                            ${icon(Heart, 'heart', 'size-3.5')} ${featuredIssue.likesCount}
                          </span>
                        </div>
    
                        <uip-button variant="default" size="sm" class="group gap-1.5 font-medium">
                          Read Issue
                          ${icon(ArrowRight, 'arrow-right', 'size-3.5 transition-transform group-hover:translate-x-0.5')}
                        </uip-button>
                      </div>
                    </div>
                  </div>
    
                  <div class="flex items-center lg:col-span-5">
                    <div class="border-border/80 bg-muted/40 w-full rounded-lg border p-4 font-mono text-xs sm:p-5">
                      <div class="border-border/60 mb-3 flex items-center justify-between border-b pb-2.5">
                        <div class="flex items-center gap-1.5">
                          <span class="bg-destructive/60 inline-block size-2.5 rounded-full"></span>
                          <span class="bg-warning/60 inline-block size-2.5 rounded-full"></span>
                          <span class="bg-success/60 inline-block size-2.5 rounded-full"></span>
                        </div>
                        <span class="text-muted-foreground text-xs">registry-teardown.ts</span>
                      </div>
                      <div class="text-muted-foreground space-y-2">
                        <div class="text-primary text-xs font-medium">// Own your component code</div>
                        <div><span class="text-foreground">$</span> npx shadcn@latest add @uipkge/card</div>
                        <div class="text-success">✔ Fetched raw TypeScript TSX</div>
                        <div class="text-success">✔ Merged local Tailwind v4 tokens</div>
                        <div class="text-muted-foreground/80 pt-1">// Zero runtime package lock-in</div>
                      </div>
                      <div class="border-border/40 mt-4 flex flex-wrap gap-1.5 border-t pt-3">
                        <span class="bg-background text-foreground border-border rounded border px-2 py-0.5 text-xs">
                          Zero npm deps
                        </span>
                        <span class="bg-background text-foreground border-border rounded border px-2 py-0.5 text-xs">
                          Tailwind v4
                        </span>
                        <span class="bg-background text-foreground border-border rounded border px-2 py-0.5 text-xs">
                          OKLCH
                        </span>
                      </div>
                    </div>
                  </div>
                </div>
              </uip-card>
            </section>
    
            <section aria-label="Search and filter archive" class="space-y-4">
              <div class="flex flex-col items-stretch justify-between gap-4 md:flex-row md:items-center">
                <div class="w-full md:max-w-md">
                  <uip-input
                    .value=${this.archiveQuery}
                    type="search"
                    placeholder="Search issues by title, topic, or tag..."
                    allow-clear
                    class="bg-card shadow-xs"
                    aria-label="Search archive issues"
                    @input=${(e: Event) => {
                      this.archiveQuery = (e.target as UipInput).value
                    }}
                  >
                    <span slot="prefix">${icon(Search, 'search', 'size-4')}</span>
                  </uip-input>
                </div>
    
                <div class="text-muted-foreground flex items-center justify-between gap-3 text-xs md:justify-end">
                  <span> Showing ${filtered.length} ${filtered.length === 1 ? 'edition' : 'editions'} </span>
                  ${this.archiveQuery || this.archiveCategory !== 'All Issues'
                    ? html`<button
                        type="button"
                        class="text-primary focus-visible:ring-ring inline-flex items-center gap-1 rounded font-medium hover:underline focus-visible:ring-1 focus-visible:outline-none"
                        @click=${this.clearArchiveFilters}
                      >
                        Reset filters ${icon(X, 'x', 'size-3')}
                      </button>`
                    : nothing}
                </div>
              </div>
    
              <div class="flex flex-wrap items-center gap-1.5" role="tablist" aria-label="Filter by category">
                ${archiveCategories.map((cat) => {
                  const isSelected = this.archiveCategory === cat
                  return html`
                    <button
                      type="button"
                      role="tab"
                      aria-selected=${isSelected ? 'true' : 'false'}
                      class=${cn(
                        'focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none',
                        isSelected
                          ? 'bg-primary text-primary-foreground font-medium shadow-xs'
                          : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground',
                      )}
                      @click=${() => {
                        this.archiveCategory = cat
                      }}
                    >
                      <span>${cat}</span>
                      <span
                        class=${cn(
                          'py-0.2 rounded-full px-1.5 text-xs',
                          isSelected
                            ? 'bg-primary-foreground/20 text-primary-foreground'
                            : 'bg-background/80 text-muted-foreground',
                        )}
                      >
                        ${this.categoryCount(cat)}
                      </span>
                    </button>
                  `
                })}
              </div>
            </section>
    
            <section aria-label="Archive issue list">
              ${filtered.length > 0
                ? html`
                    <div class="grid grid-cols-1 gap-5 md:grid-cols-2">
                      ${filtered.map(
                        (issue) => html`
                          <uip-card
                            class="group border-border/70 bg-card hover:border-primary/40 flex flex-col justify-between transition-colors hover:shadow-xs"
                          >
                            <uip-card-header class="pb-3">
                              <div class="flex items-center justify-between gap-2">
                                <uip-badge variant="outline" class="font-mono text-xs">
                                  Issue #${issue.issueNumber} · ${issue.volumeDate}
                                </uip-badge>
                                <span class="text-muted-foreground inline-flex items-center gap-1 text-xs">
                                  ${icon(Clock, 'clock', 'size-3')} ${issue.readTime}
                                </span>
                              </div>
    
                              <uip-card-title
                                class="group-hover:text-primary cursor-pointer pt-2 text-base leading-snug font-semibold tracking-tight transition-colors sm:text-lg"
                              >
                                ${issue.title}
                              </uip-card-title>
    
                              <uip-card-description class="text-muted-foreground mt-1.5 line-clamp-3 text-sm leading-relaxed">
                                ${issue.excerpt}
                              </uip-card-description>
                            </uip-card-header>
    
                            <uip-card-content class="py-0">
                              <div class="flex flex-wrap gap-1.5 pt-1">
                                ${issue.tags.map(
                                  (tag) => html`<uip-badge variant="secondary" class="font-mono text-xs font-normal">
                                    ${tag}
                                  </uip-badge>`,
                                )}
                              </div>
                            </uip-card-content>
    
                            <uip-card-footer
                              class="border-border/60 text-muted-foreground mt-4 flex items-center justify-between border-t pt-4 text-xs"
                            >
                              <div class="flex items-center gap-3">
                                <span class="inline-flex items-center gap-1">
                                  ${icon(Calendar, 'calendar', 'size-3.5')} ${issue.date}
                                </span>
                                <span class="inline-flex items-center gap-1">
                                  ${icon(MessageSquare, 'message-square', 'size-3.5')} ${issue.commentsCount}
                                </span>
                                <span class="inline-flex items-center gap-1">
                                  ${icon(Heart, 'heart', 'size-3.5')} ${issue.likesCount}
                                </span>
                              </div>
    
                              <a
                                href="#read"
                                class="text-foreground group-hover:text-primary inline-flex items-center gap-1 font-medium transition-colors focus-visible:underline focus-visible:outline-none"
                              >
                                <span>Read Issue</span>
                                ${icon(ArrowRight, 'arrow-right', 'size-3.5 transition-transform group-hover:translate-x-0.5')}
                              </a>
                            </uip-card-footer>
                          </uip-card>
                        `,
                      )}
                    </div>
                  `
                : html`
                    <div class="border-border bg-card/50 space-y-3 rounded-xl border border-dashed p-10 text-center">
                      ${icon(Newspaper, 'newspaper', 'text-muted-foreground/60 mx-auto size-8')}
                      <h3 class="text-foreground text-base font-semibold">No issues found</h3>
                      <p class="text-muted-foreground mx-auto max-w-sm text-sm">
                        No newsletter issues matched &ldquo;${this.archiveQuery}&rdquo; in ${this.archiveCategory}.
                      </p>
                      <div class="pt-2">
                        <uip-button variant="outline" size="sm" @click=${this.clearArchiveFilters}>
                          Clear search &amp; filters
                        </uip-button>
                      </div>
                    </div>
                  `}
            </section>
    
            <footer
              class="border-border/80 bg-muted/30 flex flex-col items-center justify-between gap-4 rounded-xl border p-6 text-center shadow-xs sm:flex-row sm:text-left"
            >
              <div>
                <h3 class="text-foreground text-sm font-semibold">Never miss a teardown</h3>
                <p class="text-muted-foreground mt-0.5 text-xs">
                  Published every Tuesday morning. Zero sponsor promotions, purely deep technical architecture.
                </p>
              </div>
    
              <div class="flex shrink-0 items-center gap-2.5">
                <uip-button variant="outline" size="sm" class="gap-1.5 text-xs">
                  ${icon(Rss, 'rss', 'size-3.5')} RSS Feed
                </uip-button>
                <uip-button
                  variant="default"
                  size="sm"
                  class="gap-1.5 text-xs"
                  @click=${() => {
                    const input = this.renderRoot.querySelector<HTMLElement>('#newsletter-archive-email-lit')
                    input?.focus()
                  }}
                >
                  ${icon(Bookmark, 'bookmark', 'size-3.5')} Join Newsletter
                </uip-button>
              </div>
            </footer>
          </div>
        `
      }
    }
    
    customElements.get('uip-newsletter') || customElements.define('uip-newsletter', UipNewsletter)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-newsletter': UipNewsletter
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/newsletter.json