feat(listing): portfolio-first archive, case-study hero, row action links
/posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the complete year/month archive, with the flagship block collapsing (inert, h=0) as soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact two-column hero with a 21:9 deterministic topology banner, flagship ordering from lib/portfolio.ts instead of pubDate, and one measured pill per card. Also fixes what the screenshot review turned up: ZH archive rows and category chips were printing raw English slugs, the two chip strips were unlabelled and read as one list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav had no current-section indicator, and mobile title links were under the 44px tap minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport checks and the collapse test.
This commit is contained in:
1 parent
68771101ea
commit
041471d25e
17 files changed
+1632
-76
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,324 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="1029" viewBox="0 0 2400 1029" role="img">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#08120f" />
|
||||
<stop offset="0.55" stop-color="#0c1a15" />
|
||||
<stop offset="1" stop-color="#0a1512" />
|
||||
</linearGradient>
|
||||
<radialGradient id="copperGlow" cx="0.18" cy="0.9" r="0.7">
|
||||
<stop offset="0" stop-color="#c07a3e" stop-opacity="0.16" />
|
||||
<stop offset="1" stop-color="#c07a3e" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="hubGlow" cx="0.5" cy="0.45" r="0.6">
|
||||
<stop offset="0" stop-color="#d08b4a" stop-opacity="0.55" />
|
||||
<stop offset="1" stop-color="#d08b4a" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#8fb3a2" stroke-width="0.5" opacity="0.06" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="2400" height="1029" fill="url(#bg)" />
|
||||
<rect width="2400" height="1029" fill="url(#grid)" />
|
||||
<rect width="2400" height="1029" fill="url(#copperGlow)" />
|
||||
|
||||
<!-- baseline: the network everything sits on -->
|
||||
<path d="M 60 909 H 2340" stroke="#2f5342" stroke-width="2" />
|
||||
<path d="M 60 909 H 2340" stroke="#d08b4a" stroke-width="2" stroke-dasharray="120 26 8 26" opacity="0.45" />
|
||||
<rect x="90" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="186" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="282" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="378" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="474" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="570" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="666" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="762" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="858" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="954" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1050" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1146" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1242" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1338" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1434" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1530" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1626" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1722" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1818" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="1914" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="2010" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="2106" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="2202" y="893" width="2" height="30" fill="#2f5342" />
|
||||
<rect x="2298" y="893" width="2" height="30" fill="#2f5342" />
|
||||
|
||||
<g transform="translate(0, 24)">
|
||||
<g>
|
||||
<rect x="130" y="300" width="300" height="204" rx="12" fill="#0f1d18" stroke="#2f5342" stroke-width="1.8" />
|
||||
<path d="M 130 326 h 300" stroke="#355944" stroke-width="1.4" />
|
||||
<circle cx="148" cy="313" r="4" fill="#4fae7a" />
|
||||
<circle cx="162" cy="313" r="4" fill="#3a7157" />
|
||||
<circle cx="176" cy="313" r="4" fill="#d08b4a" />
|
||||
<rect x="386" y="308" width="32" height="10" rx="5" fill="#1d3229" />
|
||||
<rect x="144.0" y="340.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="172.0" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="200.0" y="340.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="228.0" y="340.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="256.0" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="284.0" y="340.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="312.0" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="340.0" y="340.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="368.0" y="340.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="396.0" y="340.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="144.0" y="355.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="172.0" y="355.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="200.0" y="355.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="228.0" y="355.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="256.0" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="284.0" y="355.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="312.0" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="340.0" y="355.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="368.0" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="396.0" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="144.0" y="370.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="172.0" y="370.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="200.0" y="370.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="228.0" y="370.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="256.0" y="370.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="284.0" y="370.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="312.0" y="370.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="340.0" y="370.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="368.0" y="370.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="396.0" y="370.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="144.0" y="385.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="172.0" y="385.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="200.0" y="385.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="228.0" y="385.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="256.0" y="385.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="284.0" y="385.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="312.0" y="385.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="340.0" y="385.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="368.0" y="385.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="396.0" y="385.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="144.0" y="400.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="172.0" y="400.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="200.0" y="400.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="228.0" y="400.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="256.0" y="400.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="284.0" y="400.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="312.0" y="400.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="340.0" y="400.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="368.0" y="400.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="396.0" y="400.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="144.0" y="415.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="172.0" y="415.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="200.0" y="415.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="228.0" y="415.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="256.0" y="415.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="284.0" y="415.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="312.0" y="415.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="340.0" y="415.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="368.0" y="415.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="396.0" y="415.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="144.0" y="430.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="172.0" y="430.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="144" y="492" width="272" height="2" rx="1" fill="#3a7157" opacity="0.8" />
|
||||
<rect x="254" y="512" width="52" height="4" rx="2" fill="#2f5342" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="520" y="210" width="320" height="234" rx="12" fill="#0f1d18" stroke="#2f5342" stroke-width="1.8" />
|
||||
<path d="M 520 236 h 320" stroke="#355944" stroke-width="1.4" />
|
||||
<circle cx="538" cy="223" r="4" fill="#4fae7a" />
|
||||
<circle cx="552" cy="223" r="4" fill="#3a7157" />
|
||||
<circle cx="566" cy="223" r="4" fill="#d08b4a" />
|
||||
<rect x="796" y="218" width="32" height="10" rx="5" fill="#1d3229" />
|
||||
<rect x="534.0" y="250.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="564.2" y="250.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="594.4" y="250.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="624.7" y="250.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="654.9" y="250.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="685.1" y="250.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="715.3" y="250.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="745.6" y="250.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="775.8" y="250.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="806.0" y="250.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="534.0" y="265.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="564.2" y="265.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="594.4" y="265.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="624.7" y="265.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="654.9" y="265.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="685.1" y="265.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="715.3" y="265.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="745.6" y="265.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="775.8" y="265.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="806.0" y="265.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="534.0" y="280.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="564.2" y="280.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="594.4" y="280.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="624.7" y="280.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="654.9" y="280.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="685.1" y="280.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="715.3" y="280.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="745.6" y="280.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="775.8" y="280.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="806.0" y="280.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="534.0" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="564.2" y="295.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="594.4" y="295.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="624.7" y="295.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="654.9" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="685.1" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="715.3" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="745.6" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="775.8" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="806.0" y="295.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="534.0" y="310.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="564.2" y="310.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="594.4" y="310.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="624.7" y="310.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="654.9" y="310.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="685.1" y="310.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="715.3" y="310.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="745.6" y="310.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="775.8" y="310.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="806.0" y="310.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="534.0" y="325.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="564.2" y="325.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="594.4" y="325.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="624.7" y="325.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="654.9" y="325.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="685.1" y="325.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="715.3" y="325.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="745.6" y="325.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="775.8" y="325.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="806.0" y="325.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="534.0" y="340.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="564.2" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="594.4" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="624.7" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="654.9" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="685.1" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="715.3" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="745.6" y="340.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="775.8" y="340.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="806.0" y="340.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="534.0" y="355.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="564.2" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="594.4" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="624.7" y="355.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="654.9" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="685.1" y="355.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="715.3" y="355.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="745.6" y="355.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="534" y="432" width="292" height="2" rx="1" fill="#3a7157" opacity="0.8" />
|
||||
<rect x="654" y="452" width="52" height="4" rx="2" fill="#2f5342" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="930" y="396" width="280" height="159" rx="12" fill="#0f1d18" stroke="#2f5342" stroke-width="1.8" />
|
||||
<path d="M 930 422 h 280" stroke="#355944" stroke-width="1.4" />
|
||||
<circle cx="948" cy="409" r="4" fill="#4fae7a" />
|
||||
<circle cx="962" cy="409" r="4" fill="#3a7157" />
|
||||
<circle cx="976" cy="409" r="4" fill="#d08b4a" />
|
||||
<rect x="1166" y="404" width="32" height="10" rx="5" fill="#1d3229" />
|
||||
<rect x="944.0" y="436.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="969.8" y="436.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="995.6" y="436.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1021.3" y="436.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="1047.1" y="436.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="1072.9" y="436.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="1098.7" y="436.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="1124.4" y="436.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1150.2" y="436.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1176.0" y="436.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="944.0" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="969.8" y="451.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="995.6" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1021.3" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1047.1" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1072.9" y="451.0" width="20" height="11" rx="2.5" fill="#d08b4a" />
|
||||
<rect x="1098.7" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1124.4" y="451.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="1150.2" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="1176.0" y="451.0" width="20" height="11" rx="2.5" fill="#3a7157" />
|
||||
<rect x="944.0" y="466.0" width="20" height="11" rx="2.5" fill="#4fae7a" />
|
||||
<rect x="969.8" y="466.0" width="20" height="11" rx="2.5" fill="#2e5c48" />
|
||||
<rect x="944" y="543" width="252" height="2" rx="1" fill="#3a7157" opacity="0.8" />
|
||||
<rect x="1044" y="563" width="52" height="4" rx="2" fill="#2f5342" />
|
||||
</g>
|
||||
<g>
|
||||
<path d="M 300 760 H 1560" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" stroke-linecap="round" />
|
||||
<path d="M 1560 760 V 714" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" />
|
||||
<circle cx="1560" cy="714" r="5" fill="#e8ab76" />
|
||||
<g>
|
||||
<path d="M 280 526 V 754" fill="none" stroke="#d08b4a" stroke-width="2.4" opacity="0.7" />
|
||||
<circle cx="280" cy="526" r="4.5" fill="#d08b4a" />
|
||||
<circle cx="280" cy="760" r="4" fill="#e8ab76" />
|
||||
</g>
|
||||
<g>
|
||||
<path d="M 680 466 V 754" fill="none" stroke="#d08b4a" stroke-width="2.4" opacity="0.7" />
|
||||
<circle cx="680" cy="466" r="4.5" fill="#d08b4a" />
|
||||
<circle cx="680" cy="760" r="4" fill="#e8ab76" />
|
||||
</g>
|
||||
<g>
|
||||
<path d="M 1070 577 V 754" fill="none" stroke="#d08b4a" stroke-width="2.4" opacity="0.7" />
|
||||
<circle cx="1070" cy="577" r="4.5" fill="#d08b4a" />
|
||||
<circle cx="1070" cy="760" r="4" fill="#e8ab76" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<circle cx="1560" cy="514" r="196" fill="none" stroke="#3a7157" stroke-width="1.4" stroke-dasharray="4 9" opacity="0.9" />
|
||||
<circle cx="1560" cy="514" r="152" fill="none" stroke="#4a8f6f" stroke-width="1.6" opacity="0.85" />
|
||||
<circle cx="1560" cy="514" r="110" fill="#0d1f1a" stroke="#528b74" stroke-width="2.2" />
|
||||
<circle cx="1560" cy="514" r="110" fill="url(#hubGlow)" opacity="0.55" />
|
||||
<path d="M 1560 462 c 30 25 39 39 39 57 a 39 39 0 0 1 -78 0 c 0 -18 9 -32 39 -57 z" fill="#d08b4a" />
|
||||
<path d="M 1560 496 c 14 14 18 21 18 30 a 18 18 0 0 1 -36 0 c 0 -9 4 -16 18 -30 z" fill="#0d1f1a" opacity="0.72" />
|
||||
<g>
|
||||
<rect x="1766" y="275" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
|
||||
<polyline points="1782,337 1796,329 1810,333 1824,319 1838,323 1852,311 1866,315" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
|
||||
<rect x="1782" y="291" width="46" height="6" rx="3" fill="#3a7157" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1766" y="405" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
|
||||
<polyline points="1782,443 1796,455 1810,447 1824,461 1838,451 1852,463 1866,455" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
|
||||
<rect x="1782" y="421" width="46" height="6" rx="3" fill="#3a7157" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1766" y="537" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
|
||||
<polyline points="1782,593 1796,577 1810,585 1824,571 1838,579 1852,575 1866,583" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
|
||||
<rect x="1782" y="553" width="46" height="6" rx="3" fill="#3a7157" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1766" y="667" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
|
||||
<polyline points="1782,715 1796,713 1810,719 1824,707 1838,711 1852,701 1866,709" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
|
||||
<rect x="1782" y="683" width="46" height="6" rx="3" fill="#3a7157" />
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M 1760 494 C 1860 494, 1860 376, 1960 376" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<path d="M 2130 376 C 2240 376, 2220 526, 2160 526" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<path d="M 2245 582 C 2245 640, 2160 676, 2130 676" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<g>
|
||||
<rect x="1960" y="320" width="170" height="112" rx="10" fill="#0f1d18" stroke="#3a7157" stroke-width="1.6" />
|
||||
<rect x="1978" y="342" width="62" height="7" rx="3.5" fill="#4fae7a" />
|
||||
<rect x="1978" y="364" width="112" height="6" rx="3" fill="#2e5c48" />
|
||||
<rect x="1978" y="382" width="84" height="6" rx="3" fill="#2e5c48" />
|
||||
<circle cx="2106" cy="346" r="5.5" fill="#4fae7a" />
|
||||
<circle cx="2106" cy="346" r="12" fill="none" stroke="#4fae7a" stroke-width="1.2" opacity="0.5" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="2160" y="470" width="170" height="112" rx="10" fill="#0f1d18" stroke="#3a7157" stroke-width="1.6" />
|
||||
<rect x="2178" y="492" width="62" height="7" rx="3.5" fill="#4fae7a" />
|
||||
<rect x="2178" y="514" width="112" height="6" rx="3" fill="#2e5c48" />
|
||||
<rect x="2178" y="532" width="84" height="6" rx="3" fill="#2e5c48" />
|
||||
<circle cx="2306" cy="496" r="5.5" fill="#4fae7a" />
|
||||
<circle cx="2306" cy="496" r="12" fill="none" stroke="#4fae7a" stroke-width="1.2" opacity="0.5" />
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1960" y="620" width="170" height="112" rx="10" fill="#0f1d18" stroke="#3a7157" stroke-width="1.6" />
|
||||
<rect x="1978" y="642" width="62" height="7" rx="3.5" fill="#4fae7a" />
|
||||
<rect x="1978" y="664" width="112" height="6" rx="3" fill="#2e5c48" />
|
||||
<rect x="1978" y="682" width="84" height="6" rx="3" fill="#2e5c48" />
|
||||
<circle cx="2106" cy="646" r="5.5" fill="#4fae7a" />
|
||||
<circle cx="2106" cy="646" r="12" fill="none" stroke="#4fae7a" stroke-width="1.2" opacity="0.5" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Generates the case-studies portfolio banner — 21:9, no baked-in text.
|
||||
*
|
||||
* The consultant's spec for /categories/case-studies/ asks for a wide architecture
|
||||
* visual: three production hosts, Docker, Prometheus and the automation flow, in a
|
||||
* dark terminal grey-green with restrained warm-copper accents, rendered into a
|
||||
* 240–300 px tall band beside the hero copy. Everything here is geometry, no words:
|
||||
* at that display height any label would be unreadable, and the headline copy has to
|
||||
* stay semantic HTML.
|
||||
*
|
||||
* Deterministic (seeded), so regenerating never churns the committed asset.
|
||||
*
|
||||
* node scripts/gen-case-studies-banner.mjs
|
||||
*
|
||||
* Writes:
|
||||
* public/images/categories/case-studies-banner.webp 2400x1029 (21:9)
|
||||
* public/images/categories/case-studies-banner-1200.webp 1200x514
|
||||
* public/images/categories/case-studies-banner.svg the source geometry
|
||||
*/
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const W = 2400;
|
||||
const H = 1029; // 21:9
|
||||
|
||||
// Host panels: the real fleet is 3 hosts / 162 containers, and the visual carries the
|
||||
// same proportions (Unraid 62, Synology 78, VPS 22) so the art is not decorative lies.
|
||||
const HOSTS = [
|
||||
{ x: 130, y: 300, w: 300, rows: 10, cols: 10, count: 62, hot: [3, 17, 41] },
|
||||
{ x: 520, y: 210, w: 320, rows: 12, cols: 10, count: 78, hot: [2, 9, 33, 60] },
|
||||
{ x: 930, y: 396, w: 280, rows: 7, cols: 10, count: 22, hot: [6, 15] },
|
||||
];
|
||||
|
||||
const HUB = { cx: 1560, cy: 514 };
|
||||
|
||||
// Deterministic pseudo-random: same seed -> same picture, forever.
|
||||
function rnd(seed) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) % 2147483648;
|
||||
return s / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
function hostHeight(host) {
|
||||
return 26 + 14 * 2 + host.rows * 15;
|
||||
}
|
||||
|
||||
function hostPanel(host, seed) {
|
||||
const r = rnd(seed);
|
||||
const head = 26;
|
||||
const pad = 14;
|
||||
const cols = host.cols;
|
||||
const cw = 20;
|
||||
const ch = 11;
|
||||
const gx = (host.w - pad * 2 - cols * cw) / (cols - 1);
|
||||
const gy = 15;
|
||||
const h = hostHeight(host);
|
||||
|
||||
const chips = [];
|
||||
let drawn = 0;
|
||||
const hot = new Set(host.hot);
|
||||
for (let row = 0; row < host.rows && drawn < host.count; row++) {
|
||||
for (let col = 0; col < cols && drawn < host.count; col++) {
|
||||
const i = row * cols + col;
|
||||
const accent = hot.has(i)
|
||||
? '#d08b4a'
|
||||
: r() > 0.86
|
||||
? '#4fae7a'
|
||||
: r() > 0.5
|
||||
? '#3a7157'
|
||||
: '#2e5c48';
|
||||
chips.push(
|
||||
`<rect x="${(host.x + pad + col * (cw + gx)).toFixed(1)}" y="${(host.y + head + pad + row * gy).toFixed(1)}" width="${cw}" height="${ch}" rx="2.5" fill="${accent}" />`,
|
||||
);
|
||||
drawn++;
|
||||
}
|
||||
}
|
||||
|
||||
return `<g>
|
||||
<rect x="${host.x}" y="${host.y}" width="${host.w}" height="${h}" rx="12" fill="#0f1d18" stroke="#2f5342" stroke-width="1.8" />
|
||||
<path d="M ${host.x} ${host.y + head} h ${host.w}" stroke="#355944" stroke-width="1.4" />
|
||||
<circle cx="${host.x + 18}" cy="${host.y + 13}" r="4" fill="#4fae7a" />
|
||||
<circle cx="${host.x + 32}" cy="${host.y + 13}" r="4" fill="#3a7157" />
|
||||
<circle cx="${host.x + 46}" cy="${host.y + 13}" r="4" fill="#d08b4a" />
|
||||
<rect x="${host.x + host.w - 44}" y="${host.y + 8}" width="32" height="10" rx="5" fill="#1d3229" />
|
||||
${chips.join('\n ')}
|
||||
<rect x="${host.x + pad}" y="${host.y + h - 12}" width="${host.w - pad * 2}" height="2" rx="1" fill="#3a7157" opacity="0.8" />
|
||||
<rect x="${host.x + host.w / 2 - 26}" y="${host.y + h + 8}" width="52" height="4" rx="2" fill="#2f5342" />
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/** A Prometheus-ish readout hub: concentric rings, a flame glyph, four metric tiles. */
|
||||
function hub() {
|
||||
const { cx, cy } = HUB;
|
||||
const tiles = [
|
||||
{ top: cy - 196, pts: '0,34 14,26 28,30 42,16 56,20 70,8 84,12' },
|
||||
{ top: cy - 66, pts: '0,10 14,22 28,14 42,28 56,18 70,30 84,22' },
|
||||
{ top: cy + 66, pts: '0,28 14,12 28,20 42,6 56,14 70,10 84,18' },
|
||||
{ top: cy + 196, pts: '0,20 14,18 28,24 42,12 56,16 70,6 84,14' },
|
||||
].map((t) => ({ ...t, x: cx + 206, y: t.top - 43 }));
|
||||
|
||||
const tileSvg = tiles.map(
|
||||
(t) => `<g>
|
||||
<rect x="${t.x}" y="${t.y}" width="160" height="86" rx="10" fill="#0f1d18" stroke="#2f5342" stroke-width="1.4" />
|
||||
<polyline points="${t.pts
|
||||
.split(' ')
|
||||
.map((p) => {
|
||||
const [a, b] = p.split(',').map(Number);
|
||||
return `${t.x + 16 + a},${t.y + 48 + (b - 20)}`;
|
||||
})
|
||||
.join(' ')}" fill="none" stroke="#d08b4a" stroke-width="2.2" stroke-linejoin="round" stroke-linecap="round" opacity="0.95" />
|
||||
<rect x="${t.x + 16}" y="${t.y + 16}" width="46" height="6" rx="3" fill="#3a7157" />
|
||||
</g>`,
|
||||
);
|
||||
|
||||
return `<g>
|
||||
<circle cx="${cx}" cy="${cy}" r="196" fill="none" stroke="#3a7157" stroke-width="1.4" stroke-dasharray="4 9" opacity="0.9" />
|
||||
<circle cx="${cx}" cy="${cy}" r="152" fill="none" stroke="#4a8f6f" stroke-width="1.6" opacity="0.85" />
|
||||
<circle cx="${cx}" cy="${cy}" r="110" fill="#0d1f1a" stroke="#528b74" stroke-width="2.2" />
|
||||
<circle cx="${cx}" cy="${cy}" r="110" fill="url(#hubGlow)" opacity="0.55" />
|
||||
<path d="M ${cx} ${cy - 52} c 30 25 39 39 39 57 a 39 39 0 0 1 -78 0 c 0 -18 9 -32 39 -57 z" fill="#d08b4a" />
|
||||
<path d="M ${cx} ${cy - 18} c 14 14 18 21 18 30 a 18 18 0 0 1 -36 0 c 0 -9 4 -16 18 -30 z" fill="#0d1f1a" opacity="0.72" />
|
||||
${tileSvg.join('\n ')}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts → hub wiring, drawn as a network bus rather than three curves across the
|
||||
* panels: each host drops a stub into a shared copper bus below the fleet, and the
|
||||
* bus rises into the hub's outer ring. Crossing the panels looked like a mistake at
|
||||
* banner size, which is exactly the kind of detail nobody should have to decode.
|
||||
*/
|
||||
function flows() {
|
||||
const busY = 760;
|
||||
const busX = HUB.cx;
|
||||
const drops = HOSTS.map((host) => {
|
||||
const x = host.x + host.w / 2;
|
||||
const y0 = host.y + hostHeight(host) + 22;
|
||||
return `<g>
|
||||
<path d="M ${x} ${y0} V ${busY - 6}" fill="none" stroke="#d08b4a" stroke-width="2.4" opacity="0.7" />
|
||||
<circle cx="${x}" cy="${y0}" r="4.5" fill="#d08b4a" />
|
||||
<circle cx="${x}" cy="${busY}" r="4" fill="#e8ab76" />
|
||||
</g>`;
|
||||
}).join('\n ');
|
||||
|
||||
return `<g>
|
||||
<path d="M 300 ${busY} H ${busX}" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" stroke-linecap="round" />
|
||||
<path d="M ${busX} ${busY} V ${HUB.cy + 200}" fill="none" stroke="#d08b4a" stroke-width="3" opacity="0.7" />
|
||||
<circle cx="${busX}" cy="${HUB.cy + 200}" r="5" fill="#e8ab76" />
|
||||
${drops}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/** The automation tail: hub → three workflow nodes, drawn as a green chain. */
|
||||
function automation() {
|
||||
const nodes = [
|
||||
{ x: 1960, y: 320, w: 170, h: 112 },
|
||||
{ x: 2160, y: 470, w: 170, h: 112 },
|
||||
{ x: 1960, y: 620, w: 170, h: 112 },
|
||||
];
|
||||
const chain = nodes
|
||||
.map(
|
||||
(n) => `<g>
|
||||
<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" fill="#0f1d18" stroke="#3a7157" stroke-width="1.6" />
|
||||
<rect x="${n.x + 18}" y="${n.y + 22}" width="62" height="7" rx="3.5" fill="#4fae7a" />
|
||||
<rect x="${n.x + 18}" y="${n.y + 44}" width="112" height="6" rx="3" fill="#2e5c48" />
|
||||
<rect x="${n.x + 18}" y="${n.y + 62}" width="84" height="6" rx="3" fill="#2e5c48" />
|
||||
<circle cx="${n.x + n.w - 24}" cy="${n.y + 26}" r="5.5" fill="#4fae7a" />
|
||||
<circle cx="${n.x + n.w - 24}" cy="${n.y + 26}" r="12" fill="none" stroke="#4fae7a" stroke-width="1.2" opacity="0.5" />
|
||||
</g>`,
|
||||
)
|
||||
.join('\n ');
|
||||
|
||||
return `<g>
|
||||
<path d="M ${HUB.cx + 200} ${HUB.cy - 20} C 1860 ${HUB.cy - 20}, 1860 376, 1960 376" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<path d="M 2130 376 C 2240 376, 2220 526, 2160 526" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
<path d="M 2245 582 C 2245 640, 2160 676, 2130 676" fill="none" stroke="#5fd3a0" stroke-width="2.6" opacity="0.85" stroke-dasharray="8 7" />
|
||||
${chain}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#08120f" />
|
||||
<stop offset="0.55" stop-color="#0c1a15" />
|
||||
<stop offset="1" stop-color="#0a1512" />
|
||||
</linearGradient>
|
||||
<radialGradient id="copperGlow" cx="0.18" cy="0.9" r="0.7">
|
||||
<stop offset="0" stop-color="#c07a3e" stop-opacity="0.16" />
|
||||
<stop offset="1" stop-color="#c07a3e" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient id="hubGlow" cx="0.5" cy="0.45" r="0.6">
|
||||
<stop offset="0" stop-color="#d08b4a" stop-opacity="0.55" />
|
||||
<stop offset="1" stop-color="#d08b4a" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">
|
||||
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="#8fb3a2" stroke-width="0.5" opacity="0.06" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="${W}" height="${H}" fill="url(#bg)" />
|
||||
<rect width="${W}" height="${H}" fill="url(#grid)" />
|
||||
<rect width="${W}" height="${H}" fill="url(#copperGlow)" />
|
||||
|
||||
<!-- baseline: the network everything sits on -->
|
||||
<path d="M 60 ${H - 120} H ${W - 60}" stroke="#2f5342" stroke-width="2" />
|
||||
<path d="M 60 ${H - 120} H ${W - 60}" stroke="#d08b4a" stroke-width="2" stroke-dasharray="120 26 8 26" opacity="0.45" />
|
||||
${Array.from({ length: 24 }, (_, i) => `<rect x="${90 + i * 96}" y="${H - 136}" width="2" height="30" fill="#2f5342" />`).join('\n ')}
|
||||
|
||||
<g transform="translate(0, 24)">
|
||||
${HOSTS.map((h, i) => hostPanel(h, 97 + i * 13)).join('\n ')}
|
||||
${flows()}
|
||||
${hub()}
|
||||
${automation()}
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const outDir = path.join(process.cwd(), 'public', 'images', 'categories');
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
await sharp(Buffer.from(svg)).webp({ quality: 82 }).toFile(path.join(outDir, 'case-studies-banner.webp'));
|
||||
await sharp(Buffer.from(svg))
|
||||
.resize(1200, 514, { fit: 'fill' })
|
||||
.webp({ quality: 80 })
|
||||
.toFile(path.join(outDir, 'case-studies-banner-1200.webp'));
|
||||
|
||||
// Keep the source next to the render so a future edit does not have to reverse-engineer
|
||||
// the geometry out of a WebP.
|
||||
await writeFile(path.join(outDir, 'case-studies-banner.svg'), svg, 'utf8');
|
||||
|
||||
const report = [];
|
||||
for (const f of ['case-studies-banner.webp', 'case-studies-banner-1200.webp']) {
|
||||
const { size } = await stat(path.join(outDir, f));
|
||||
report.push(`${f}: ${(size / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
console.log(report.join('\n'));
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Layout probe over CDP: horizontal-overflow + interaction checks at fixed viewports.
|
||||
*
|
||||
* Uses Node's built-in WebSocket client (Node >= 22) so nothing has to be installed.
|
||||
*
|
||||
* node scripts/probe-layout.mjs [baseUrl]
|
||||
*
|
||||
* Requires a headless Chrome with remote debugging and a static server on `baseUrl`
|
||||
* (default http://127.0.0.1:4321, i.e. `python -m http.server 4321 --directory dist`).
|
||||
*
|
||||
* Checks per page × width:
|
||||
* - documentElement.scrollWidth === innerWidth (no horizontal scroll)
|
||||
* - names every element whose right edge exceeds the viewport by > 1px (so a failure
|
||||
* is actionable instead of "something overflows")
|
||||
* - the /posts/ featured block collapses on the first keystroke and comes back when
|
||||
* the filter is cleared
|
||||
*/
|
||||
const BASE = process.argv[2] ?? 'http://127.0.0.1:4321';
|
||||
const CDP = process.argv[3] ?? 'http://127.0.0.1:9333';
|
||||
|
||||
const PAGES = [
|
||||
'/',
|
||||
'/posts/',
|
||||
'/zh/posts/',
|
||||
'/categories/',
|
||||
'/categories/case-studies/',
|
||||
'/zh/categories/case-studies/',
|
||||
'/categories/devops/',
|
||||
'/tags/docker/',
|
||||
'/about/',
|
||||
];
|
||||
|
||||
const WIDTHS = [1920, 1280, 960, 768, 430, 390, 375, 320];
|
||||
|
||||
async function target(url) {
|
||||
const res = await fetch(`${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
|
||||
if (!res.ok) throw new Error(`cannot create target: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function connect(wsUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.addEventListener('open', () => resolve(ws));
|
||||
ws.addEventListener('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
function send(ws, method, params = {}) {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.id !== id) return;
|
||||
ws.removeEventListener('message', onMessage);
|
||||
if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));
|
||||
else resolve(msg.result);
|
||||
};
|
||||
ws.addEventListener('message', onMessage);
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluate(ws, expression) {
|
||||
const r = await send(ws, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
userGesture: true,
|
||||
});
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
}
|
||||
|
||||
const OVERFLOW_JS = `(() => {
|
||||
const vw = window.innerWidth;
|
||||
const offenders = [];
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
if (r.right > vw + 1 || r.left < -1) {
|
||||
offenders.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: (el.className && String(el.className).slice(0, 60)) || '',
|
||||
left: Math.round(r.left),
|
||||
right: Math.round(r.right),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
vw,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
docScroll: document.documentElement.scrollHeight,
|
||||
offenders: offenders.slice(0, 6),
|
||||
};
|
||||
})()`;
|
||||
|
||||
const COLLAPSE_JS = `(async () => {
|
||||
const feat = document.getElementById('archive-featured');
|
||||
const q = document.getElementById('archive-q');
|
||||
const status = document.getElementById('archive-status-text');
|
||||
const clear = document.getElementById('archive-clear');
|
||||
if (!feat || !q) return { skipped: true };
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const before = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() };
|
||||
q.value = 'docker';
|
||||
q.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await wait(420);
|
||||
const during = {
|
||||
collapsed: feat.dataset.collapsed,
|
||||
h: Math.round(feat.getBoundingClientRect().height),
|
||||
status: status?.textContent?.trim(),
|
||||
clearVisible: clear ? !clear.hidden : null,
|
||||
inert: feat.inert === true,
|
||||
visibleRows: [...document.querySelectorAll('.archive-row')].filter((r) => !r.hidden).length,
|
||||
};
|
||||
clear?.click();
|
||||
await wait(420);
|
||||
const after = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() };
|
||||
return { before, during, after };
|
||||
})()`;
|
||||
|
||||
const results = [];
|
||||
const fails = [];
|
||||
|
||||
for (const path of PAGES) {
|
||||
const t = await target('about:blank');
|
||||
const ws = await connect(t.webSocketDebuggerUrl);
|
||||
await send(ws, 'Page.enable');
|
||||
await send(ws, 'Runtime.enable');
|
||||
|
||||
for (const width of WIDTHS) {
|
||||
await send(ws, 'Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height: 900,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: width < 700,
|
||||
});
|
||||
await send(ws, 'Page.navigate', { url: BASE + path });
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
const m = await evaluate(ws, OVERFLOW_JS);
|
||||
const ok = m.scrollWidth <= m.clientWidth + 1;
|
||||
results.push({ path, width, ok, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders.length });
|
||||
if (!ok || m.offenders.length) {
|
||||
fails.push({ path, width, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders });
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction check only where the featured block exists (desktop + phone widths).
|
||||
if (path === '/posts/' || path === '/zh/posts/') {
|
||||
for (const width of [1280, 390]) {
|
||||
await send(ws, 'Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height: 900,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: width < 700,
|
||||
});
|
||||
await send(ws, 'Page.navigate', { url: BASE + path });
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
const c = await evaluate(ws, COLLAPSE_JS);
|
||||
results.push({ path, width, collapse: c });
|
||||
if (!c.skipped) {
|
||||
const good =
|
||||
c.before.collapsed === 'false' &&
|
||||
c.during.collapsed === 'true' &&
|
||||
c.during.h === 0 &&
|
||||
c.during.inert === true &&
|
||||
c.during.clearVisible === true &&
|
||||
/docker/.test(String(c.during.status)) === false &&
|
||||
c.after.collapsed === 'false' &&
|
||||
c.after.h > 100;
|
||||
if (!good) fails.push({ path, width, collapse: c });
|
||||
else console.log(`collapse ok ${path} @${width}: ${c.before.status} → ${c.during.status} → ${c.after.status} (h ${c.before.h} → ${c.during.h} → ${c.after.h})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await send(ws, 'Page.close');
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const byWidth = results.filter((r) => r.ok !== undefined);
|
||||
const okCount = byWidth.filter((r) => r.ok).length;
|
||||
console.log(`\nviewport checks: ${okCount}/${byWidth.length} clean`);
|
||||
console.log(`pages × widths: ${PAGES.length} × ${WIDTHS.length}`);
|
||||
|
||||
if (fails.length) {
|
||||
console.log('\nFAILURES:');
|
||||
for (const f of fails) console.log(JSON.stringify(f));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('no horizontal overflow, featured collapse verified');
|
||||
+320
-17
@@ -1,6 +1,8 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { tagCounts, tagHref } from '../lib/tags';
|
||||
import { categoryLabel } from '../lib/categories';
|
||||
import type { FeaturedCard } from '../lib/featured';
|
||||
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
@@ -17,8 +19,13 @@ interface Props {
|
||||
chipPosts?: CollectionEntry<'posts'>[];
|
||||
/** On a tag page, the tag being viewed — its chip is marked active instead of looking clickable-to-self. */
|
||||
activeTag?: string;
|
||||
/**
|
||||
* Featured cards above the rows. Only the archive passes these; the block collapses
|
||||
* out of the way as soon as the reader types a filter or picks a category chip.
|
||||
*/
|
||||
featured?: FeaturedCard[];
|
||||
}
|
||||
const { posts, lang = 'en', chrome = true, chipPosts = posts, activeTag } = Astro.props;
|
||||
const { posts, lang = 'en', chrome = true, chipPosts = posts, activeTag, featured } = Astro.props;
|
||||
const isZh = lang === 'zh';
|
||||
|
||||
const counts = tagCounts(chipPosts);
|
||||
@@ -39,8 +46,39 @@ const shortDate = (d: Date) =>
|
||||
const yearOf = (d: Date) => d.getUTCFullYear();
|
||||
|
||||
const labels = isZh
|
||||
? { filter: '按标题或标签筛选…', all: '全部', empty: '没有匹配的文章。', search: '全文搜索', count: '篇' }
|
||||
: { filter: 'Filter by title or tag…', all: 'All', empty: 'No posts match.', search: 'Full-text search', count: 'posts' };
|
||||
? {
|
||||
filter: '按标题或标签筛选…',
|
||||
all: '全部',
|
||||
empty: '没有匹配的文章。',
|
||||
search: '全文搜索',
|
||||
count: '篇',
|
||||
featured: '精选案例',
|
||||
readArticle: '阅读文章',
|
||||
readCase: '阅读案例复盘',
|
||||
minutes: '分钟',
|
||||
statusAll: `共 ${posts.length} 篇`,
|
||||
statusSome: `命中 {n} / ${posts.length} 篇`,
|
||||
clear: '清除筛选',
|
||||
}
|
||||
: {
|
||||
filter: 'Filter by title or tag…',
|
||||
all: 'All',
|
||||
empty: 'No posts match.',
|
||||
search: 'Full-text search',
|
||||
count: 'posts',
|
||||
featured: 'Featured case studies',
|
||||
readArticle: 'Read article',
|
||||
readCase: 'Read case study',
|
||||
minutes: 'min read',
|
||||
statusAll: `All ${posts.length} posts`,
|
||||
statusSome: `Showing {n} of ${posts.length} posts`,
|
||||
clear: 'Clear filters',
|
||||
};
|
||||
|
||||
// A case study is a portfolio artefact, so its call to action says so. Applied at the
|
||||
// one place that renders listing cards so no page can drift from the rule.
|
||||
const ctaLabel = (category: string) =>
|
||||
category === 'case-studies' ? labels.readCase : labels.readArticle;
|
||||
---
|
||||
|
||||
{chrome && (
|
||||
@@ -58,25 +96,90 @@ const labels = isZh
|
||||
</div>
|
||||
|
||||
<div class="archive-chips" role="group" aria-label={isZh ? '按分类筛选' : 'Filter by category'}>
|
||||
<button class="chip is-active" data-cat="" type="button">{labels.all} <span>{posts.length}</span></button>
|
||||
{categories.map((c) => (
|
||||
<button class="chip" data-cat={c.name} type="button">{c.name} <span>{c.n}</span></button>
|
||||
))}
|
||||
<span class="chip-strip-label" aria-hidden="true">{isZh ? '分类' : 'Category'}</span>
|
||||
<span class="chip-strip-items">
|
||||
<button class="chip is-active" data-cat="" type="button">{labels.all} <span>{posts.length}</span></button>
|
||||
{categories.map((c) => (
|
||||
<button class="chip" data-cat={c.name} type="button">
|
||||
{categoryLabel(c.name, lang)} <span>{c.n}</span>
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-chips archive-tags" role="group" aria-label={isZh ? '按标签筛选' : 'Filter by tag'}>
|
||||
{chips.map((c) => (
|
||||
<a
|
||||
class:list={['chip', 'tag-chip', { 'is-active': c.tag === activeTag }]}
|
||||
href={tagHref(c.tag, lang)}
|
||||
aria-current={c.tag === activeTag ? 'page' : undefined}
|
||||
>{c.tag} <span>{c.n}</span></a>
|
||||
))}
|
||||
<span class="chip-strip-label" aria-hidden="true">{isZh ? '话题' : 'Topic'}</span>
|
||||
<span class="chip-strip-items">
|
||||
{chips.map((c) => (
|
||||
<a
|
||||
class:list={['chip', 'tag-chip', { 'is-active': c.tag === activeTag }]}
|
||||
href={tagHref(c.tag, lang)}
|
||||
aria-current={c.tag === activeTag ? 'page' : undefined}
|
||||
>{c.tag} <span>{c.n}</span></a>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!chrome && <p class="archive-meta">{posts.length} {labels.count}</p>}
|
||||
|
||||
{featured && featured.length > 0 && (
|
||||
<section
|
||||
id="archive-featured"
|
||||
class="archive-featured"
|
||||
aria-labelledby="archive-featured-title"
|
||||
data-collapsed="false"
|
||||
>
|
||||
<div class="archive-featured-inner">
|
||||
<h2 id="archive-featured-title" class="archive-featured-title">
|
||||
<span class="af-dot" aria-hidden="true"></span>{labels.featured}
|
||||
</h2>
|
||||
<div class="archive-featured-grid">
|
||||
{featured.map((f) => (
|
||||
<a class="feat-card" href={f.href}>
|
||||
<span class="feat-thumb">
|
||||
<img
|
||||
src={f.thumb800}
|
||||
srcset={`${f.thumb400} 400w, ${f.thumb800} 800w`}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width="800"
|
||||
height="420"
|
||||
/>
|
||||
</span>
|
||||
<span class="feat-body">
|
||||
<span class="feat-top">
|
||||
<span class="featured-badge">{f.categoryLabel}</span>
|
||||
<span class="feat-metric"><span class="pulse-dot" aria-hidden="true"></span>{f.metric}</span>
|
||||
</span>
|
||||
<span class="feat-title">{f.title}</span>
|
||||
<span class="feat-desc">{f.description}</span>
|
||||
<span class="feat-cta">
|
||||
{ctaLabel(f.category)} → <span class="feat-min">{f.minutes} {labels.minutes}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{chrome && (
|
||||
<p
|
||||
class="archive-status"
|
||||
id="archive-filter-status"
|
||||
data-label-all={labels.statusAll}
|
||||
data-label-some={labels.statusSome}
|
||||
data-label-clear={labels.clear}
|
||||
>
|
||||
<span id="archive-status-text">{labels.statusAll}</span>
|
||||
<button class="archive-clear" id="archive-clear" type="button" hidden>{labels.clear}</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol class="archive" id="archive-list">
|
||||
{years.map((y) => (
|
||||
<li class="archive-year-block" data-year={y}>
|
||||
@@ -91,7 +194,9 @@ const labels = isZh
|
||||
>
|
||||
<time datetime={p.data.pubDate.toISOString()}>{shortDate(p.data.pubDate)}</time>
|
||||
<a class="archive-title" href={`/posts/${p.slug}/`}>{p.data.title}</a>
|
||||
<a class="archive-cat" href={`${isZh ? '/zh' : ''}/categories/${p.data.category}/`}>{p.data.category}</a>
|
||||
<a class="archive-cat" href={`${isZh ? '/zh' : ''}/categories/${p.data.category}/`}>
|
||||
{categoryLabel(p.data.category, lang)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -119,6 +224,7 @@ const labels = isZh
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
.archive-input:focus { outline: none; border-color: var(--brand); }
|
||||
.archive-overlay-btn {
|
||||
@@ -129,13 +235,49 @@ const labels = isZh
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.8rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.archive-overlay-btn:hover { color: var(--brand); border-color: var(--brand); }
|
||||
|
||||
.archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
|
||||
.archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; align-items: center; }
|
||||
.archive-tags { margin-bottom: 1.1rem; }
|
||||
/* Two strips of identical chips sat next to each other and read as one list, with
|
||||
"devops" appearing twice (category 30, topic 6). Name each strip. */
|
||||
.chip-strip-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
min-width: 4.2rem;
|
||||
align-self: flex-start;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
/* The chips wrap inside their own column so a second line lines up under the first
|
||||
chip instead of sliding back under the strip label. */
|
||||
.chip-strip-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
/* flex-basis must be 0, not auto: a wrap container places items by their
|
||||
hypothetical size, so a max-content basis pushed the whole topic strip onto its
|
||||
own line instead of shrinking next to the label. */
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* On a phone the label column cost ~67 px of a 342 px content width, which wrapped
|
||||
the topic chips onto more lines than needed. Stack the label above there. */
|
||||
@media (max-width: 700px) {
|
||||
.chip-strip-label {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
padding-top: 0;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.chip-strip-items { margin-top: 0.3rem; }
|
||||
}
|
||||
.chip {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
@@ -147,11 +289,136 @@ const labels = isZh
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
.chip span { opacity: 0.55; margin-left: 0.15rem; }
|
||||
.chip:hover { color: var(--brand); border-color: var(--brand); }
|
||||
.chip.is-active { color: var(--ink); border-color: var(--brand); }
|
||||
|
||||
/* Featured block: collapses to zero height the moment the reader filters, and the
|
||||
viewport goes to the results. `grid-template-rows: 1fr → 0fr` animates without
|
||||
knowing the block's height (Chrome 117+, Safari 17.4+, FF 66+); older engines
|
||||
simply jump, which is acceptable for a 200 ms affordance. */
|
||||
.archive-featured {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
margin: 0 0 1.4rem;
|
||||
transition: grid-template-rows 0.2s ease, opacity 0.2s ease, margin-bottom 0.2s ease;
|
||||
}
|
||||
.archive-featured.is-collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
margin-bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.archive-featured-inner { overflow: hidden; min-height: 0; }
|
||||
.archive-featured-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin: 0.2rem 0 0.7rem;
|
||||
}
|
||||
.af-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 8px rgba(109, 141, 255, 0.8);
|
||||
}
|
||||
.archive-featured-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 232px), 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.feat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.feat-card:hover,
|
||||
.feat-card:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 10px 28px -14px rgba(0, 0, 0, 0.5), 0 0 18px -6px rgba(109, 141, 255, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
.feat-thumb { display: block; aspect-ratio: 16 / 9; background: var(--bg); }
|
||||
.feat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.feat-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.7rem 0.8rem 0.85rem; }
|
||||
.feat-top { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.feat-metric {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pulse-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--good);
|
||||
}
|
||||
.feat-title { font-size: 0.98rem; font-weight: 600; line-height: 1.3; color: var(--ink); }
|
||||
.feat-desc {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.feat-cta {
|
||||
margin-top: auto;
|
||||
padding-top: 0.35rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.feat-min { color: var(--faint); font-weight: 400; }
|
||||
|
||||
.archive-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
color: var(--faint);
|
||||
margin: 0 0 0.5rem;
|
||||
min-height: 28px;
|
||||
}
|
||||
.archive-clear {
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
color: var(--brand);
|
||||
background: none;
|
||||
border: 1px solid color-mix(in srgb, var(--brand) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
min-height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-clear:hover { border-color: var(--brand); }
|
||||
|
||||
.archive { list-style: none; padding: 0; margin: 0; }
|
||||
.archive-year {
|
||||
font-size: 0.8rem;
|
||||
@@ -197,11 +464,17 @@ const labels = isZh
|
||||
.archive-empty { color: var(--muted); margin-top: 1.5rem; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.archive-featured, .feat-card { transition: none; }
|
||||
.feat-card:hover, .feat-card:focus-visible { transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.archive-row { flex-wrap: wrap; gap: 0.3rem 0.6rem; padding: 0.4rem 0; }
|
||||
.archive-title { flex: 1 1 100%; order: 2; }
|
||||
.archive-row time { flex: 0 0 auto; order: 1; }
|
||||
.archive-cat { order: 1; }
|
||||
.chip { min-height: 36px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -209,16 +482,29 @@ const labels = isZh
|
||||
// Client-side filter over an already-rendered list: zero network requests, works
|
||||
// with the browser's own Ctrl+F, and keeps the archive one static page.
|
||||
const q = document.getElementById('archive-q') as HTMLInputElement | null;
|
||||
const list = document.getElementById('archive-list');
|
||||
const empty = document.getElementById('archive-empty');
|
||||
const chips = Array.from(document.querySelectorAll('.chip[data-cat]'));
|
||||
const overlayBtn = document.getElementById('archive-search');
|
||||
const featuredBlock = document.getElementById('archive-featured');
|
||||
const status = document.getElementById('archive-filter-status');
|
||||
const statusText = document.getElementById('archive-status-text');
|
||||
const clearBtn = document.getElementById('archive-clear') as HTMLButtonElement | null;
|
||||
let activeCat = '';
|
||||
|
||||
overlayBtn?.addEventListener('click', () => {
|
||||
document.getElementById('search-toggle')?.click();
|
||||
});
|
||||
|
||||
// A collapsed block with 0fr height still leaves its links focusable, so keyboard
|
||||
// and screen-reader users would tab into invisible cards — `inert` closes that.
|
||||
function setCollapsed(collapsed: boolean) {
|
||||
if (!featuredBlock) return;
|
||||
featuredBlock.classList.toggle('is-collapsed', collapsed);
|
||||
featuredBlock.dataset.collapsed = String(collapsed);
|
||||
featuredBlock.setAttribute('aria-hidden', String(collapsed));
|
||||
(featuredBlock as HTMLElement).inert = collapsed;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const term = (q?.value ?? '').trim().toLowerCase();
|
||||
const terms = term ? term.split(/\s+/) : [];
|
||||
@@ -239,6 +525,16 @@ const labels = isZh
|
||||
el.hidden = !any;
|
||||
});
|
||||
if (empty) empty.hidden = shown !== 0;
|
||||
|
||||
// Featured posts are rows in the full list too, so a hit is never lost while the
|
||||
// block is collapsed — the results list stays the single source of truth.
|
||||
const filtering = Boolean(term || activeCat);
|
||||
setCollapsed(filtering);
|
||||
if (clearBtn) clearBtn.hidden = !filtering;
|
||||
if (statusText && status) {
|
||||
const template = (filtering ? status.dataset.labelSome : status.dataset.labelAll) ?? '';
|
||||
statusText.textContent = template.replace('{n}', String(shown));
|
||||
}
|
||||
}
|
||||
|
||||
q?.addEventListener('input', apply);
|
||||
@@ -249,4 +545,11 @@ const labels = isZh
|
||||
apply();
|
||||
});
|
||||
});
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
if (q) q.value = '';
|
||||
activeCat = '';
|
||||
chips.forEach((c) => c.classList.toggle('is-active', (c as HTMLElement).dataset.cat === ''));
|
||||
apply();
|
||||
q?.focus();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
/**
|
||||
* PortfolioHero — the compact two-column hero for /categories/case-studies/.
|
||||
*
|
||||
* Round-2 ruling: text on the left, a wide (21:9) architecture visual on the right,
|
||||
* stacking naturally on mobile. The headline, blurb and facts are semantic HTML — the
|
||||
* banner carries no text, so nothing in the image has to be an SEO or a11y duplicate.
|
||||
*/
|
||||
interface Props {
|
||||
lang: 'en' | 'zh';
|
||||
title: string;
|
||||
blurb?: string;
|
||||
count: number;
|
||||
/** How many of the listed write-ups are the flagship set. */
|
||||
flagshipCount: number;
|
||||
}
|
||||
|
||||
const { lang, title, blurb, count, flagshipCount } = Astro.props;
|
||||
const isZh = lang === 'zh';
|
||||
|
||||
const copy = isZh
|
||||
? {
|
||||
kicker: '实战作品集',
|
||||
factsA: ['篇端到端复盘', '篇旗舰案例置顶'],
|
||||
hosts: '3 台生产主机',
|
||||
containers: '162 个容器',
|
||||
bilingual: '中英双语',
|
||||
cta: '先看旗舰案例',
|
||||
}
|
||||
: {
|
||||
kicker: 'Working portfolio',
|
||||
factsA: ['end-to-end write-ups', 'flagship cases pinned first'],
|
||||
hosts: '3 production hosts',
|
||||
containers: '162 containers',
|
||||
bilingual: 'EN + 中文',
|
||||
cta: 'Read the flagship cases',
|
||||
};
|
||||
|
||||
const alt = isZh
|
||||
? '架构示意图:三台生产主机(Unraid、群晖 NAS、VPS)通过 Docker 容器栈接入 Prometheus 监控中枢,再流向自动化工作流'
|
||||
: 'Architecture diagram: three production hosts (Unraid, Synology NAS, VPS) feeding Docker container stacks into a Prometheus monitoring hub, then into automation workflows';
|
||||
---
|
||||
|
||||
<section class="portfolio-hero">
|
||||
<div class="ph-copy">
|
||||
<p class="ph-kicker">{copy.kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
{blurb && <p class="ph-lede">{blurb}</p>}
|
||||
|
||||
<ul class="ph-facts">
|
||||
<li><b>{count}</b> {copy.factsA[0]}</li>
|
||||
<li><b>{flagshipCount}</b> {copy.factsA[1]}</li>
|
||||
<li>{copy.hosts} · {copy.containers}</li>
|
||||
<li>{copy.bilingual}</li>
|
||||
</ul>
|
||||
|
||||
<p class="ph-actions">
|
||||
<a class="ph-cta" href="#case-list">{copy.cta} <span aria-hidden="true">↓</span></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<figure class="ph-art">
|
||||
<picture>
|
||||
<source
|
||||
srcset="/images/categories/case-studies-banner-1200.webp 1200w, /images/categories/case-studies-banner.webp 2400w"
|
||||
sizes="(max-width: 959px) 100vw, 620px"
|
||||
type="image/webp"
|
||||
/>
|
||||
<img
|
||||
src="/images/categories/case-studies-banner.webp"
|
||||
alt={alt}
|
||||
width="2400"
|
||||
height="1029"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.portfolio-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.ph-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.portfolio-hero h1 { margin: 0 0 0.6rem; }
|
||||
|
||||
.ph-lede {
|
||||
color: var(--muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem;
|
||||
max-width: 44ch;
|
||||
}
|
||||
|
||||
.ph-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
.ph-facts li {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.ph-facts b { color: var(--ink); font-weight: 600; }
|
||||
|
||||
.ph-actions { margin: 0; }
|
||||
.ph-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0 0.1rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
.ph-cta:hover { text-decoration: underline; }
|
||||
|
||||
.ph-art { margin: 0; }
|
||||
.ph-art picture { display: block; }
|
||||
.ph-art img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0b1613;
|
||||
aspect-ratio: 21 / 9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Desktop: copy left, art right. The art column is sized so the 21:9 band lands in
|
||||
the 240–300 px range the consultant specified (620 px wide → ~266 px tall). */
|
||||
@media (min-width: 960px) {
|
||||
.portfolio-hero {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
|
||||
gap: 2.25rem;
|
||||
}
|
||||
.ph-art img { max-height: 300px; }
|
||||
}
|
||||
</style>
|
||||
@@ -9,9 +9,14 @@ interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
limit?: number;
|
||||
lang?: 'en' | 'zh';
|
||||
/**
|
||||
* Measured headline per base slug (see lib/portfolio.ts) — rendered as a pill on a
|
||||
* portfolio card. Omitted on the home page and tag pages, where a bare list is right.
|
||||
*/
|
||||
metrics?: Record<string, string>;
|
||||
}
|
||||
|
||||
const { posts, limit, lang = 'en' } = Astro.props;
|
||||
const { posts, limit, lang = 'en', metrics } = Astro.props;
|
||||
const shown = limit ? posts.slice(0, limit) : posts;
|
||||
const isZh = lang === 'zh';
|
||||
|
||||
@@ -45,6 +50,20 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
|
||||
const base = post.slug.replace(/^zh\//, '');
|
||||
return `/og/thumb/${base}-${w}.webp`;
|
||||
};
|
||||
|
||||
// A case study is a portfolio artefact, so the row's action says so. The same rule is
|
||||
// applied in Archive.astro for the /posts/ Featured block.
|
||||
const ctaLabel = (category: string) => {
|
||||
if (category === 'case-studies') return isZh ? '阅读案例复盘' : 'Read case study';
|
||||
return isZh ? '阅读文章' : 'Read article';
|
||||
};
|
||||
const metricFor = (post: CollectionEntry<'posts'>) =>
|
||||
metrics?.[post.slug.replace(/^zh\//, '')];
|
||||
|
||||
// A card that lists every tag wraps to two metadata lines on some rows and one on
|
||||
// others, so the grid loses its rhythm (and the summary/CTA drift). Show four and
|
||||
// fold the rest into a "+N" chip whose tooltip names them.
|
||||
const TAG_LIMIT = 4;
|
||||
---
|
||||
|
||||
<ul class="post-list">
|
||||
@@ -76,13 +95,30 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
|
||||
)}
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={catHref(post.data.category)}>{categoryLabel(post.data.category, lang)}</a>
|
||||
{post.data.tags.map((t) => {
|
||||
{post.data.tags.slice(0, TAG_LIMIT).map((t) => {
|
||||
const href = tagLink(t, counts, lang);
|
||||
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
|
||||
})}
|
||||
{post.data.tags.length > TAG_LIMIT && (
|
||||
<span class="tag tag-more" title={post.data.tags.slice(TAG_LIMIT).join(', ')}>
|
||||
+{post.data.tags.length - TAG_LIMIT}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{post.data.description}</p>
|
||||
<div class="row-foot">
|
||||
{/* A second visual target for the same destination — deliberately out of the
|
||||
tab order so keyboard users get one stop per card, not two. */}
|
||||
<a class="row-cta" href={`/posts/${post.slug}/`} tabindex="-1" aria-hidden="true">
|
||||
{ctaLabel(post.data.category)} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{metricFor(post) && (
|
||||
<span class="row-metric">
|
||||
<span class="pulse-dot" aria-hidden="true"></span>{metricFor(post)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
@@ -64,6 +64,20 @@ const switchLabel = isZh ? 'EN' : '中文';
|
||||
const nav = isZh
|
||||
? { blueprints: '系统蓝图', caseStudies: '工程案例', posts: '全部文章', about: '关于与合作' }
|
||||
: { blueprints: 'Blueprints', caseStudies: 'Case Studies', posts: 'All Posts', about: 'Work With Me' };
|
||||
|
||||
// Which section is the reader in? Without this the four nav links looked identical on
|
||||
// every page, so the new portfolio route had no visible "you are here".
|
||||
const sectionPath = Astro.url.pathname.replace(/^\/zh(?=\/|$)/, '') || '/';
|
||||
const inSection = {
|
||||
blueprints: sectionPath.startsWith('/blueprints'),
|
||||
caseStudies: sectionPath.startsWith('/categories/case-studies'),
|
||||
posts:
|
||||
(sectionPath.startsWith('/posts') ||
|
||||
sectionPath.startsWith('/tags') ||
|
||||
sectionPath.startsWith('/categories')) &&
|
||||
!sectionPath.startsWith('/categories/case-studies'),
|
||||
about: sectionPath.startsWith('/about') || sectionPath.startsWith('/work-with-me'),
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -184,10 +198,10 @@ const nav = isZh
|
||||
whole group onto a second row (`flex-basis: 100%`) instead of letting
|
||||
individual links wrap around the search / language / theme controls. */}
|
||||
<ul class="nav-links">
|
||||
<li><a class="nav-link" href={isZh ? '/zh/blueprints/' : '/blueprints/'}>{nav.blueprints}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/categories/case-studies/' : '/categories/case-studies/'}>{nav.caseStudies}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/posts/' : '/posts/'}>{nav.posts}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/about/' : '/about/'}>{nav.about}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/blueprints/' : '/blueprints/'} aria-current={inSection.blueprints ? 'page' : undefined}>{nav.blueprints}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/categories/case-studies/' : '/categories/case-studies/'} aria-current={inSection.caseStudies ? 'page' : undefined}>{nav.caseStudies}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/posts/' : '/posts/'} aria-current={inSection.posts ? 'page' : undefined}>{nav.posts}</a></li>
|
||||
<li><a class="nav-link" href={isZh ? '/zh/about/' : '/about/'} aria-current={inSection.about ? 'page' : undefined}>{nav.about}</a></li>
|
||||
</ul>
|
||||
<button class="search-toggle" id="search-toggle" aria-label={isZh ? '搜索' : 'Search'}>
|
||||
⌕ <span class="search-hint">{isZh ? '搜索' : 'Search'}</span><kbd class="search-key">/</kbd>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { readingMinutes } from './reading-time';
|
||||
import { categoryLabel } from './categories';
|
||||
|
||||
type Post = CollectionEntry<'posts'>;
|
||||
|
||||
/**
|
||||
* Flagship picks, shared by the home page and the archive's Featured block.
|
||||
*
|
||||
* Fixed slugs rather than "newest 4": the front page has to answer "who is this and
|
||||
* what has he actually run" (scale → automation → observability → a delivered
|
||||
* business result), and that answer must not change because a new post landed.
|
||||
*/
|
||||
export const FEATURED_SLUGS = [
|
||||
'running-production-infrastructure-solo',
|
||||
'loop-engineering-without-a-coding-agent',
|
||||
'one-prometheus-for-unraid-synology-and-a-vps',
|
||||
'self-healing-digital-goods-entitlements',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The measured headline for each flagship — a number, not an adjective. Rendered as
|
||||
* a pill next to the category badge so a reader scanning the grid gets the result
|
||||
* before opening anything.
|
||||
*/
|
||||
export const FEATURED_METRICS: Record<string, { en: string; zh: string }> = {
|
||||
'running-production-infrastructure-solo': {
|
||||
en: '3 Hosts · 162 Containers',
|
||||
zh: '3 台主机 · 162 个容器',
|
||||
},
|
||||
'loop-engineering-without-a-coding-agent': {
|
||||
en: '11 Cron Jobs · 8 Pure Loops',
|
||||
zh: '11 个定时任务 · 8 个循环',
|
||||
},
|
||||
'one-prometheus-for-unraid-synology-and-a-vps': {
|
||||
en: '3 Hosts · 1 Prometheus',
|
||||
zh: '3 台主机 · 1 个 Prometheus',
|
||||
},
|
||||
'self-healing-digital-goods-entitlements': {
|
||||
en: 'NocoDB + n8n + AList',
|
||||
zh: 'NocoDB + n8n + AList',
|
||||
},
|
||||
};
|
||||
|
||||
export interface FeaturedCard {
|
||||
/** Slug as it renders in the URL (ZH posts keep their `zh/` prefix). */
|
||||
slug: string;
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
metric: string;
|
||||
minutes: number;
|
||||
thumb400: string;
|
||||
thumb800: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the fixed slug list into render-ready cards for one language.
|
||||
*
|
||||
* `posts` must already be filtered to a single language (the listing pages do that
|
||||
* with `isEn`/`isZh`). Falls back to the newest posts of that language if a
|
||||
* flagship slug ever disappears, so a renamed file can never empty the block.
|
||||
*/
|
||||
export function pickFeatured(
|
||||
posts: Post[],
|
||||
lang: 'en' | 'zh' = 'en',
|
||||
limit: number = FEATURED_SLUGS.length,
|
||||
): FeaturedCard[] {
|
||||
const base = (slug: string) => slug.replace(/^zh\//, '');
|
||||
const bySlug = new Map(posts.map((p) => [base(p.slug), p]));
|
||||
|
||||
const picked = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(
|
||||
(p): p is Post => Boolean(p),
|
||||
);
|
||||
const source = picked.length >= Math.min(limit, FEATURED_SLUGS.length)
|
||||
? picked
|
||||
: posts.slice(0, limit);
|
||||
|
||||
return source.slice(0, limit).map((post) => {
|
||||
const clean = base(post.slug);
|
||||
return {
|
||||
slug: post.slug,
|
||||
href: `/posts/${post.slug}/`,
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
category: post.data.category,
|
||||
categoryLabel: categoryLabel(post.data.category, lang),
|
||||
metric: FEATURED_METRICS[clean]?.[lang] ?? categoryLabel(post.data.category, lang),
|
||||
minutes: readingMinutes(post.body),
|
||||
// Thumbs are keyed by the base slug, never by the og file name: a ZH post's
|
||||
// ogImage is /og/zh-<slug>.jpg while the renderer only emits
|
||||
// /og/thumb/<base>-<w>.webp.
|
||||
thumb400: `/og/thumb/${clean}-400.webp`,
|
||||
thumb800: `/og/thumb/${clean}-800.webp`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { sortForListing } from './sort';
|
||||
|
||||
type Post = CollectionEntry<'posts'>;
|
||||
|
||||
/**
|
||||
* Which case studies lead the portfolio, and why.
|
||||
*
|
||||
* The consultant's round-2 ruling dropped plain reverse-chronological order for this
|
||||
* category: a portfolio has to open with proof of scale, then proof of the hardest
|
||||
* work, not with whatever happened to be written last. These four cover the four
|
||||
* claims a hiring manager is checking — fleet scale, GPU/local-AI work, end-to-end
|
||||
* automation, and a delivered business result.
|
||||
*
|
||||
* `pickFeatured` (lib/featured.ts) reuses a subset of these for the home page and the
|
||||
* /posts/ Featured block, so the site never tells two different "flagship" stories.
|
||||
*/
|
||||
export const CASE_STUDY_FLAGSHIPS = [
|
||||
'running-production-infrastructure-solo',
|
||||
'self-hosted-tts-cosyvoice2-n8n-gpu',
|
||||
'loop-engineering-without-a-coding-agent',
|
||||
'self-healing-digital-goods-entitlements',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A measured headline per case study — shown as a pill on the portfolio cards.
|
||||
*
|
||||
* Only entries taken from the post body are listed here (container counts, VRAM, cron
|
||||
* counts, release counts). A post without a verifiable number gets no pill rather than
|
||||
* a decorative one; `orderCaseStudies` never invents a metric.
|
||||
*/
|
||||
export const CASE_STUDY_METRICS: Record<string, { en: string; zh: string }> = {
|
||||
'running-production-infrastructure-solo': { en: '3 Hosts · 162 Containers', zh: '3 台主机 · 162 个容器' },
|
||||
'one-prometheus-for-unraid-synology-and-a-vps': { en: '3 Hosts · 1 Prometheus', zh: '3 台主机 · 1 个 Prometheus' },
|
||||
'loop-engineering-without-a-coding-agent': { en: '11 Cron Jobs · 8 Loops', zh: '11 个定时任务 · 8 个循环' },
|
||||
'self-healing-digital-goods-entitlements': { en: 'NocoDB + n8n + AList', zh: 'NocoDB + n8n + AList' },
|
||||
'self-hosted-tts-cosyvoice2-n8n-gpu': { en: 'RTX 3060 · 3.2 GB VRAM', zh: 'RTX 3060 · 3.2 GB 显存' },
|
||||
'self-hosted-speech-to-text-api': { en: '3× faster than typing', zh: '比打字快 3 倍' },
|
||||
'how-i-vetted-20-vps-providers-with-parallel-subagents': { en: '20 VPS · 3 hours', zh: '20 家 VPS · 3 小时' },
|
||||
'how-i-built-the-digikedai-telegram-bot': { en: '24/7 instant reply', zh: '24/7 即时回复' },
|
||||
'how-i-built-an-automated-music-video-pipeline': { en: 'Remotion · Docker CDP', zh: 'Remotion · Docker CDP' },
|
||||
'from-audio-to-a-4-minute-ai-music-video': { en: '4-minute MV · one pipeline', zh: '4 分钟 MV · 一条流水线' },
|
||||
'how-i-host-this-blog': { en: 'Astro · Gitea Actions', zh: 'Astro · Gitea Actions' },
|
||||
'self-hosting-mem0-memory-stack': { en: 'mem0 · pgvector · LiteLLM', zh: 'mem0 · pgvector · LiteLLM' },
|
||||
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': { en: '22 releases', zh: '22 个版本' },
|
||||
'ai-furniture-compositing-with-flux-kontext': { en: 'FLUX.1 Kontext', zh: 'FLUX.1 Kontext' },
|
||||
'yue2-on-a-12gb-gpu-songs-that-lost-their-endings': { en: 'YuE2 · 12 GB VRAM', zh: 'YuE2 · 12 GB 显存' },
|
||||
'generating-full-length-songs-with-yue2-comfyui': { en: 'ComfyUI · YuE2', zh: 'ComfyUI · YuE2' },
|
||||
};
|
||||
|
||||
const baseSlug = (slug: string) => slug.replace(/^zh\//, '');
|
||||
|
||||
/**
|
||||
* Flagships first (in the declared order), then everything else newest-first.
|
||||
* Stable across languages: the same slugs lead /categories/case-studies/ and
|
||||
* /zh/categories/case-studies/.
|
||||
*/
|
||||
export function orderCaseStudies(posts: Post[]): Post[] {
|
||||
const bySlug = new Map(posts.map((p) => [baseSlug(p.slug), p]));
|
||||
const flagshipPosts = CASE_STUDY_FLAGSHIPS.map((s) => bySlug.get(s)).filter(
|
||||
(p): p is Post => Boolean(p),
|
||||
);
|
||||
const flagshipSet = new Set(flagshipPosts.map((p) => p.slug));
|
||||
const rest = sortForListing(posts.filter((p) => !flagshipSet.has(p.slug)));
|
||||
return [...flagshipPosts, ...rest];
|
||||
}
|
||||
|
||||
/** Metrics map for PostList, keyed by base slug so an EN page and its ZH twin match. */
|
||||
export function caseStudyMetrics(lang: 'en' | 'zh'): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [slug, value] of Object.entries(CASE_STUDY_METRICS)) out[slug] = value[lang];
|
||||
return out;
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { SITE } from '../../config';
|
||||
import PostList from '../../components/PostList.astro';
|
||||
import CategoryArt from '../../components/CategoryArt.astro';
|
||||
import PortfolioHero from '../../components/PortfolioHero.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
import { sortForListing } from '../../lib/sort';
|
||||
import { categoryMeta } from '../../lib/categories';
|
||||
import { CASE_STUDY_FLAGSHIPS, caseStudyMetrics, orderCaseStudies } from '../../lib/portfolio';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', isEn);
|
||||
@@ -17,30 +19,54 @@ export async function getStaticPaths() {
|
||||
const { category } = Astro.params;
|
||||
const meta = categoryMeta(category as string);
|
||||
|
||||
const posts = sortForListing(
|
||||
(await getCollection('posts', isEn)).filter((p) => p.data.category === category),
|
||||
// case-studies is the portfolio: flagship write-ups lead instead of the newest post,
|
||||
// and each card carries its measured headline. Every other category stays plain
|
||||
// reverse-chronological.
|
||||
const isPortfolio = category === 'case-studies';
|
||||
const inCategory = (await getCollection('posts', isEn)).filter(
|
||||
(p) => p.data.category === category,
|
||||
);
|
||||
const posts = isPortfolio ? orderCaseStudies(inCategory) : sortForListing(inCategory);
|
||||
const metrics = isPortfolio ? caseStudyMetrics('en') : undefined;
|
||||
const flagshipCount = isPortfolio
|
||||
? posts.filter((p) => (CASE_STUDY_FLAGSHIPS as readonly string[]).includes(p.slug)).length
|
||||
: 0;
|
||||
|
||||
const name = meta?.name.en ?? category;
|
||||
const blurb = meta?.blurb.en;
|
||||
---
|
||||
|
||||
|
||||
<BaseLayout
|
||||
title={`${name} — Mr Hoelee`}
|
||||
description={blurb ?? `Posts in the ${category} category.`}
|
||||
switchHref={`/zh/categories/${category}/`}
|
||||
altLocaleUrl={`${SITE.url}/zh/categories/${category}/`}
|
||||
>
|
||||
<h1>{name}</h1>
|
||||
{blurb && <p class="lede">{blurb}</p>}
|
||||
{isPortfolio ? (
|
||||
<PortfolioHero
|
||||
lang="en"
|
||||
title={name}
|
||||
blurb={blurb}
|
||||
count={posts.length}
|
||||
flagshipCount={flagshipCount}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1>{name}</h1>
|
||||
{blurb && <p class="lede">{blurb}</p>}
|
||||
|
||||
<div class="cat-art-wrap">
|
||||
<CategoryArt slug={category} />
|
||||
</div>
|
||||
<div class="cat-art-wrap">
|
||||
<CategoryArt slug={category} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p class="cat-count">
|
||||
{posts.length} post{posts.length === 1 ? '' : 's'} in this category
|
||||
</p>
|
||||
<section id="case-list">
|
||||
<p class="cat-count">
|
||||
{posts.length} post{posts.length === 1 ? '' : 's'} in this category
|
||||
</p>
|
||||
|
||||
<PostList posts={posts} lang="en" />
|
||||
<PostList posts={posts} lang="en" metrics={metrics} />
|
||||
</section>
|
||||
</BaseLayout>
|
||||
+15
-30
@@ -5,31 +5,18 @@ import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../lib/lang';
|
||||
import { SITE } from '../config';
|
||||
import { sortForListing } from '../lib/sort';
|
||||
import { pickFeatured } from '../lib/featured';
|
||||
|
||||
const posts = sortForListing(await getCollection('posts', isEn));
|
||||
const engineeringPosts = posts.filter((p) => p.data.stream !== 'creative-lab');
|
||||
const creativeLabPosts = posts.filter((p) => p.data.stream === 'creative-lab');
|
||||
|
||||
// Featured picks: fixed slugs, not "newest 4", so the front page stays stable after a
|
||||
// publish and keeps answering "who is this and what has he actually run" (scale →
|
||||
// automation → observability → a delivered business result). Falls back to the newest
|
||||
// posts if a slug ever disappears.
|
||||
const FEATURED_SLUGS = [
|
||||
'running-production-infrastructure-solo',
|
||||
'loop-engineering-without-a-coding-agent',
|
||||
'one-prometheus-for-unraid-synology-and-a-vps',
|
||||
'self-healing-digital-goods-entitlements',
|
||||
];
|
||||
const bySlug = new Map(posts.map((p) => [p.slug, p]));
|
||||
const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(Boolean);
|
||||
const featuredPosts = featured.length === 4 ? featured : posts.slice(0, 4);
|
||||
|
||||
const featuredMetrics: Record<string, string> = {
|
||||
'running-production-infrastructure-solo': '3 Hosts · 162 Containers',
|
||||
'loop-engineering-without-a-coding-agent': '11 Cron Jobs · 8 Pure Loops',
|
||||
'one-prometheus-for-unraid-synology-and-a-vps': '3 Hosts · 1 Prometheus',
|
||||
'self-healing-digital-goods-entitlements': 'NocoDB + n8n + AList',
|
||||
};
|
||||
// Featured picks live in src/lib/featured.ts so the front page and the /posts/
|
||||
// archive render the same flagship set with the same measured headline. Fixed slugs,
|
||||
// not "newest 4", so the front page stays stable after a publish — see the comment
|
||||
// in that module for the ordering rationale (scale → automation → observability →
|
||||
// a delivered business result).
|
||||
const featured = pickFeatured(posts, 'en', 4);
|
||||
---
|
||||
|
||||
<BaseLayout title={SITE.title} description={SITE.description} altLocaleUrl={`${SITE.url}/zh/`}>
|
||||
@@ -142,15 +129,13 @@ const featuredMetrics: Record<string, string> = {
|
||||
<section id="featured">
|
||||
<h2>Featured</h2>
|
||||
<div class="featured-bento-grid">
|
||||
{featuredPosts.map((post, idx) => {
|
||||
const cleanSlug = post.slug.replace(/^zh\//, '');
|
||||
const metric = featuredMetrics[cleanSlug] ?? post.data.category;
|
||||
{featured.map((post, idx) => {
|
||||
return (
|
||||
<a class={`bento-card bento-item-${idx + 1}`} href={`/posts/${post.slug}/`}>
|
||||
<a class={`bento-card bento-item-${idx + 1}`} href={post.href}>
|
||||
<div class="bento-visual" aria-hidden="true">
|
||||
<img
|
||||
src={`/og/thumb/${cleanSlug}-800.webp`}
|
||||
srcset={`/og/thumb/${cleanSlug}-400.webp 400w, /og/thumb/${cleanSlug}-800.webp 800w`}
|
||||
src={post.thumb800}
|
||||
srcset={`${post.thumb400} 400w, ${post.thumb800} 800w`}
|
||||
sizes="(max-width: 640px) 60vw, 341px"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@@ -163,14 +148,14 @@ const featuredMetrics: Record<string, string> = {
|
||||
</div>
|
||||
<div class="bento-content">
|
||||
<div class="bento-top-meta">
|
||||
<span class="featured-badge">{post.data.category}</span>
|
||||
<span class="featured-badge">{post.category}</span>
|
||||
<span class="bento-metric-pill">
|
||||
<span class="pulse-dot"></span>
|
||||
{metric}
|
||||
{post.metric}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{post.data.title}</h3>
|
||||
<p>{post.data.description}</p>
|
||||
<h3>{post.title}</h3>
|
||||
<p>{post.description}</p>
|
||||
<span class="bento-cta">Read case study →</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -4,12 +4,18 @@ import Archive from '../../components/Archive.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isEn } from '../../lib/lang';
|
||||
import { sortForListing } from '../../lib/sort';
|
||||
import { pickFeatured } from '../../lib/featured';
|
||||
import { SITE } from '../../config';
|
||||
|
||||
// One static page for all posts: the filter (title/tags/category) runs client-side
|
||||
// over this already-rendered list, so it costs no requests and Ctrl+F still works.
|
||||
// Before 2026-10-16 this page rendered 71 full cards and stood 18,220px tall.
|
||||
//
|
||||
// Since 2026-10-27 the page is hybrid: three flagship case-study cards on top, then
|
||||
// the full year/month archive. The featured block collapses as soon as the reader
|
||||
// filters (Archive.astro), handing the viewport to the results.
|
||||
const posts = sortForListing(await getCollection('posts', isEn));
|
||||
const featured = pickFeatured(posts, 'en', 3);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -23,5 +29,5 @@ const posts = sortForListing(await getCollection('posts', isEn));
|
||||
{posts.length} posts, newest first. Filter by title, tag or category below — or read a
|
||||
whole topic through its <a href="/tags/docker/">tag page</a>.
|
||||
</p>
|
||||
<Archive posts={posts} lang="en" />
|
||||
<Archive posts={posts} lang="en" featured={featured} />
|
||||
</BaseLayout>
|
||||
@@ -3,10 +3,12 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { SITE } from '../../../config';
|
||||
import PostList from '../../../components/PostList.astro';
|
||||
import CategoryArt from '../../../components/CategoryArt.astro';
|
||||
import PortfolioHero from '../../../components/PortfolioHero.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isZh } from '../../../lib/lang';
|
||||
import { sortForListing } from '../../../lib/sort';
|
||||
import { categoryMeta } from '../../../lib/categories';
|
||||
import { CASE_STUDY_FLAGSHIPS, caseStudyMetrics, orderCaseStudies } from '../../../lib/portfolio';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts', isZh);
|
||||
@@ -17,14 +19,25 @@ export async function getStaticPaths() {
|
||||
const { category } = Astro.params;
|
||||
const meta = categoryMeta(category as string);
|
||||
|
||||
const posts = sortForListing(
|
||||
(await getCollection('posts', isZh)).filter((p) => p.data.category === category),
|
||||
// Same portfolio treatment as the EN page: flagships lead, cards carry the measured
|
||||
// headline, and the ZH twins are matched by base slug so both languages agree.
|
||||
const isPortfolio = category === 'case-studies';
|
||||
const inCategory = (await getCollection('posts', isZh)).filter(
|
||||
(p) => p.data.category === category,
|
||||
);
|
||||
const posts = isPortfolio ? orderCaseStudies(inCategory) : sortForListing(inCategory);
|
||||
const metrics = isPortfolio ? caseStudyMetrics('zh') : undefined;
|
||||
const flagshipCount = isPortfolio
|
||||
? posts.filter((p) =>
|
||||
(CASE_STUDY_FLAGSHIPS as readonly string[]).includes(p.slug.replace(/^zh\//, '')),
|
||||
).length
|
||||
: 0;
|
||||
|
||||
const name = meta?.name.zh ?? category;
|
||||
const blurb = meta?.blurb.zh;
|
||||
---
|
||||
|
||||
|
||||
<BaseLayout
|
||||
title={`${name} — Mr Hoelee`}
|
||||
description={blurb ?? `「${category}」分类下的文章。`}
|
||||
@@ -32,14 +45,28 @@ const blurb = meta?.blurb.zh;
|
||||
switchHref={`/categories/${category}/`}
|
||||
altLocaleUrl={`${SITE.url}/categories/${category}/`}
|
||||
>
|
||||
<h1>{name}</h1>
|
||||
{blurb && <p class="lede">{blurb}</p>}
|
||||
{isPortfolio ? (
|
||||
<PortfolioHero
|
||||
lang="zh"
|
||||
title={name}
|
||||
blurb={blurb}
|
||||
count={posts.length}
|
||||
flagshipCount={flagshipCount}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1>{name}</h1>
|
||||
{blurb && <p class="lede">{blurb}</p>}
|
||||
|
||||
<div class="cat-art-wrap">
|
||||
<CategoryArt slug={category} />
|
||||
</div>
|
||||
<div class="cat-art-wrap">
|
||||
<CategoryArt slug={category} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p class="cat-count">本分类共 {posts.length} 篇文章</p>
|
||||
<section id="case-list">
|
||||
<p class="cat-count">本分类共 {posts.length} 篇文章</p>
|
||||
|
||||
<PostList posts={posts} lang="zh" />
|
||||
<PostList posts={posts} lang="zh" metrics={metrics} />
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -4,12 +4,14 @@ import Archive from '../../../components/Archive.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isZh } from '../../../lib/lang';
|
||||
import { sortForListing } from '../../../lib/sort';
|
||||
import { pickFeatured } from '../../../lib/featured';
|
||||
import { SITE } from '../../../config';
|
||||
|
||||
// The ZH archive. Until 2026-10-16 the ZH nav pointed "文章/关于" at /zh/ and this
|
||||
// route did not exist at all: /posts/zh/ (a directory with no index) answered 403
|
||||
// from nginx, and /zh/posts/ 404'd. Both are fixed here + in the nginx conf.
|
||||
const posts = sortForListing(await getCollection('posts', isZh));
|
||||
const featured = pickFeatured(posts, 'zh', 3);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
@@ -24,5 +26,5 @@ const posts = sortForListing(await getCollection('posts', isZh));
|
||||
共 {posts.length} 篇,按时间倒序。下方可按标题、标签或分类即时筛选,也可以走
|
||||
<a href="/zh/tags/docker/">标签页</a> 把一个主题读完。
|
||||
</p>
|
||||
<Archive posts={posts} lang="zh" />
|
||||
<Archive posts={posts} lang="zh" featured={featured} />
|
||||
</BaseLayout>
|
||||
@@ -139,6 +139,20 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav .nav-link:hover { color: var(--brand); }
|
||||
.nav .nav-link[aria-current='page'] {
|
||||
color: var(--ink);
|
||||
position: relative;
|
||||
}
|
||||
.nav .nav-link[aria-current='page']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -0.35rem;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand);
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -361,6 +375,12 @@ span.tag {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
span.tag-more {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted);
|
||||
border-style: solid;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* ---------- Code ---------- */
|
||||
code {
|
||||
@@ -1352,6 +1372,49 @@ pre code {
|
||||
}
|
||||
}
|
||||
|
||||
/* Row footer: the action wording (article vs case study) plus, on the portfolio
|
||||
category, the measured headline for that write-up. */
|
||||
.post-list .row-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.6rem;
|
||||
min-height: 28px;
|
||||
}
|
||||
.post-list .row-cta {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.post-list .row-cta:hover { text-decoration: underline; }
|
||||
.post-list .row-metric {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.55rem;
|
||||
}
|
||||
|
||||
/* Touch targets: on a phone the card's title link is the primary tap surface, and a
|
||||
single-line title only measured ~26 px. Give it the 44 px minimum. */
|
||||
@media (max-width: 700px) {
|
||||
.post-list .body > a:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Page head (lead paragraphs on listing pages) ---------- */
|
||||
.page-head h1 { margin: 0 0 0.75rem; }
|
||||
.lede {
|
||||
|
||||
Reference in new issue
Block a user