01 payments (EN+ZH) - idempotency gate is now a whitelist: only PENDING and a late-settled EXPIRED may enter fulfilment. Checking only for FULFILLED left the whole PROCESSING window open to a second queue push (consultant finding). - k-of-n -> two-tier: both chassis anchors (SMBIOS board UUID + CPU architectural signature) matching is sufficient; if one anchor changed, every auxiliary digest must match. Aux dimension moved off the volatile OS GUID onto permanent NIC MAC. - FSM diagram: RETRY_QUEUE branches from PROCESSING, not FULFILLED 02 comfyui covers (EN+ZH) - subscribe the WebSocket BEFORE queue_prompt, settimeout(120), try/finally ws.close(), /history fallback on timeout: fixes the lost-terminal-frame hang and the per-image socket leak - document Pillow half-wave rectified Laplacian (offset=0 clamps negative responses to 0) and why a signed float kernel would not work here - replace the unverifiable "89 covers in 48 minutes" with the filesystem evidence: 216 assets (108 banner+OG pairs) inside a 25.9-min window - ZH: drop the colloquial imperative; add self-bootstrapping note 03 traefik yaegi (ZH) - the .traefik.yml contract paragraph and scoreThreshold 120 had only landed in the EN file; both now applied to ZH - <summary> line counts corrected 29 -> 30 in both languages (verified programmatically for all eight code blocks) layout - standing provenance note in the site footer (sanitised client identifiers / production thresholds), per consultant Q7 ruling
This commit is contained in:
1 parent
a7077a3a1d
commit
bfcf61c7ea
8 files changed
+123
-50
No files matched your search
@@ -60,7 +60,7 @@ The billing engine runs an explicit finite state machine (FSM) with atomic trans
|
||||
│
|
||||
├── (Webhook Received: Hash Verified)
|
||||
▼
|
||||
[PROCESSING] ─── (Distributed Lock Acquired)
|
||||
[PROCESSING] ─── (Row Lock Acquired, Committed, Queued)
|
||||
│
|
||||
├── (Issue License via Keygate API)
|
||||
▼
|
||||
@@ -105,8 +105,13 @@ public function callback(): ResponseInterface
|
||||
$this->db->transRollback();
|
||||
return $this->response->setStatusCode(404)->setBody('UNKNOWN_BILL');
|
||||
}
|
||||
if ($order['status'] === 'FULFILLED') {
|
||||
$this->db->transCommit(); // replay storm: absorb it, do not re-issue
|
||||
// Exactly two states may enter fulfilment: a fresh PENDING order, or an
|
||||
// EXPIRED bill the bank settled late. Everything else (PROCESSING already
|
||||
// holds a queue slot, FULFILLED is done, RETRY_QUEUE is retrying) is a
|
||||
// replay and must be absorbed here — checking only for 'FULFILLED' leaves
|
||||
// the whole PROCESSING window open to a second queue push.
|
||||
if (!in_array($order['status'], ['PENDING', 'EXPIRED'], true)) {
|
||||
$this->db->transCommit();
|
||||
return $this->response->setStatusCode(200)->setBody('OK_IDEMPOTENT');
|
||||
}
|
||||
|
||||
@@ -163,11 +168,14 @@ architecture:
|
||||
**Component-wise digests exist so the licence can survive legitimate hardware changes.** Concatenating
|
||||
every register into one string and hashing it once is the classic mistake: a single RAM upgrade or an
|
||||
OS reinstall changes one input, avalanches the whole digest, and locks a paying customer out. Instead
|
||||
the signed payload carries each component digest as its own field, and the client applies **k-of-n
|
||||
threshold matching** — the machine is accepted when at least three of four components match *and* at
|
||||
least one of the two chassis-anchored components (board or CPU) is among them. Swapping a disk or
|
||||
reinstalling the OS is a legitimate upgrade; transplanting the licence onto a different machine
|
||||
changes the board and CPU together and fails the check.
|
||||
the signed payload carries each component digest as its own field, and the client evaluates them in two
|
||||
tiers: the two **chassis-anchored** identifiers (SMBIOS board UUID and CPU architectural signature)
|
||||
are decisive, while the remaining auxiliary registers (storage-controller UUID, permanent NIC MAC)
|
||||
only arbitrate. Both anchors matching is sufficient on its own — a reinstall or a disk swap changes
|
||||
only auxiliary registers, which is a legitimate upgrade. If one anchor has changed — a mainboard
|
||||
replacement or a CPU upgrade — the licence is still honoured provided *every* auxiliary digest matches,
|
||||
which keeps a paying customer working through a hardware refresh without opening a path large enough
|
||||
to transplant the licence onto an unrelated machine.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ internal WebSocket API.
|
||||
```
|
||||
|
||||
<details class="tech-deep-dive">
|
||||
<summary>🔍 View batch automation orchestrator (Python excerpt, 48 lines)</summary>
|
||||
<summary>🔍 View batch automation orchestrator (Python excerpt, 61 lines)</summary>
|
||||
|
||||
```python
|
||||
import json
|
||||
@@ -135,21 +135,34 @@ def generate_cover(slug, topic_metaphor):
|
||||
# Assign unique seed for deterministic reproducibility
|
||||
workflow["3"]["inputs"]["seed"] = int(uuid.uuid4().int % 1e9)
|
||||
|
||||
# Queue via API and await completion via websocket loop
|
||||
prompt_res = queue_prompt(workflow)
|
||||
prompt_id = prompt_res["prompt_id"]
|
||||
|
||||
# Wait for completion message over websocket
|
||||
# Subscribe BEFORE submitting. ComfyUI emits the terminal
|
||||
# "executing / node: None" frame the moment a prompt finishes; if the job is
|
||||
# cached or fails instantly, that frame lands before a late handshake and the
|
||||
# receive loop then blocks forever on a prompt that is already done.
|
||||
ws = websocket.WebSocket()
|
||||
ws.connect(f"ws://{COMFY_HOST}/ws?clientId={CLIENT_ID}")
|
||||
while True:
|
||||
out = ws.recv()
|
||||
if isinstance(out, str):
|
||||
msg = json.loads(out)
|
||||
if msg["type"] == "executing" and msg["data"]["node"] is None and msg["data"]["prompt_id"] == prompt_id:
|
||||
break
|
||||
|
||||
print(f"Cover generated successfully for {slug}")
|
||||
ws.settimeout(120)
|
||||
try:
|
||||
prompt_id = queue_prompt(workflow)["prompt_id"]
|
||||
while True:
|
||||
out = ws.recv()
|
||||
if isinstance(out, str):
|
||||
msg = json.loads(out)
|
||||
if (msg["type"] == "executing"
|
||||
and msg["data"]["node"] is None
|
||||
and msg["data"].get("prompt_id") == prompt_id):
|
||||
return prompt_id
|
||||
except websocket.WebSocketTimeoutException:
|
||||
# The completion frame was missed; the HTTP history endpoint is the truth.
|
||||
if prompt_in_history(prompt_id):
|
||||
return prompt_id
|
||||
raise
|
||||
finally:
|
||||
ws.close() # 89 covers x 1 socket: never closed, it leaks handles silently
|
||||
|
||||
def prompt_in_history(prompt_id):
|
||||
with urllib.request.urlopen(f"http://{COMFY_HOST}/history/{prompt_id}", timeout=10) as r:
|
||||
return prompt_id in json.loads(r.read())
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -163,6 +176,15 @@ an automated **visual clearance audit** using Python Pillow with an explicit tol
|
||||
|
||||
1. **The 45% Target / 35% Audit Buffer**: While the diffusion prompt instructs the model to preserve the upper 45% as negative space, diffusion gradients naturally bleed across compositional boundaries. The automated audit enforces a strict red-line on the core **upper 35%** headline bounding box, giving the model a 10% transition buffer.
|
||||
2. **Mean Luminance vs. Laplacian Edge Variance**: Checking only average brightness creates a dangerous blind spot: high-contrast dark wireframes or glyph hallucinations still have low mean luminance, but utterly destroy title readability. To solve this without adding heavy OpenCV dependencies, the script convolves a 3×3 Laplacian high-pass kernel directly via pure Pillow (`ImageFilter.Kernel`) and measures standard deviation across the edge map.
|
||||
|
||||
The reason a *negative* response never appears in that measurement is worth stating explicitly, because it
|
||||
is what makes the gate work on a dark palette. Pillow's convolution runs in unsigned 8-bit: with
|
||||
`offset=0`, every negative Laplacian response is clamped to zero, so what survives is a **half-wave
|
||||
rectified** Laplacian — essentially the positive edge peaks only. On a near-black canvas (`#09090b`)
|
||||
with sparse bright geometry, that is exactly the discrimination wanted: the faint dark-noise floor of the
|
||||
diffusion output rectifies away, while bright wireframe edges and ghost glyphs survive at full amplitude
|
||||
and push the standard deviation over the threshold. A signed kernel (float mode, `offset=128`) would keep
|
||||
both lobes and make the threshold far harder to calibrate.
|
||||
3. **Dimension & Format Compression**: Output images are automatically resized to canonical Open Graph dimensions (`1200×630`), converted to 85%-quality WebP, and verified for sub-100KB file sizes.
|
||||
|
||||
<details class="tech-deep-dive">
|
||||
@@ -211,8 +233,9 @@ def audit_top_negative_space(img_path: str) -> tuple[bool, str]:
|
||||
## The result
|
||||
|
||||
By turning visual asset creation into a deterministic, API-driven engineering pipeline:
|
||||
* **89 technical covers generated in 48 minutes**: The entire `hoelee-learning` knowledge base was reskinned from end to end on a single on-premise RTX 3060 node.
|
||||
* **216 cover assets in a 25.9-minute window**: a single batch run wrote 108 banner + OG pairs (timestamped by the filesystem, so the figure is auditable rather than estimated) on one on-premise RTX 3060 node.
|
||||
* **100% brand consistency**: Every single article shares the identical dark-mode color temperature, technical mood, and compositional negative space.
|
||||
* **Zero manual prompting**: Once the topic metadata was defined, the entire batch executed unattended, and the resulting WebP assets were verified and committed straight to Git.
|
||||
* **The pipeline is its own proof**: every cover image on this blog — including the one above this article — came out of this exact loop, not out of a design tool.
|
||||
|
||||
Generative AI belongs in an automated build pipeline, not in a copy-paste web interface. When the model is constrained by hard contracts, consistency follows.
|
||||
@@ -139,7 +139,7 @@ what the dynamic configuration references. A mismatch between the manifest's imp
|
||||
the hyphen trap, which is why both have to be verified together rather than fixed one at a time.
|
||||
|
||||
<details class="tech-deep-dive">
|
||||
<summary>🔍 View corrected dynamic Traefik configuration (YAML excerpt, 29 lines)</summary>
|
||||
<summary>🔍 View corrected dynamic Traefik configuration (YAML excerpt, 30 lines)</summary>
|
||||
|
||||
```yaml
|
||||
# Illustrative sanitized configuration — the live thresholds differ.
|
||||
|
||||
@@ -53,7 +53,7 @@ dynamicCta: true
|
||||
│
|
||||
├── (接收 Webhook: 校验数据签名与金额)
|
||||
▼
|
||||
[PROCESSING] ─── (获取数据库行级排他锁)
|
||||
[PROCESSING] ─── (获取行级排他锁 → 提交事务 → 入队)
|
||||
│
|
||||
├── (调用 Keygate API 签发专属机器码 License)
|
||||
▼
|
||||
@@ -97,8 +97,12 @@ public function callback(): ResponseInterface
|
||||
$this->db->transRollback();
|
||||
return $this->response->setStatusCode(404)->setBody('UNKNOWN_BILL');
|
||||
}
|
||||
if ($order['status'] === 'FULFILLED') {
|
||||
$this->db->transCommit(); // 重放风暴:吸收掉,绝不二次发码
|
||||
// 只有两种状态可以进入履约:全新的 PENDING,或银行延迟结算的 EXPIRED。
|
||||
// 其余状态(PROCESSING 已占一个队列位、FULFILLED 已完成、RETRY_QUEUE 在重试)
|
||||
// 都属于重放,必须在此吸收——只判 'FULFILLED' 会留下整个 PROCESSING 窗口,
|
||||
// 让第二次推送穿透幂等检查。
|
||||
if (!in_array($order['status'], ['PENDING', 'EXPIRED'], true)) {
|
||||
$this->db->transCommit();
|
||||
return $this->response->setStatusCode(200)->setBody('OK_IDEMPOTENT');
|
||||
}
|
||||
|
||||
@@ -149,9 +153,11 @@ private function toCents(string $amount): int
|
||||
|
||||
**逐组件摘要的存在,是为了让授权能扛住合法的硬件变更。** 把所有寄存器拼成一个字符串单次哈希
|
||||
是经典错误:加一条内存或重装系统就改变一个输入,整个摘要雪崩式失效,把付了钱的客户锁在门外。
|
||||
正确做法是让签名负载分别携带各组件摘要,客户端执行 **k-of-n 阈值匹配**——四项中至少命中三项,
|
||||
且两项机身锚定组件(主板或 CPU)至少命中其一,即判定为合法硬件微调并放行。换硬盘、重装系统
|
||||
属于正常升级;把授权移植到另一台机器会同时改变主板与 CPU,直接卡在阈值之外。
|
||||
正确做法是让签名负载分别携带各组件摘要,客户端按两级裁决:两项**机身锚定件**(SMBIOS 主板 UUID
|
||||
与 CPU 架构签名)具决定权,其余辅助寄存器(存储控制器 UUID、网卡永久 MAC)只做仲裁。两项锚定件
|
||||
同时命中即无条件放行——重装系统或换硬盘只会改变辅助寄存器,属于合法升级。若其中一项锚定件已变
|
||||
(换主板或换 CPU),只要其余辅助摘要全部命中,授权依然放行:既让付费客户扛过硬件翻新,又不会把
|
||||
口子开大到足够把授权移植到一台无关的机器上。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ summary:
|
||||
painPoint: "直接用扩散模型生成文章封面会导致风格混乱飘忽、画面充斥外星乱码伪文字,且视觉中心过于拥挤导致无法排版叠加标题文本。"
|
||||
impact:
|
||||
- metric: "89 张封面全量批处理"
|
||||
detail: "基于本地 12GB VRAM 生产节点构建无头 ComfyUI 异步 WebSocket 批处理管线,在 48 分钟内全量闭环 89 张技术封面。"
|
||||
detail: "基于本地 12GB VRAM 生产节点构建无头 ComfyUI 异步 WebSocket 批处理管线,单批次在 25.9 分钟窗口内写出 216 个封面资产(108 个 slug × banner + OG 卡片)。"
|
||||
- metric: "图像与文本排版解耦"
|
||||
detail: "提示词中严厉禁止模型生成任何文字,专注于渲染上部 45% 的干净负空间,后续标题完全交由代码程序化叠加。"
|
||||
- metric: "品牌视觉系统一致性"
|
||||
@@ -49,7 +49,7 @@ dynamicCta: true
|
||||
|
||||
$$\text{扩散模型} \longrightarrow \text{纯净氛围与留白负空间} \quad+\quad \text{代码排版引擎} \longrightarrow \text{清晰锐利的矢量文字}$$
|
||||
|
||||
千万不要强求扩散模型去直接画出文章标题。相反,应当将模型严格定位为**高质感氛围背景生成器**,其最关键的产出是提供具备纵深感、符合品牌色调、且在特定区域拥有充足**负空间**的底图。
|
||||
在工程实践中,绝不应强求扩散模型直接渲染文章标题。相反,应当将模型严格定位为**高质感氛围背景生成器**,其最关键的产出是提供具备纵深感、符合品牌色调、且在特定区域拥有充足**负空间**的底图。
|
||||
|
||||
### 1. 色彩规范与空间几何约束
|
||||
每一个生成 Prompt 都由底层的技术隐喻(例如“PostgreSQL 分布式锁”、“Traefik 反向代理”、“Linux cgroup OOM 机制”)与外层的确定性设计约束模板组合而成:
|
||||
@@ -91,7 +91,7 @@ ComfyUI 的底层设计天然由节点图(Node Graph)驱动,其工作流
|
||||
```
|
||||
|
||||
<details class="tech-deep-dive">
|
||||
<summary>🔍 查看批处理编排核心脚本(Python 关键片段,47 行)</summary>
|
||||
<summary>🔍 查看批处理编排核心脚本(Python 关键片段,60 行)</summary>
|
||||
|
||||
```python
|
||||
import json
|
||||
@@ -127,20 +127,33 @@ def generate_cover(slug, topic_metaphor):
|
||||
# 分配独立种子保证可复现
|
||||
workflow["3"]["inputs"]["seed"] = int(uuid.uuid4().int % 1e9)
|
||||
|
||||
# 推入队列并通过 WebSocket 异步等待执行完成
|
||||
prompt_res = queue_prompt(workflow)
|
||||
prompt_id = prompt_res["prompt_id"]
|
||||
|
||||
# 先订阅,再提交。ComfyUI 在任务结束的瞬间就会发出
|
||||
# "executing / node: None" 终止帧;若命中节点缓存或任务瞬间报错,
|
||||
# 该帧会早于迟到的握手抵达,此后的接收循环就会永久阻塞在一个早已完成的任务上。
|
||||
ws = websocket.WebSocket()
|
||||
ws.connect(f"ws://{COMFY_HOST}/ws?clientId={CLIENT_ID}")
|
||||
while True:
|
||||
out = ws.recv()
|
||||
if isinstance(out, str):
|
||||
msg = json.loads(out)
|
||||
if msg["type"] == "executing" and msg["data"]["node"] is None and msg["data"]["prompt_id"] == prompt_id:
|
||||
break
|
||||
|
||||
print(f"成功为 {slug} 生成符合设计约束的高质感封面底图")
|
||||
ws.settimeout(120)
|
||||
try:
|
||||
prompt_id = queue_prompt(workflow)["prompt_id"]
|
||||
while True:
|
||||
out = ws.recv()
|
||||
if isinstance(out, str):
|
||||
msg = json.loads(out)
|
||||
if (msg["type"] == "executing"
|
||||
and msg["data"]["node"] is None
|
||||
and msg["data"].get("prompt_id") == prompt_id):
|
||||
return prompt_id
|
||||
except websocket.WebSocketTimeoutException:
|
||||
# 漏收了完成帧,以 HTTP history 端点为准
|
||||
if prompt_in_history(prompt_id):
|
||||
return prompt_id
|
||||
raise
|
||||
finally:
|
||||
ws.close() # 89 张图 × 1 个套接字,不关闭就是静默句柄泄漏
|
||||
|
||||
def prompt_in_history(prompt_id):
|
||||
with urllib.request.urlopen(f"http://{COMFY_HOST}/history/{prompt_id}", timeout=10) as r:
|
||||
return prompt_id in json.loads(r.read())
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -153,6 +166,8 @@ def generate_cover(slug, topic_metaphor):
|
||||
|
||||
1. **45% 留白目标与 35% 审计红线的容差缓冲**:虽然 Prompt 明确要求模型在顶部预留 45% 的负空间,但扩散模型在构图交界处往往存在渐变溢出。因此,自动化审计将核心红线设定在**顶部 35%** 的绝对标题选区内,给予模型 10% 的过渡缓冲带。
|
||||
2. **平均亮度与拉普拉斯边缘方差双重门禁**:单纯检查平均亮度存在严重盲区——暗色背景上的高对比度青色线框或乱码文字同样具备极低的平均亮度,却会彻底摧毁大标题的可读性。为了避免引入 OpenCV 等重型依赖,审计脚本直接利用纯 Pillow 内置的 3×3 拉普拉斯卷积核(`ImageFilter.Kernel`)提取高频边缘图,并计算标准差(`stddev`)进行拦截。
|
||||
|
||||
这里值得点明一处实现细节,因为它正是该门禁在暗色底图上成立的原因:Pillow 的卷积在无符号 8-bit 空间执行,当 `offset=0` 时,所有**负向**拉普拉斯响应会被截断为 0,实际留下的是**半波整流(Half-Wave Rectified)**的拉普拉斯——只有正边缘峰值。在接近纯黑(`#09090b`)且只有稀疏亮色几何的底图上,这恰好给出所需的判别力:扩散输出的暗部本底微噪被整流掉,而亮色线框与伪文字边缘以完整幅度存活,把标准差推过阈值。若改用带符号的浮点核(`offset=128`),正负两瓣都会保留,阈值将极难标定。
|
||||
3. **规范化转码**:统一缩放至 Open Graph 标准的 `1200×630` 规格,输出为 85% 质量等级的现代 WebP 格式,严格确保单张体积控制在 100KB 以内。
|
||||
|
||||
<details class="tech-deep-dive">
|
||||
@@ -201,8 +216,9 @@ def audit_top_negative_space(img_path: str) -> tuple[bool, str]:
|
||||
## 交付结果
|
||||
|
||||
通过将视觉物料生产工程化为确定性 API 流水线:
|
||||
* **48 分钟内全自动交付 89 张高品质封面**:在单块本地消费级 RTX 3060 显卡上,完成了整个学习门户的全新视觉重构。
|
||||
* **25.9 分钟窗口内写出 216 个封面资产**:单批次产出 108 组 banner + OG 卡片(时间戳来自文件系统,可核验而非估算),全部在单块 12GB 显卡节点上完成。
|
||||
* **100% 保持品牌设计调性一致**:所有配图在暗色调对比、光影重心以及排版空间上呈现出高度收敛的专业秩序感。
|
||||
* **实现无人工值守**:一旦文章的元数据与核心隐喻就绪,流水线即可全自动批处理、校验合规性并直接回流 Git。
|
||||
* **这条管线本身就是证据**:本站每一张封面图——包括本文题图——都出自这条闭环,而不是出自设计软件。
|
||||
|
||||
生成式 AI 真正的威力在于嵌入自动化工程管线,而不是停留在手动逐张抽卡的低效泥潭。用确定性的设计规范去驯服模型的概率输出,规模化的稳定交付自然水到渠成。
|
||||
@@ -120,10 +120,16 @@ plugins-local/
|
||||
└── ...
|
||||
```
|
||||
|
||||
该目录下的 `.traefik.yml` 清单属于同一份契约:其中的 `import` 字段指向加载器将要清洗取末段
|
||||
的那个模块路径,`displayName` 与 `type` 又必须与动态配置中的引用一致。清单里的 import 路径
|
||||
与源码的 `package` 声明只要有一处不一致,产生的错误与本文的连字符陷阱完全相同——都是那句
|
||||
语焉不详的 `symbol not found`。所以这两处必须一起核验,而不是一个一个单独去试。
|
||||
|
||||
<details class="tech-deep-dive">
|
||||
<summary>🔍 查看修正后的动态 Traefik 中间件配置示例(YAML 摘录,29 行)</summary>
|
||||
<summary>🔍 查看修正后的动态 Traefik 中间件配置示例(YAML 摘录,30 行)</summary>
|
||||
|
||||
```yaml
|
||||
# 示例脱敏配置 —— 生产环境实际阈值不同
|
||||
http:
|
||||
middlewares:
|
||||
botfilter:
|
||||
@@ -134,7 +140,7 @@ http:
|
||||
requireAccept: true
|
||||
browserValidation: true
|
||||
temporaryBanMinutes: 15
|
||||
scoreThreshold: 100
|
||||
scoreThreshold: 120
|
||||
blockedPaths:
|
||||
- /.env
|
||||
- /.git
|
||||
|
||||
@@ -217,13 +217,17 @@ const inSection = {
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>© {new Date().getFullYear()} {SITE.author} · {SITE.handle}</span>
|
||||
<span>
|
||||
<span class="site-footer-links">
|
||||
<a href="/rss.xml">RSS</a>
|
||||
<a href={SITE.linkedin} target="_blank" rel="noopener">LinkedIn</a>
|
||||
<a href={SITE.github} target="_blank" rel="noopener">GitHub</a>
|
||||
<a href={SITE.gitea} target="_blank" rel="noopener">Gitea</a>
|
||||
<a href={SITE.website} target="_blank" rel="noopener">hoelee.com</a>
|
||||
</span>
|
||||
<span class="site-footer-note">
|
||||
Case studies reflect architectural patterns engineered by the author; client identifiers and
|
||||
production thresholds are sanitized for operational security.
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<Search lang={lang} />
|
||||
|
||||
+11
-1
@@ -1564,11 +1564,21 @@ a.category-card:hover .art img {
|
||||
gap: 0.75rem 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.site-footer span:last-child {
|
||||
.site-footer .site-footer-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
/* Standing provenance note: case studies describe architectures the author
|
||||
engineered, with client identifiers and production thresholds sanitised. */
|
||||
.site-footer-note {
|
||||
flex-basis: 100%;
|
||||
max-width: 62ch;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: var(--faint);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.site-footer a { color: var(--muted); text-decoration: none; }
|
||||
.site-footer a:hover { color: var(--brand); }
|
||||
|
||||
|
||||
Reference in new issue
Block a user