address PR #103 review (CodeRabbit): OnUsage, multi-cancel flush, unsafe-toggle guard, hitbox clamp

loop.go: keep OnUsage on the reactive retry (drop only OnText) so token telemetry counts the retry. tui: flushRunID -> flushRunIDs set so a second cancel before the first goroutine returns doesn't lose the first run's checkpoint flush; shift+tab now toggles Auto<->Ask only (Unsafe is no longer reachable by a casual keypress since it disables permission prompts). zenline: PermLayout returns inactive geometry when the button row is clipped at small heights so clicks can't resolve to allow/deny on non-button rows. providerio_test: check pw.Close (errcheck). Tests added; build/vet/-race/full-suite green.

Refs #102

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
KRATOSandClaude Opus 4.8 committed 2026-06-07 08:28:30 +05:30
1 parent 4bc58e143e
commit 18108b8b09
10 files changed
+543 -35

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
# Deep audit — `zero-zenline` (PR #101 branch `zenline-runtime-work`)
**Date:** 2026-06-07
**Method:** 12 parallel finders (10 subsystems + cross-cutting *wiring* and *concurrency*) read ~35K LOC of non-test Go in full; every candidate finding was then handed to an independent **adversarial verifier** that re-read the actual code to confirm or refute it. **49 raised → 43 confirmed, 6 refuted.** The two most surprising HIGH findings (H7/H8) were additionally spot-checked against source + git history by hand.
**Confirmed by severity:** 🔴 9 high · 🟠 ~11 medium · 🟡 ~19 low (43 incl. 2 cross-finder duplicates).
> Recurring root theme: **the argument-tolerance / alias layer outran its consumers.** Several tools now accept aliased keys (`file`/`file_path`, `diff`, `cmd`/`script`/`shell`, …), but downstream consumers (checkpoint capture, the destructive-command gate) still read only the canonical key — see H1 and H4.
---
## 🔴 HIGH (9)
### H1 · Checkpoints silently skipped for aliased writes
`internal/tools/mutation_targets.go:10-28`
`MutationTargets` reads only the canonical `path`/`patch` keys via `stringArg`, but `write_file`/`edit_file` resolve `path` via `aliasedStringArg([]string{"path","file","file_path","filename"})` and `apply_patch` via `aliasedStringArg([]string{"patch","diff"})`. A model that writes via `{"file": …}` gets **no checkpoint captured → `/rewind` cannot undo that write.**
**Fix:** resolve path/patch in `MutationTargets` via the same alias key lists the tools use.
### H2 · `rewind` restores wrong bytes when the closest checkpoint is "Skipped"
`internal/sessions/rewind.go:58-82`
Checkpoints apply newest→oldest with no per-path short-circuit. If the closest-to-target state for a path is `Skipped` (oversize/unreadable) while a newer checkpoint carries a blob, the **older blob is written and the run is reported as restored** — the file is left in the wrong state.
**Fix:** handle only the closest-to-target (oldest) entry per path (`if restored[f.Path] { continue }`).
### H3 · `rewind` path guard is lexical-only — in-workspace symlink escapes
`internal/sessions/rewind.go:89-100`
`resolveWithinWorkspace` joins+cleans and rejects `..`, but never resolves symlinks (unlike `tools.resolveWorkspaceTargetPath`, which calls `filepath.EvalSymlinks`). A checkpoint path through an in-workspace symlink pointing outside the workspace passes the guard and **writes outside the workspace** on restore.
**Fix:** `EvalSymlinks` the deepest existing ancestor, re-join missing segments, verify it stays under `EvalSymlinks(root)`.
### H4 · Destructive-command gate ignores `cmd`/`script`/`shell` aliases
`internal/sandbox/risk.go:65`
`Classify` extracts the shell command solely via `argString(request.Args, "command")`, but the `bash` tool reads the command from any of `command`/`cmd`/`script`/`shell` via `aliasedStringArg` (`bash.go:55`). **A model invoking bash via an alias bypasses the `rm -rf` / curl-pipe destructive deny gate entirely.**
**Fix:** in `Classify`, resolve the command across the same alias set (`firstNonEmpty` over `command`/`cmd`/`script`/`shell`).
### H5 · Cancelling a run discards its session events + orphans checkpoint blobs
`internal/tui/model.go:816-832, 384-387, 1016-1032`
The agent goroutine accumulates all session events (incl. `EventSessionCheckpoint` payloads captured before each mutating tool) into a goroutine-local slice. On cancel, `activeRunID` is zeroed and those events are dropped, so checkpoint **blobs are written to disk but never referenced → `/rewind` broken for cancelled runs, plus orphaned blobs.**
**Fix:** flush in-flight `sessionEvents` before clearing `activeRunID` (keep handling the recently-cancelled runID, or append checkpoint/tool events as they occur).
### H6 · MCP schema conversion drops nested array/object shape
`internal/mcp/schema.go:56-94`
`propertyToMCP`/`propertyFromMCP` copy only `Type/Description/Enum/Default/Minimum/Maximum` — they drop `Items` (array element shape), `Properties`, and `Required` in both directions. **Tools with array/object params (`ask_user`, `update_plan`) lose their nested schema over MCP**, so peers see malformed parameters.
**Fix:** recursively map `Items`/`Properties`/`Required` to/from the MCP `items`/`properties`/`required` keys.
### H7 · Interactive permission-mode cycling is advertised but never wired
`internal/tui/model.go` (no `KeyShiftTab` case); labels at `view.go:72,124`
The status bar says "shift+tab to cycle" and renders Auto/Ask/Unsafe labels, but **no key handler ever mutates `m.permissionMode`** — it is set once at construction and frozen.
**Fix:** add a `case tea.KeyShiftTab:` that cycles Auto→Ask→Unsafe.
### H8 · TUI is hardcoded to Auto, which hides all write/shell tools
`internal/cli/app.go:306` + `internal/agent/loop.go:888-896` (`ToolAdvertised`) → `toolDefinitions`/`ToolVisible`
`runTUI` hardcodes `PermissionModeAuto`. `ToolAdvertised` in Auto returns true **only for `PermissionAllow` tools**, so `write_file`/`edit_file`/`bash`/`apply_patch` (all `promptSafety` → `PermissionPrompt`) are **never advertised to the model**. With H7 there is no way to switch out of Auto. Statically, the interactive TUI cannot write files or run shell. Git shows Auto has been set since PR #54.
> ⚠️ **VERIFY LIVE — contradiction.** This conflicts with earlier observed behavior where `write_file` *was* called in the live TUI. I confirmed the code path and git history but could not reconcile it without a TTY (no tmux in the audit env). **Deciding test:** restart the TUI and ask it to write a file — if `write_file` is never attempted, H7/H8 are real and serious; if it writes, this reading is too narrow.
**Fix (if real):** wire shift+tab (H7); in Ask mode prompt tools become advertised and the permission flow fires.
### H9 · MCP stdio client blocks forever on a hung server
`internal/mcp/client.go:181-224` (+ `protocol.go:42-78`)
`request` checks `ctx` once, then takes `client.mu` and does a blocking `bufio.ReadString`/`io.ReadFull` on the server's stdout with **no context awareness**. A stdio MCP server that accepts a request but never replies hangs the agent indefinitely, and the held `client.mu` serializes all other MCP calls behind it.
**Fix:** run `reader.read()` in a goroutine feeding a buffered channel; `select` on `ctx.Done()` (the pattern the OpenAI SSE watchdog already uses).
---
## 🟠 MEDIUM (~11)
- **M1 · ask_user/task results bypass secret-scrubbing** — `loop.go:526-531, 453-459`. Both are intercepted *before* the registry boundary (the documented single scrub point), so a secret in a user answer or sub-agent `FinalAnswer` enters the transcript unredacted with `Redacted=false`. Same trust boundary as the rest of the conversation (hence medium, not high), but it breaks a documented invariant. *Fix:* run both outputs through `redaction.RedactString` and set `Redacted`.
- **M2 · Reactive compaction re-streams text to the user** — `loop.go:115-142`. On a mid-stream context-limit error the retry reuses `OnText`, so partial text already shown is streamed a second time (garbled output). The summary path already omits `OnText` for this reason; the retry path doesn't. *Fix:* drop `OnText`/`OnUsage` on the retry collect.
- **M3 · Data race on `updatePlanTool.currentPlan`** — `update_plan.go:60-75`. Agent goroutine writes it in `Run` while `/plan` reads it via `CurrentPlan()` on the UI goroutine. *Fix:* guard with a mutex.
- **M4 · Anthropic & Gemini have no idle-timeout watchdog** — `anthropic/provider.go:130`, `gemini/provider.go:116`. Only OpenAI got the stall guard; a stalled-but-open Anthropic/Gemini upstream hangs the agent forever. *Fix:* apply the same reader-goroutine + idle-timer watchdog.
- **M5 · No cross-process session lock** — `sessions/store.go:425-437`. The lock is in-memory per `Store`; a CLI `zero sessions rewind` racing the TUI on the same session is not serialized → possible metadata corruption. *Fix:* per-session flock.
- **M6 · `rm -rf` with quoted/braced `$HOME` bypasses the destructive pattern** — `sandbox/risk.go:13`. `rm -rf "$HOME"` and `${HOME}` evade the regex.
- **M7 · Interactive-command detector skip-list incomplete** — `sandbox/safe_command.go:186`. `nice vim`, `timeout … vim`, `sh -c '…'`, `sudo <flag> …` defeat detection. *Fix:* extend the wrapper skip-list, skip leading option tokens, recurse into `sh -c`.
- **M8 · Glamour renderer cache `mdCache` is unbounded** — `zenline/markdown.go:33-39`. The *output* cache is bounded but the *renderer* cache (the expensive goldmark+chroma objects) is not — a resize drag grows it per width. *Fix:* bound+reset `mdCache` too.
- **M9 · Markdown lines not clipped to frame width** — `zenline/render.go:645-663`. Glamour doesn't hard-break unbreakable tokens, so a long URL/code line exceeds the wrap width and breaks the fixed-height layout. *Fix:* clip each line to the frame budget.
- **M10 · `PermLayout` hit-test geometry diverges from the rendered modal** — `zenline/render.go:348-369`. No width/height clamp or overlay-height subtraction, unlike `RenderChat` — mouse clicks can miss the buttons after resize.
- **M11 · Zenline skin shows a misleading "working…" spinner during ask_user** — `tui/zenline_view.go:88-107`. `ChatData` has no ask_user field, so the zenline skin shows no question/options while blocked (the default skin does). *Fix:* add an `AskUser` field to `ChatData` and render it; suppress Working/Thinking when a prompt is pending.
---
## 🟡 LOW (~19) — grouped
**Sandbox heuristics:** piped-installer match requires a literal `"| sh"` space — `|sh`, `|zsh`, `curl|bash` slip by (`risk.go:74`); `chmod -Rf 777`, octal `0777`, `chmod 777 -R`, `rm -rf -- /` evade `matchesDestructive` (`risk.go:13`).
**Streaming / runtime:** collected tool calls can emit out of model order when one ends mid-stream and another flushes at termination (`helpers.go:63-66`); duplicate/empty `ToolCallID`s collapse into one call, silently dropping calls (`helpers.go:36`); `DroppedToolCalls` ignored when a turn also has a valid call (`loop.go:159`); Gemini `emitDone` value receiver makes `state.done=true` a dead store (`gemini/provider.go:200`); Anthropic drops a nameless `tool_use` block without emitting `StreamEventToolCallDropped` (`anthropic/provider.go:165`).
**Sessions:** `readBlob` doesn't re-verify sha256 on read → silent corruption restores bad bytes (`checkpoint.go:147`); `RestoreToSequence` double-counts `FilesRestored/Deleted` for multi-checkpoint paths (`rewind.go:63`); `writeMetadata` uses a fixed `.tmp` path → collides across processes (`store.go:468`).
**Providers:** OpenAI duplicates `providerio` error/redact helpers and has drifted — 503/529 not classified as rate-limit (`openai/provider.go:305`). *Fix:* delete the inline copies, call `providerio.ClassifiedError`/`Redact`.
**CLI wiring:** `--mode`-supplied tool filters skip `validateExecToolFilters` and `--list-tools` because `applyExecMode` runs too late (`exec.go:111-139`); a mode-selected model's deprecation notice is discarded (`exec.go:455`); `zenline` command is dispatched but omitted from `zero --help` (`app.go:371`).
**Plugins / skills:** plugin manifest `tools/hooks/prompts/skills` are fully parsed but **never wired into the runtime** — discovery-only (`plugins.go:326`); plugin-declared skill directories are not merged into the `skill` tool's lookup path (`skills.go:56`).
**Dead code / edges:** `executeTask` no-provider fallback is unreachable dead code (`loop.go:393`); unused `stringSliceArg` (`ask_user.go:184`); optional path args reject an explicit `""` instead of falling back to default (`grep.go:55`); reactive-compaction sets `reactiveAttempted` even on a no-op, disabling later recovery (`compaction.go:281`); MCP `Serve`/connect-`initialize` ignore ctx during blocking reads (`mcp/server.go:41`, `client.go:91`).
---
## ✅ Refuted (6) — checked, not real
1. Repeated-failure stop "orphans `tool_calls`" — mechanics accurate but the impact is unreachable in this codebase.
2. task-scrubbing as a separate high — same issue as M1, same trust boundary (medium).
3. `pruneOrphanBlobs` unlocked delete — the only window is the TUI batched checkpoint, which is guarded against concurrent rewind.
4. `resultSummary` bodyMax "ignored" for diffs — intended design, not a bug.
5. `--profile` "dead flag" — actually used and test-backed (`exec_parse.go`).
6. `connectStdio` pipe leak on Start failure — the stdlib closes descriptors on `Start` failure.
---
## Recommended fix order
1. **H4** (destructive-gate alias bypass) + **H1** (rewind data loss) — shared one-line root cause (alias-aware consumers), highest risk/effort ratio.
2. **H7/H8** — but **confirm live first** (screenshot contradiction); if real, it's a headline functional gap (TUI can't write).
3. **H3** (symlink escape), **H9/H6** (MCP hang + nested schema), **H2/H5** (rewind correctness + cancelled-run blobs).
4. Medium batch: M1 (scrubbing), M2 (double-stream), M3 (plan race), M4 (provider watchdogs).
---
*Generated by an automated multi-agent audit (12 finders → adversarial verifiers). Findings are against the `zenline-runtime-work` branch (PR #101). Each item cites file:line; verify before fixing.*
+9 -7
View File
@@ -143,13 +143,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryStreamErr
}
// Omit OnText/OnUsage on the reactive retry: when the original
// error surfaced MID-stream, partial text was already forwarded to
// the user. Re-streaming the retried response on top of it would
// duplicate output. Mirroring summarizeClosure, the recovery stays
// invisible — the retried text is still captured in collected.Text
// and becomes the turn's assistant message.
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{})
// Omit OnText on the reactive retry: when the original error
// surfaced MID-stream, partial text was already forwarded to the
// user. Re-streaming the retried response on top of it would
// duplicate output. OnUsage IS kept so token telemetry/budgeting
// still counts the successful retry. The retried text is captured
// in collected.Text and becomes the turn's assistant message.
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
OnUsage: options.OnUsage,
})
}
}
if collected.Error != "" {
@@ -13,7 +13,7 @@ import (
// the idle timeout, cancels the request context, and returns ErrStreamIdle.
func TestScanSSEDataWithContextAbortsOnIdle(t *testing.T) {
pr, pw := io.Pipe()
defer pw.Close()
defer func() { _ = pw.Close() }()
// Send one event, then never send anything else and never close.
go func() {
@@ -52,7 +52,7 @@ func TestScanSSEDataWithContextAbortsOnIdle(t *testing.T) {
// ctx cancellation must unblock a hung read and surface ctx.Err().
func TestScanSSEDataWithContextHonorsContextCancel(t *testing.T) {
pr, pw := io.Pipe()
defer pw.Close()
defer func() { _ = pw.Close() }()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
+22 -16
View File
@@ -56,15 +56,17 @@ type model struct {
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunID is the id of a run that was cancelled while still in flight. Its
// agent goroutine keeps running to completion and returns its accumulated
// sessionEvents (including EventSessionCheckpoint payloads captured before each
// mutating tool) in a final agentResponseMsg. activeRunID is already zeroed by
// then, so without this the message would be dropped and the checkpoint blobs
// already written to disk would be orphaned (breaking /rewind). The
// agentResponseMsg handler persists this run's session events (only) so the
// checkpoints stay referenced.
flushRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight. Each
// cancelled agent goroutine keeps running to completion and returns its
// accumulated sessionEvents (including EventSessionCheckpoint payloads captured
// before each mutating tool) in a final agentResponseMsg. activeRunID is
// already zeroed by then, so without this the message would be dropped and the
// checkpoint blobs already written to disk would be orphaned (breaking
// /rewind). It is a SET (not a single id) so a second cancel before the first
// goroutine returns doesn't overwrite/lose the first run's pending flush. The
// agentResponseMsg handler persists each such run's session events (only) so
// the checkpoints stay referenced, then removes the id.
flushRunIDs map[int]struct{}
pendingPermission *pendingPermissionPrompt
pendingAskUser *pendingAskUserPrompt
width int
@@ -277,10 +279,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.handleSubmit()
case tea.KeyShiftTab:
// shift+tab cycles the permission mode (Auto→Ask→Unsafe→Auto), but
// only when nothing modal is up: a permission prompt, ask_user
// questionnaire, or open picker all take precedence and let the key
// fall through to their own handlers below.
// shift+tab toggles the permission mode between Auto and Ask (Unsafe
// is intentionally not reachable by a casual keypress — see
// nextPermissionMode), but only when nothing modal is up: a permission
// prompt, ask_user questionnaire, or open picker all take precedence
// and let the key fall through to their own handlers below.
if m.pendingPermission == nil && m.pendingAskUser == nil && m.picker == nil {
m.permissionMode = nextPermissionMode(m.permissionMode)
return m, nil
@@ -408,8 +411,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// works; the cancel path already wrote the "Run cancelled." marker, so
// skip transcript rows, the trailing cancellation error, and any pending
// state changes.
if msg.runID == m.flushRunID && m.flushRunID != 0 {
m.flushRunID = 0
if _, flushing := m.flushRunIDs[msg.runID]; flushing {
delete(m.flushRunIDs, msg.runID)
m, _ = m.appendSessionEvents(flushableSessionEvents(msg.sessionEvents))
}
return m, nil
@@ -851,7 +854,10 @@ func (m *model) cancelRun() {
// checkpoint blobs it captured before each mutating tool are orphaned on disk
// and /rewind can't reference them.
if m.pending && m.activeRunID != 0 {
m.flushRunID = m.activeRunID
if m.flushRunIDs == nil {
m.flushRunIDs = make(map[int]struct{})
}
m.flushRunIDs[m.activeRunID] = struct{}{}
}
if m.pending && m.activeSession.SessionID != "" {
if next, err := (*m).appendSessionEvent(sessions.EventError, map[string]any{
+5 -1
View File
@@ -1068,9 +1068,10 @@ func TestShiftTabCyclesPermissionMode(t *testing.T) {
m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto})
m.width = 96
// shift+tab toggles Auto<->Ask only; Unsafe is intentionally NOT reachable by
// a casual keypress (it disables permission prompts).
for _, want := range []agent.PermissionMode{
agent.PermissionModeAsk,
agent.PermissionModeUnsafe,
agent.PermissionModeAuto,
} {
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
@@ -1081,6 +1082,9 @@ func TestShiftTabCyclesPermissionMode(t *testing.T) {
if m.permissionMode != want {
t.Fatalf("expected permission mode %q after shift+tab, got %q", want, m.permissionMode)
}
if m.permissionMode == agent.PermissionModeUnsafe {
t.Fatalf("shift+tab must never land on Unsafe")
}
}
// The rendered status label tracks the cycled mode.
+3 -3
View File
@@ -753,7 +753,7 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) {
if next.pending {
t.Fatal("expected Esc to clear pending state")
}
if next.flushRunID == 0 {
if len(next.flushRunIDs) == 0 {
t.Fatal("expected cancelled run to be flagged for session-event flush")
}
@@ -763,8 +763,8 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) {
finalMsg := receiveFinalMessage(t, finalCh)
updated, _ = next.Update(finalMsg)
next = updated.(model)
if next.flushRunID != 0 {
t.Fatalf("expected flush flag to clear after draining cancelled run, got %d", next.flushRunID)
if len(next.flushRunIDs) != 0 {
t.Fatalf("expected flush set to clear after draining cancelled run, got %v", next.flushRunIDs)
}
events := readOnlySessionEvents(t, store)
+7 -6
View File
@@ -72,17 +72,18 @@ func (m model) modeSegment() string {
return style.Render("⏵⏵ "+label) + zeroTheme.muted.Render(" · shift+tab to cycle")
}
// nextPermissionMode advances the permission mode in the order the status bar
// advertises: Auto → Ask → Unsafe → Auto. Any unrecognized value resets to Auto.
// nextPermissionMode toggles between the two prompt-respecting modes:
// Auto ⇄ Ask. Unsafe (which disables permission prompts entirely) is
// deliberately NOT reachable by a casual keypress — a single shift+tab landing
// on it would let prompt-required tools run with no decision. Unsafe stays an
// explicit opt-in (the launch/--skip-permissions-unsafe path), not a UI toggle.
// Unsafe is folded back to Ask so the toggle always lands somewhere safe.
func nextPermissionMode(mode agent.PermissionMode) agent.PermissionMode {
switch mode {
case agent.PermissionModeAuto:
return agent.PermissionModeAsk
case agent.PermissionModeAsk:
return agent.PermissionModeUnsafe
case agent.PermissionModeUnsafe:
return agent.PermissionModeAuto
default:
// Ask (and anything else, incl. an externally-set Unsafe) → Auto.
return agent.PermissionModeAuto
}
}
+6
View File
@@ -387,6 +387,12 @@ func PermLayout(width, height int) PermGeometry {
if top < 0 {
top = 0
}
// If the body is too short to actually render the button row, the modal is
// clipped above the buttons — disable the hitboxes so a click can't resolve
// to allow/deny on a row where no button is drawn.
if top+permBtnRow >= bodyH {
return PermGeometry{Active: false}
}
bx := (width - bw) / 2
if bx < 0 {
bx = 0
+13
View File
@@ -279,3 +279,16 @@ func stripANSI(s string) string {
}
return b.String()
}
func TestPermLayoutDisablesHitboxesWhenButtonsClipped(t *testing.T) {
// At a clamped/too-short height the button row can't render; hitboxes must be
// inactive so a click can't resolve to allow/deny on a non-button row.
g := PermLayout(80, 8) // height floors to 8 -> bodyH=5 < top+permBtnRow
if g.Active {
t.Fatalf("expected inactive geometry when buttons are clipped, got %+v", g)
}
// A roomy height still yields active, positioned buttons.
if big := PermLayout(80, 30); !big.Active || big.Allow.W == 0 {
t.Fatalf("expected active geometry at full height, got %+v", big)
}
}
+362
View File
@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Northwind Goods — Curated everyday essentials</title>
<style>
/* ---------- Base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color: #1f2937;
background: #fafaf9;
line-height: 1.55;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
h1, h2, h3 { margin: 0 0 0.5em; line-height: 1.2; }
h1 { font-size: clamp(2rem, 4vw + 1rem, 3.5rem); font-weight: 700; }
h2 { font-size: clamp(1.5rem, 2vw + 1rem, 2.25rem); font-weight: 700; }
p { margin: 0 0 1em; }
.container { width: min(1100px, 90%); margin: 0 auto; }
/* ---------- Header ---------- */
header {
position: sticky; top: 0; z-index: 10;
background: rgba(255,255,255,0.85);
backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid #e7e5e4;
}
.nav {
display: flex; align-items: center; justify-content: space-between;
padding: 0.9rem 0;
}
.logo {
font-weight: 700; font-size: 1.15rem; letter-spacing: -0.01em;
display: flex; align-items: center; gap: 0.5rem;
}
.logo-mark {
width: 28px; height: 28px; border-radius: 8px;
background: linear-gradient(135deg, #f97316, #db2777);
display: inline-block;
}
nav ul { list-style: none; display: flex; gap: 1.5rem; margin: 0; padding: 0; }
nav a { font-size: 0.95rem; color: #44403c; }
nav a:hover { color: #db2777; }
.cart-btn {
border: 1px solid #e7e5e4; background: white;
padding: 0.45rem 0.9rem; border-radius: 999px;
font-size: 0.9rem; cursor: pointer; transition: all 0.2s;
}
.cart-btn:hover { background: #1f2937; color: white; border-color: #1f2937; }
/* ---------- Hero ---------- */
.hero {
padding: 5rem 0 4rem;
background:
radial-gradient(1200px 400px at 80% 0%, rgba(219,39,119,0.10), transparent),
radial-gradient(900px 400px at 0% 20%, rgba(249,115,22,0.10), transparent);
}
.hero-inner { display: grid; gap: 2.5rem; align-items: center; }
.hero .eyebrow {
display: inline-block; font-size: 0.8rem; font-weight: 600;
letter-spacing: 0.12em; text-transform: uppercase;
color: #db2777; margin-bottom: 1rem;
}
.hero p.lede { font-size: 1.1rem; color: #57534e; max-width: 38rem; }
.cta-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.25rem; }
.btn {
display: inline-block; padding: 0.75rem 1.25rem;
border-radius: 10px; font-weight: 600; font-size: 0.95rem;
cursor: pointer; transition: transform 0.1s ease, background 0.2s;
border: 1px solid transparent;
}
.btn-primary { background: #1f2937; color: white; }
.btn-primary:hover { background: #000; transform: translateY(-1px); }
.btn-ghost { background: white; color: #1f2937; border-color: #e7e5e4; }
.btn-ghost:hover { border-color: #1f2937; }
.hero-art {
aspect-ratio: 4 / 3; border-radius: 20px;
background:
linear-gradient(135deg, rgba(249,115,22,0.85), rgba(219,39,119,0.85)),
repeating-linear-gradient(45deg, rgba(255,255,255,0.06) 0 10px, transparent 10px 20px);
box-shadow: 0 30px 60px -20px rgba(31,41,55,0.35);
position: relative; overflow: hidden;
}
.hero-art::after {
content: "New Season";
position: absolute; bottom: 1.25rem; left: 1.25rem;
background: rgba(255,255,255,0.95); color: #1f2937;
padding: 0.4rem 0.8rem; border-radius: 999px;
font-size: 0.8rem; font-weight: 600;
}
@media (min-width: 800px) {
.hero-inner { grid-template-columns: 1.1fr 1fr; }
}
/* ---------- Section ---------- */
section { padding: 4rem 0; }
.section-head { text-align: center; margin-bottom: 2.5rem; }
.section-head p { color: #57534e; }
/* ---------- Products ---------- */
.grid {
display: grid; gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card {
background: white; border: 1px solid #e7e5e4;
border-radius: 16px; overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex; flex-direction: column;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 30px -15px rgba(31,41,55,0.15);
}
.thumb {
aspect-ratio: 4 / 3;
background: linear-gradient(135deg, #fde68a, #fca5a5);
}
.card:nth-child(2) .thumb { background: linear-gradient(135deg, #bae6fd, #a7f3d0); }
.card:nth-child(3) .thumb { background: linear-gradient(135deg, #ddd6fe, #fbcfe8); }
.card:nth-child(4) .thumb { background: linear-gradient(135deg, #fed7aa, #fecaca); }
.card-body { padding: 1rem 1.1rem 1.2rem; display: flex; flex-direction: column; gap: 0.4rem; }
.price-row {
display: flex; align-items: center; justify-content: space-between;
margin-top: 0.5rem;
}
.price { font-weight: 700; font-size: 1.05rem; }
.tag {
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.05em;
text-transform: uppercase; padding: 0.2rem 0.5rem; border-radius: 999px;
background: #f5f5f4; color: #57534e;
}
.add {
margin-top: 0.4rem; width: 100%;
background: #1f2937; color: white; border: none;
padding: 0.55rem 0.8rem; border-radius: 8px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.2s;
}
.add:hover { background: #db2777; }
/* ---------- About ---------- */
.about { background: #f5f5f4; }
.about-inner { display: grid; gap: 2rem; align-items: center; }
@media (min-width: 800px) {
.about-inner { grid-template-columns: 1fr 1.2fr; }
}
.about .stat {
display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 1.5rem;
}
.stat div { min-width: 120px; }
.stat strong { display: block; font-size: 1.6rem; color: #1f2937; }
.stat span { color: #57534e; font-size: 0.9rem; }
/* ---------- Newsletter ---------- */
.newsletter {
background: #1f2937; color: white; border-radius: 20px;
padding: 2.5rem 2rem; text-align: center;
background-image:
radial-gradient(600px 200px at 0% 0%, rgba(219,39,119,0.35), transparent),
radial-gradient(600px 200px at 100% 100%, rgba(249,115,22,0.35), transparent);
}
.newsletter form {
margin-top: 1.25rem;
display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap;
}
.newsletter input {
padding: 0.75rem 1rem; border-radius: 10px; border: none;
min-width: 260px; font-size: 0.95rem;
}
.newsletter .btn-primary { background: white; color: #1f2937; }
.newsletter .btn-primary:hover { background: #f5f5f4; }
/* ---------- Footer ---------- */
footer {
background: #fafaf9; border-top: 1px solid #e7e5e4;
padding: 2.5rem 0; color: #57534e; font-size: 0.9rem;
}
.foot {
display: flex; flex-wrap: wrap; gap: 1.5rem;
align-items: center; justify-content: space-between;
}
.foot .links { display: flex; gap: 1.25rem; }
.foot a:hover { color: #1f2937; }
</style>
</head>
<body>
<!-- ===== Header ===== -->
<header>
<div class="container nav">
<a href="#" class="logo">
<span class="logo-mark" aria-hidden="true"></span>
Northwind&nbsp;Goods
</a>
<nav aria-label="Primary">
<ul>
<li><a href="#shop">Shop</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
</nav>
<button class="cart-btn" aria-label="Open cart">🛒 Cart (0)</button>
</div>
</header>
<!-- ===== Hero ===== -->
<section class="hero">
<div class="container hero-inner">
<div>
<span class="eyebrow">New · Autumn 2025</span>
<h1>Thoughtful goods for everyday living.</h1>
<p class="lede">
We curate a small, well-made collection of home and lifestyle
essentials — sourced from independent makers, built to last.
</p>
<div class="cta-row">
<a href="#shop" class="btn btn-primary">Shop the collection</a>
<a href="#about" class="btn btn-ghost">Our story</a>
</div>
</div>
<div class="hero-art" role="img" aria-label="Autumn collection hero image"></div>
</div>
</section>
<!-- ===== Products ===== -->
<section id="shop">
<div class="container">
<div class="section-head">
<h2>Featured this week</h2>
<p>A few favourites from the workshop — refreshed every Monday.</p>
</div>
<div class="grid">
<article class="card">
<div class="thumb" aria-hidden="true"></div>
<div class="card-body">
<span class="tag">Home</span>
<h3>Stoneware Mug</h3>
<p>Hand-thrown, 12oz. Dishwasher safe.</p>
<div class="price-row">
<span class="price">$28</span>
</div>
<button class="add" type="button">Add to cart</button>
</div>
</article>
<article class="card">
<div class="thumb" aria-hidden="true"></div>
<div class="card-body">
<span class="tag">Kitchen</span>
<h3>Linen Tea Towel</h3>
<p>Soft, absorbent, and quick to dry.</p>
<div class="price-row">
<span class="price">$18</span>
</div>
<button class="add" type="button">Add to cart</button>
</div>
</article>
<article class="card">
<div class="thumb" aria-hidden="true"></div>
<div class="card-body">
<span class="tag">Outdoors</span>
<h3>Wool Throw</h3>
<p>Heavy-weave wool, perfect for cool nights.</p>
<div class="price-row">
<span class="price">$96</span>
</div>
<button class="add" type="button">Add to cart</button>
</div>
</article>
<article class="card">
<div class="thumb" aria-hidden="true"></div>
<div class="card-body">
<span class="tag">Office</span>
<h3>Walnut Notebook</h3>
<p>100gsm paper, lays flat on any surface.</p>
<div class="price-row">
<span class="price">$24</span>
</div>
<button class="add" type="button">Add to cart</button>
</div>
</article>
</div>
</div>
</section>
<!-- ===== About ===== -->
<section id="about" class="about">
<div class="container about-inner">
<div>
<h2>Small shop. Big care.</h2>
<p>
Northwind Goods started as a weekend hobby in 2019 and has grown
into a small studio of four. Every product in our store is
something we use ourselves — and we ship from a single,
low-waste warehouse in Portland, Oregon.
</p>
<div class="stat">
<div><strong>400+</strong><span>Products in rotation</span></div>
<div><strong>42</strong><span>Independent makers</span></div>
<div><strong>4.9★</strong><span>Average review</span></div>
</div>
</div>
<div class="hero-art" style="border-radius:20px;" role="img" aria-label="Inside the studio"></div>
</div>
</section>
<!-- ===== Contact / Newsletter ===== -->
<section id="contact">
<div class="container">
<div class="newsletter">
<h2>Stay in the loop</h2>
<p>New arrivals, restocks, and the occasional behind-the-scenes note — once a month, no spam.</p>
<form onsubmit="event.preventDefault(); this.querySelector('button').textContent='Subscribed ✓';">
<label class="sr-only" for="email" style="position:absolute;left:-9999px;">Email</label>
<input id="email" type="email" required placeholder="[email protected]" />
<button type="submit" class="btn btn-primary">Subscribe</button>
</form>
</div>
</div>
</section>
<!-- ===== Footer ===== -->
<footer>
<div class="container foot">
<span>© 2025 Northwind Goods · Made in Portland, OR</span>
<div class="links">
<a href="#">Shipping</a>
<a href="#">Returns</a>
<a href="#">Privacy</a>
<a href="#">Contact</a>
</div>
</div>
</footer>
<script>
// Tiny cart counter — purely UI demo
let count = 0;
const cartBtn = document.querySelector('.cart-btn');
document.querySelectorAll('.add').forEach(btn => {
btn.addEventListener('click', () => {
count += 1;
cartBtn.textContent = `🛒 Cart (${count})`;
btn.textContent = 'Added ✓';
setTimeout(() => (btn.textContent = 'Add to cart'), 1200);
});
});
</script>
</body>
</html>