feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)

Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

@@ -0,0 +1,943 @@
<!doctype html>
<html lang="zh-CN" data-accent="ikb">
<head>
<meta charset="utf-8">
<title>[必填] 替换为社交卡组标题 · Social Card Set</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&family=Noto+Sans+SC:wght@200;300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
/* =========================================================
Swiss International — Social Card Seed Template
---------------------------------------------------------
Three boards built in one HTML:
.poster.xhs 1080×1440 (3:4 小红书)
.poster.square 1080×1080 (1:1 微信方封面)
.poster.wide 2100× 900 (21:9 微信主封面)
.pair-preview 21:9 + 1:1 配对预览
Accent switching via <html data-accent="…">:
ikb (Klein Blue) | lemon-yellow | lemon-green | safety-orange
Fill posters where marked <!-- POSTERS_HERE -->.
Each poster carries one Layout Recipe (S01-S12) from
references/layout-recipes.md.
========================================================= */
/* ---------- 1. Accent palettes (4 mutually exclusive) ---------- */
:root,
[data-accent="ikb"] {
--paper: #fafaf8;
--ink: #0a0a0a;
--grey-1: #f0f0ee;
--grey-2: #d4d4d2;
--grey-3: #737373;
--accent: #002FA7;
--accent-on: #ffffff;
}
[data-accent="lemon-yellow"] {
--paper: #fafaf8;
--ink: #0a0a0a;
--grey-1: #f0f0ee;
--grey-2: #d4d4d2;
--grey-3: #737373;
--accent: #FFD500;
--accent-on: #0a0a0a;
}
[data-accent="lemon-green"] {
--paper: #fafaf8;
--ink: #0a0a0a;
--grey-1: #f0f0ee;
--grey-2: #d4d4d2;
--grey-3: #737373;
--accent: #C5E803;
--accent-on: #0a0a0a;
}
[data-accent="safety-orange"] {
--paper: #fafaf8;
--ink: #0a0a0a;
--grey-1: #f0f0ee;
--grey-2: #d4d4d2;
--grey-3: #737373;
--accent: #FF6B35;
--accent-on: #ffffff;
}
/* ---------- 2. Typography stack ---------- */
:root {
--sans: "Inter", "Helvetica Neue", Helvetica, "Noto Sans SC",
-apple-system, "PingFang SC", "Microsoft YaHei UI", sans-serif;
--sans-zh: "Noto Sans SC", -apple-system, "PingFang SC",
"Microsoft YaHei UI", "Inter", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Consolas, monospace;
}
/* ---------- 3. Spacing tokens (8/12/16/24/32/40/48/64/80/96/160) ---------- */
:root {
--sp-3: 8px;
--sp-4: 12px;
--sp-5: 16px;
--sp-6: 24px;
--sp-7: 32px;
--sp-8: 40px;
--sp-9: 48px;
--sp-10: 64px;
--sp-11: 80px;
--sp-12: 96px;
--sp-13:160px;
}
/* ---------- 4. Reset & sheet ---------- */
*,*::before,*::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: #1a1a1a;
font-family: var(--sans);
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 64px 32px;
}
.sheet {
display: flex;
flex-direction: column;
align-items: center;
gap: 48px;
}
/* ---------- 5. Poster boards (fixed export sizes) ---------- */
.poster {
position: relative;
background: var(--paper);
color: var(--ink);
overflow: hidden;
isolation: isolate;
}
.poster.xhs { width: 1080px; height: 1440px; }
.poster.square { width: 1080px; height: 1080px; }
.poster.wide { width: 2100px; height: 900px; }
.poster figure { margin: 0; }
.poster.xhs .content { padding: var(--sp-12) var(--sp-11); }
.poster.square .content { padding: var(--sp-11) var(--sp-11); }
.poster.wide .content { padding: var(--sp-11) var(--sp-13); }
.content { position: relative; width: 100%; height: 100%; z-index: 2; }
/* Optional decorative layers — opt in per poster */
.dot-mat,
.ring-mat,
.cross-mat {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.dot-mat {
opacity: .08;
background-image: radial-gradient(var(--ink) 1.5px, transparent 1.5px);
background-size: 24px 24px;
}
.ring-mat {
opacity: .08;
background-image: radial-gradient(circle at 50% 50%, transparent 4px, var(--ink) 4.5px, var(--ink) 5px, transparent 5.5px);
background-size: 32px 32px;
}
.cross-mat {
opacity: .06;
background-image:
linear-gradient(var(--ink), var(--ink)),
linear-gradient(90deg, var(--ink), var(--ink));
background-size: 1px 14px, 14px 1px;
background-position: center, center;
background-repeat: repeat;
}
/* ---------- 6. Typography roles (Swiss extreme contrast) ----------
Hard rule: "the larger, the lighter"
≥ 200px → weight 200 (ExtraLight)
Smaller text gets heavier weight (≥ 500). */
.h-hero {
font-family: var(--sans);
font-weight: 200;
font-size: 240px;
line-height: 1.02;
letter-spacing: -.02em;
margin: 0;
color: var(--ink);
}
.h-statement {
font-family: var(--sans);
font-weight: 200;
font-size: 180px;
line-height: 1.05;
letter-spacing: -.015em;
margin: 0;
color: var(--ink);
}
.h-xl {
font-family: var(--sans);
font-weight: 300;
font-size: 120px;
line-height: 1.08;
letter-spacing: -.01em;
margin: 0;
color: var(--ink);
}
.h-md {
font-family: var(--sans);
font-weight: 400;
font-size: 56px;
line-height: 1.18;
margin: 0;
color: var(--ink);
}
.lead {
font-family: var(--sans-zh);
font-weight: 400;
font-size: 30px;
line-height: 1.55;
color: var(--ink);
margin: 0;
}
.body {
font-family: var(--sans-zh);
font-weight: 400;
font-size: 26px;
line-height: 1.6;
color: var(--ink);
margin: 0;
}
.t-cat {
font-family: var(--sans);
font-weight: 600;
font-size: 22px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--accent);
margin: 0;
}
.t-meta {
font-family: var(--mono);
font-weight: 500;
font-size: 20px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--grey-3);
margin: 0;
}
.num-mega {
font-family: var(--sans);
font-weight: 200;
font-size: 200px;
line-height: .92;
letter-spacing: -.03em;
color: var(--ink);
margin: 0;
}
.num-xl {
font-family: var(--sans);
font-weight: 200;
font-size: 144px;
line-height: .96;
letter-spacing: -.02em;
margin: 0;
}
.mono { font-family: var(--mono); }
/* XHS (3:4) boards: 1080×1440 has the tightest vertical budget,
so display sizes step down vs the .wide default. These caps
were validated by the smoke-ai-tools deck — 2-line h-xl @96px
leaves room for matrix + hero-stat-bottom inside 1440px. */
.poster.xhs .h-hero { font-size: 168px; }
.poster.xhs .h-statement { font-size: 124px; }
.poster.xhs .h-xl { font-size: 96px; }
.poster.xhs .num-mega { font-size: 168px; }
.poster.xhs .num-xl { font-size: 120px; }
/* Square boards tighten scales */
.poster.square .h-hero { font-size: 180px; }
.poster.square .h-statement { font-size: 140px; }
.poster.square .h-xl { font-size: 88px; }
.poster.square .num-mega { font-size: 168px; }
.poster.square .num-xl { font-size: 112px; }
/* Wide boards rely on horizontal real estate */
.poster.wide .h-hero { font-size: 200px; }
.poster.wide .h-statement { font-size: 156px; }
.poster.wide .h-xl { font-size: 104px; }
/* ---------- 7. Layout primitives ---------- */
.stack { display: flex; flex-direction: column; }
.row { display: flex; flex-direction: row; }
.gap-3 { gap: var(--sp-3); }
.gap-4 { gap: var(--sp-4); }
.gap-5 { gap: var(--sp-5); }
.gap-6 { gap: var(--sp-6); }
.gap-7 { gap: var(--sp-7); }
.gap-8 { gap: var(--sp-8); }
.gap-9 { gap: var(--sp-9); }
.gap-10 { gap: var(--sp-10); }
.grow { flex: 1; }
.grid-12 { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--sp-7); }
.grid-2-9 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-9); }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-7); }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-6); }
.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; }
.span-8 { grid-column: span 8; }
.span-9 { grid-column: span 9; }
.span-12 { grid-column: span 12; }
.center { text-align: center; }
.right { text-align: right; }
.uppercase{ text-transform: uppercase; }
.hr-hairline {
height: 1px;
background: var(--grey-2);
border: 0;
margin: 0;
}
.hr-accent {
height: 3px;
background: var(--accent);
border: 0;
width: 96px;
margin: 0;
}
/* ---------- 8. Card fills (mutually exclusive — pick ONE) ----------
Hard rule: never combine card-ink/card-accent/card-fill/card-outlined
on the same node. Multi-card grids must use the same class. */
.card-ink {
background: var(--ink);
color: var(--paper);
padding: var(--sp-8);
}
.card-ink .t-cat,
.card-ink .t-meta,
.card-ink .lead,
.card-ink .body,
.card-ink .h-md { color: var(--paper); }
.card-accent {
background: var(--accent);
color: var(--accent-on);
padding: var(--sp-8);
}
.card-accent .t-cat,
.card-accent .t-meta,
.card-accent .lead,
.card-accent .body,
.card-accent .h-md { color: var(--accent-on); }
.card-fill {
background: var(--grey-1);
color: var(--ink);
padding: var(--sp-8);
}
.card-outlined {
background: transparent;
color: var(--ink);
border: 1px solid var(--grey-2);
padding: var(--sp-8);
}
/* ---------- 9. Frame containers (image holders) ---------- */
.frame-img {
position: relative;
overflow: hidden;
background: var(--grey-1);
display: block;
margin: 0;
}
.frame-img img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 35%;
display: block;
}
.frame-img.fit-contain img { object-fit: contain; background: var(--paper); }
.frame-img.r-3x4 { aspect-ratio: 3 / 4; }
.frame-img.r-1x1 { aspect-ratio: 1 / 1; }
.frame-img.r-4x3 { aspect-ratio: 4 / 3; }
.frame-img.r-16x9 { aspect-ratio: 16 / 9; }
.frame-img.r-16x10 { aspect-ratio: 16 / 10; }
.frame-img.r-21x9 { aspect-ratio: 21 / 9; }
.swiss-img-caption {
font-family: var(--mono);
font-weight: 500;
font-size: 18px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--grey-3);
margin-top: var(--sp-5);
}
/* ---------- 9b. Screenshot treatment (.frame-shot) ---------- */
/* Swiss is stricter than Editorial: corners default square, shadows default
none. Override only when the shot must literally float off the page. */
.frame-shot {
position: relative;
overflow: hidden;
display: block;
margin: 0;
background: var(--grey-1);
border-radius: 0;
}
.frame-shot.corners-sq { border-radius: 0; }
.frame-shot.corners-sm { border-radius: 4px; }
.frame-shot.corners-md { border-radius: 10px; }
/* Swiss shadows are flat — only the subtle outline scrim is welcome */
.frame-shot.shadow-soft { box-shadow: 0 12px 28px -16px rgba(0,0,0,.18); }
.frame-shot.shadow-ed { box-shadow: 0 20px 40px -20px rgba(0,0,0,.22), 0 0 0 1px var(--grey-2); }
/* background tones — pure / dot / grid only. No accent colour fills. */
.frame-shot.bg-paper { background: var(--paper); }
.frame-shot.bg-grey-1 { background: var(--grey-1); }
.frame-shot.bg-ink { background: var(--ink); }
.frame-shot.bg-grid {
background-color: var(--paper);
background-image:
linear-gradient(to right, var(--grey-2) 1px, transparent 1px),
linear-gradient(to bottom, var(--grey-2) 1px, transparent 1px);
background-size: 48px 48px;
}
.frame-shot.bg-dot {
background-color: var(--paper);
background-image: radial-gradient(var(--grey-2) 1px, transparent 1px);
background-size: 18px 18px;
}
/* asset backgrounds — ported from PPT skill, real texture for screenshot wells.
Use ONLY when current accent matches the asset's accent (don't mix oranges and IKB). */
.frame-shot.bg-asset-ikb-dot { background: #0c1a3a url("assets/screenshot-backgrounds/style-b/ikb-dot-gradient.webp") center/cover no-repeat; }
.frame-shot.bg-asset-lemon-green-dot { background: #d6e08a url("assets/screenshot-backgrounds/style-b/lemon-green-dot-shadow.webp") center/cover no-repeat; }
.frame-shot.bg-asset-lemon-grid { background: #e6e9b8 url("assets/screenshot-backgrounds/style-b/lemon-grid.webp") center/cover no-repeat; }
.frame-shot.bg-asset-safety-orange { background: #ff5b1f url("assets/screenshot-backgrounds/style-b/safety-orange-halftone.webp") center/cover no-repeat; }
.frame-shot.inset-none > img,
.frame-shot.inset-none > .shot-body { padding: 0; }
.frame-shot.inset-sub { padding: 20px; }
.frame-shot.inset-bal { padding: 48px; }
.frame-shot.r-3x4 { aspect-ratio: 3 / 4; }
.frame-shot.r-1x1 { aspect-ratio: 1 / 1; }
.frame-shot.r-4x3 { aspect-ratio: 4 / 3; }
.frame-shot.r-16x9 { aspect-ratio: 16 / 9; }
.frame-shot.r-16x10 { aspect-ratio: 16 / 10; }
.frame-shot.r-21x9 { aspect-ratio: 21 / 9; }
.frame-shot.r-3x2 { aspect-ratio: 3 / 2; }
.frame-shot > img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
border-radius: inherit;
}
.frame-shot.fit-cover > img { object-fit: cover; }
/* device chrome — CSS-only, no SVG */
.device-browser {
position: relative;
background: var(--paper);
border: 1px solid var(--grey-2);
border-radius: 6px;
overflow: hidden;
}
.device-browser::before {
content: '';
display: block;
height: 32px;
background: var(--grey-1);
border-bottom: 1px solid var(--grey-2);
background-image:
radial-gradient(circle at 16px 16px, var(--grey-3) 4px, transparent 4.5px),
radial-gradient(circle at 34px 16px, var(--grey-3) 4px, transparent 4.5px),
radial-gradient(circle at 52px 16px, var(--grey-3) 4px, transparent 4.5px);
}
.device-browser > .frame-shot,
.device-browser > img {
border-radius: 0;
display: block;
width: 100%;
}
.device-phone {
position: relative;
background: var(--ink);
border-radius: 28px;
padding: 12px;
box-shadow: 0 16px 36px -18px rgba(0,0,0,.32);
}
.device-phone > .frame-shot,
.device-phone > img {
border-radius: 18px;
display: block;
width: 100%;
}
/* ---------- 9c. Map block (.map-block) ---------- */
/* Static map for Swiss decks. Pins are pure HTML, no SVG text.
Use accent sparingly: at most one accent pin per board. */
.map-block {
position: relative;
background: var(--grey-1);
overflow: hidden;
aspect-ratio: 4 / 3;
width: 100%;
}
.map-block.r-16x10 { aspect-ratio: 16 / 10; }
.map-block.r-16x9 { aspect-ratio: 16 / 9; }
.map-block.r-1x1 { aspect-ratio: 1 / 1; }
.map-block.r-3x4 { aspect-ratio: 3 / 4; }
.map-block > img,
.map-block > svg {
position: absolute; inset: 0;
width: 100%; height: 100%;
display: block;
}
.map-block > img { object-fit: cover; }
/* desaturated tile filter to keep Swiss strict tone */
.map-block.tone-paper > img { filter: saturate(0) contrast(1.05) brightness(1.02); }
.map-block.tone-ink > img { filter: saturate(0) brightness(.58); }
/* schematic SVG defaults */
.map-block > svg .map-coast { fill: none; stroke: var(--grey-2); stroke-width: .4; }
.map-block > svg .map-road { fill: none; stroke: var(--grey-3); stroke-width: .28; stroke-dasharray: 1.2 .8; opacity: .7; }
.map-block > svg .map-water { fill: var(--grey-1); }
.map-block > svg .map-grid { stroke: var(--grey-2); stroke-width: .15; opacity: .5; }
.map-pin {
position: absolute;
width: 0; height: 0;
transform: translate(-50%, -50%);
}
.map-pin .dot {
position: absolute;
left: -6px; top: -6px;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--ink);
border: 2px solid var(--paper);
box-shadow: 0 1px 3px rgba(0,0,0,.22);
}
.map-pin.accent .dot { background: var(--accent); }
.map-pin .line {
position: absolute;
left: 7px; top: 0;
width: 28px; height: 1px;
background: var(--ink);
opacity: .55;
}
.map-pin.left .line { left: auto; right: 7px; }
.map-pin .card {
position: absolute;
left: 38px; top: -20px;
min-width: 80px;
padding: 8px 10px;
background: rgba(250,250,248,.94);
box-shadow: 0 0 0 1px var(--grey-2);
border-radius: 2px;
white-space: nowrap;
}
.map-pin.left .card { left: auto; right: 38px; }
.map-pin .card .name {
font-family: var(--sans-zh), var(--sans);
font-weight: 500;
font-size: 15px;
line-height: 1.1;
color: var(--ink);
}
.map-pin .card .meta {
display: block;
margin-top: 4px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--grey-3);
}
.map-pin.accent .card .name { color: var(--accent); }
.map-legend {
position: absolute;
left: 14px; bottom: 14px;
z-index: 3;
background: rgba(250,250,248,.94);
padding: 8px 12px;
box-shadow: 0 0 0 1px var(--grey-2);
font-family: var(--mono);
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--grey-3);
}
.map-legend.tr { left: auto; right: 14px; top: 14px; bottom: auto; }
/* ---------- 10. Swiss-specific components ---------- */
/* S08 Image Hero (port of S22) */
.image-hero {
display: grid;
grid-template-rows: auto 1fr;
gap: var(--sp-7);
height: 100%;
}
.image-hero .hero-img-wrap {
position: relative;
overflow: hidden;
background: var(--grey-1);
}
.image-hero .hero-img-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 35%;
display: block;
}
.image-hero .hero-overlay-block {
position: absolute;
left: 0;
top: 0;
background: var(--paper);
padding: var(--sp-7) var(--sp-8);
max-width: 60%;
}
.image-hero .hero-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-8);
align-items: end;
border-top: 1px solid var(--grey-2);
padding-top: var(--sp-7);
}
.image-hero .hero-stats .stat-block .num {
font-family: var(--sans);
font-weight: 200;
font-size: 96px;
line-height: 1;
letter-spacing: -.02em;
color: var(--ink);
margin: 0 0 var(--sp-4);
}
.image-hero .hero-stats .stat-block .lbl {
font-family: var(--mono);
font-size: 18px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--grey-3);
margin: 0;
}
/* S09 KPI Tower (port of S06) */
.kpi-tower-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-7);
align-items: end;
height: 100%;
}
.kpi-tower-row .tower-col {
display: flex;
flex-direction: column;
gap: var(--sp-5);
align-items: flex-start;
}
.kpi-tower-row .tower-col .num {
font-family: var(--sans);
font-weight: 200;
font-size: 112px;
line-height: 1;
letter-spacing: -.02em;
margin: 0;
}
.kpi-tower-row .tower-col .lbl {
font-family: var(--mono);
font-size: 20px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--grey-3);
margin: 0;
}
.kpi-tower-row .tower-col .bar-tower {
width: 100%;
height: var(--h, 240px);
background: var(--accent);
transform-origin: bottom;
}
.kpi-tower-row .tower-col.muted .bar-tower {
background: var(--grey-2);
}
.poster.xhs .kpi-tower-row { grid-template-columns: 1fr 1fr; gap: var(--sp-9); }
.poster.square .kpi-tower-row { grid-template-columns: 1fr 1fr; gap: var(--sp-9); }
/* S10 H-Bar Chart (port of S07) */
.h-bar-chart {
display: grid;
gap: var(--sp-6);
}
.h-bar-chart .bar-row {
display: grid;
grid-template-columns: 220px 1fr 120px;
gap: var(--sp-6);
align-items: center;
}
.h-bar-chart .bar-row .row-lbl {
font-family: var(--sans-zh);
font-weight: 500;
font-size: 26px;
color: var(--ink);
}
.h-bar-chart .bar-row .row-track {
position: relative;
height: 28px;
background: var(--grey-1);
}
.h-bar-chart .bar-row .row-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--w, 50%);
background: var(--accent);
}
.h-bar-chart .bar-row .row-val {
font-family: var(--mono);
font-weight: 500;
font-size: 26px;
letter-spacing: .04em;
text-align: right;
color: var(--ink);
}
/* On portrait the labels move above the track */
.poster.xhs .h-bar-chart .bar-row {
grid-template-columns: 1fr;
gap: var(--sp-4);
}
.poster.xhs .h-bar-chart .bar-row .row-val {
text-align: left;
}
/* S11 Stacked Ledger (port of S20) */
.stacked-ledger {
display: flex;
flex-direction: column;
}
.stacked-ledger .ledger-row {
display: grid;
grid-template-columns: 280px 1fr auto;
gap: var(--sp-7);
align-items: baseline;
padding: var(--sp-7) 0;
border-bottom: 1px solid var(--grey-2);
}
.stacked-ledger .ledger-row:first-child { padding-top: 0; }
.stacked-ledger .ledger-row:last-child { border-bottom: 0; }
.stacked-ledger .ledger-num {
font-family: var(--sans);
font-weight: 200;
font-size: 112px;
line-height: 1;
letter-spacing: -.02em;
margin: 0;
}
.stacked-ledger .ledger-lbl {
font-family: var(--sans-zh);
font-weight: 500;
font-size: 30px;
line-height: 1.3;
color: var(--ink);
}
.stacked-ledger .ledger-lbl .sub {
display: block;
font-weight: 400;
font-size: 22px;
color: var(--grey-3);
margin-top: var(--sp-3);
}
.stacked-ledger .ledger-icn {
width: 56px;
height: 56px;
color: var(--accent);
}
.poster.square .stacked-ledger .ledger-num { font-size: 88px; }
.poster.square .stacked-ledger .ledger-row { grid-template-columns: 220px 1fr auto; }
/* S12 Matrix Fill (port of S15) */
.matrix-fill {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 1fr;
gap: var(--sp-5);
}
.matrix-fill .matrix-cell {
background: var(--grey-1);
padding: var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-4);
min-height: 160px;
}
.matrix-fill .matrix-cell .cell-nb {
font-family: var(--mono);
font-size: 18px;
letter-spacing: .12em;
color: var(--grey-3);
}
.matrix-fill .matrix-cell .cell-title {
font-family: var(--sans-zh);
font-weight: 500;
font-size: 26px;
line-height: 1.3;
color: var(--ink);
}
.matrix-fill .matrix-cell.is-accent {
background: var(--accent);
color: var(--accent-on);
}
.matrix-fill .matrix-cell.is-accent .cell-nb,
.matrix-fill .matrix-cell.is-accent .cell-title { color: var(--accent-on); }
.hero-stat-bottom {
margin-top: var(--sp-9);
padding-top: var(--sp-7);
border-top: 1px solid var(--grey-2);
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: var(--sp-7);
}
/* xhs uses 2 columns; up to 8 cells = 4 rows. Switch to min-content
so rows don't stretch and crowd out hero-stat-bottom (validated
in smoke-ai-tools/xhs-04). When pairing matrix-fill with
hero-stat-bottom inside .poster.xhs, prefer 6 cells (3 rows). */
.poster.xhs .matrix-fill {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: min-content;
gap: var(--sp-4);
}
.poster.xhs .matrix-fill .matrix-cell { min-height: 0; padding: var(--sp-5); }
.poster.xhs .matrix-fill .matrix-cell .cell-title { font-size: 24px; }
.poster.xhs .hero-stat-bottom {
margin-top: var(--sp-6);
padding-top: var(--sp-5);
align-items: center;
}
.poster.xhs .hero-stat-bottom .num-mega { font-size: 128px; line-height: 1; }
.poster.square .matrix-fill{ grid-template-columns: repeat(3, 1fr); grid-auto-rows: auto; }
.poster.square .matrix-fill .matrix-cell { min-height: 0; }
/* ---------- 11. Issue label + corner metadata ---------- */
.chrome-min {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--mono);
font-size: 18px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--grey-3);
padding-bottom: var(--sp-6);
border-bottom: 1px solid var(--grey-2);
margin-bottom: var(--sp-9);
}
/* ---------- 12. Pair preview frame ---------- */
.pair-preview {
width: 2400px;
min-height: 1180px;
background: var(--grey-1);
padding: var(--sp-9);
display: grid;
grid-template-columns: 2100px 1080px;
grid-template-rows: 900px 1080px;
gap: var(--sp-9);
align-content: center;
justify-content: center;
}
.pair-preview .preview-wide { grid-column: 1; grid-row: 1 / span 2; align-self: center; }
.pair-preview .preview-square { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
.pair-preview .preview-label {
font-family: var(--mono);
font-weight: 500;
font-size: 18px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--grey-3);
margin-bottom: var(--sp-5);
}
/* ---------- 13. Hard rules (visual guard rails) ----------
Swiss style refuses these — uncomment to find offenders.
.poster *[style*="border-radius"],
.poster *[style*="box-shadow"],
.poster *[style*="linear-gradient"] { outline: 4px solid red !important; }
*/
</style>
</head>
<body>
<!-- ACCENT_OVERRIDE
Change <html data-accent="…"> above to:
ikb | lemon-yellow | lemon-green | safety-orange
One deck — one accent. Never combine.
-->
<main class="sheet">
<!-- POSTERS_HERE -->
<!--
Replace the placeholder poster below with real content.
Each poster carries one Layout Recipe (S01-S12) from
references/layout-recipes.md.
Available board sizes:
<section class="poster xhs" id="xhs-01">…</section> 3:4
<section class="poster square" id="wechat-1x1">…</section> 1:1
<section class="poster wide" id="wechat-21x9">…</section> 21:9
For WeChat covers, also include a <section class="pair-preview">
that places the 21:9 + 1:1 side-by-side for review.
Hard rules every poster must honor:
- exactly one --accent in the entire deck
- no border-radius, no box-shadow, no gradients
- card-ink / card-accent / card-fill / card-outlined are
mutually exclusive — never combine on one node
- "the larger, the lighter": ≥ 200px must use weight 200
-->
<!-- ============================================================
Placeholder: S01 Accent Cover (3:4)
Replace with real cover content from layout-recipes.md.
============================================================ -->
<section class="poster xhs" id="xhs-01">
<div class="content stack gap-9">
<div class="chrome-min">
<span>Vol. 01 · Swiss</span>
<span>2026.05</span>
</div>
<div class="stack gap-7">
<p class="t-cat">封面 · Cover</p>
<h1 class="h-statement">替换<br>标题</h1>
</div>
<div class="grow"></div>
<hr class="hr-accent">
<p class="lead">用一句克制的解释,告诉读者这一组卡片在讲什么。</p>
<div class="row gap-6">
<p class="t-meta">Issue 01</p>
<p class="t-meta">/ [作者]</p>
</div>
</div>
</section>
</main>
<!-- Lucide icon initialization (used by stacked ledger / matrix accents) -->
<script>
if (window.lucide && typeof window.lucide.createIcons === 'function') {
window.lucide.createIcons();
}
</script>
</body>
</html>