Newsletter
blockmarketingNewsletter 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
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/newsletter.json$npx shadcn@latest add https://uipkge.dev/r/lit/newsletter.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/newsletter.json$bunx shadcn@latest add https://uipkge.dev/r/lit/newsletter.jsonManifest:
https://uipkge.dev/r/lit/newsletter.jsonInstalls to:src/uip/blocks/newsletter/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | NewsletterVariant | inline-band | optional |
publication-name | string | The Unbundled Engineer | optional |
subtitle | string | Weekly architectural teardowns of modern design systems, web performance, and component registries. | optional |
subscriber-count | string | 42,500+ Subscribers · Top 1% on Substack | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ChannelOptioninterface ChannelOption {
id: string
title: string
frequency: string
description: string
badge: string
}ArchiveIssueinterface 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
}HTMLElementTagNameMapinterface 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="[email protected]" 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="[email protected]" .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 • 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} • 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="[email protected]" 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’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 “${this.archiveQuery}” in ${this.archiveCategory}. </p> <div class="pt-2"> <uip-button variant="outline" size="sm" @click=${this.clearArchiveFilters}> Clear search & 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