From 13ffc694006ec8851f4147e80c4f7ee3f326516b Mon Sep 17 00:00:00 2001 From: Thando Date: Wed, 24 Jun 2026 10:49:18 +0200 Subject: [PATCH] feat: human-readable acceptance criteria across CLI, dashboard, and prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pure formatter that splits run-on acceptance-criteria blobs into discrete, human-readable items so a person clicking through a story can read the intent rather than parsing a wall of text. - internal/criteria/format.go: Format/FormatMarkdown — sentence-splitting that guards abbreviations (e.g.) and identifier periods (WorldState.copy()) and strips -, *, bullet, and ordinal markers. Fully unit-tested. - nxd review : prints a Description block + bulleted Acceptance Criteria block (previously showed neither). - web snapshot: exposes acceptance_criteria_items (story_id -> []string); dashboard expands a detail row with description + criteria checklist when a story title is clicked (storyDetailRow/toggleStoryDetail). - Tech-Lead prompt + planner tool schema: require 3-6 intent-first criteria, one per line, so newly planned stories are readable at the source. TDD: tests written first for the formatter, review command, and snapshot. Full suite green (go test ./... -race), go vet clean, golangci-lint 0 issues. --- CLAUDE.md | 6 +- internal/agent/prompts.go | 15 ++- internal/cli/review_intent_test.go | 53 ++++++++++ internal/cli/review_story.go | 12 +++ internal/criteria/format.go | 153 ++++++++++++++++++++++++++++ internal/criteria/format_test.go | 125 +++++++++++++++++++++++ internal/engine/planner_tools.go | 2 +- internal/web/criteria_items_test.go | 51 ++++++++++ internal/web/data.go | 15 +++ internal/web/static/app.js | 72 ++++++++++++- internal/web/static/styles.css | 34 +++++++ 11 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 internal/cli/review_intent_test.go create mode 100644 internal/criteria/format.go create mode 100644 internal/criteria/format_test.go create mode 100644 internal/web/criteria_items_test.go diff --git a/CLAUDE.md b/CLAUDE.md index b833c46..3ac09a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ nxd resume → dispatcher → executor → agents (parallel per wave) | `internal/llm/semaphore.go` | Concurrency limiter wrapping `llm.Client` (default 1 for single-GPU Ollama) | | `internal/artifact/store.go` | Per-story artifact persistence (launch config, trace JSONL, diffs, QA/review results) | | `internal/scratchboard/` | Cross-agent knowledge sharing (JSONL-backed, per-requirement) | -| `internal/criteria/` | Declarative success criteria (file_exists, file_contains, test_passes, coverage_above, command_succeeds) | +| `internal/criteria/` | Declarative success criteria (file_exists, file_contains, test_passes, coverage_above, command_succeeds); `format.go` is a pure formatter (`Format`/`FormatMarkdown`) that splits run-on acceptance-criteria blobs into discrete human-readable items | | `internal/web/eventbus.go` | In-process pub/sub for instant WebSocket event push | | `internal/web/static/app.js` | Web dashboard frontend: DAG SVG visualization, agents, pipeline, stories, activity, review gates | | `internal/graph/export.go` | DAG export as JSON with nodes, edges, wave assignments | @@ -135,6 +135,10 @@ kill rm -f ~/.nxd/nxd.lock ~/.nxd/events.jsonl ~/.nxd/nxd.db ``` +## Current State (2026-06-24) + +- **Human-readable acceptance criteria**: `internal/criteria/format.go` (`Format`/`FormatMarkdown`, pure, fully unit-tested) segments a run-on acceptance-criteria blob into discrete items — sentence-splitting that guards abbreviations (`e.g.`) and identifier periods (`WorldState.copy()`) and strips `-`/`*`/`•`/`1.` markers. Surfaces: `nxd review ` prints a `Description:` block + a bulleted `Acceptance Criteria:` block; the web snapshot exposes `acceptance_criteria_items` (story_id → []string) and the dashboard expands a detail row (description + criteria checklist) when a story title is clicked (`storyDetailRow`/`toggleStoryDetail` in `app.js`). The Tech-Lead prompt (`prompts.go`) and planner tool schema (`planner_tools.go`) now require 3-6 intent-first criteria, one per line. + ## Current State (2026-06-02) - **Production-readiness pass complete (2026-06-02)**: req-daemon, req-logs, Tech-Lead conflict resolver, post-merge integration build, devdb dashboard column + Databases panel all wired. Coverage backfilled where pure-Go (cli +3pp, criteria +4pp, docker +3pp); architectural ceilings for live-PG / live-Docker recorded. diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go index 1a3f2d7..7c420d3 100644 --- a/internal/agent/prompts.go +++ b/internal/agent/prompts.go @@ -208,7 +208,20 @@ Guidelines: - Stories with score 1-3 should be simple enough for a junior developer - Stories with score 4-5 need intermediate-level work - Stories with score 6+ need senior-level architecture decisions -- Identify cross-story dependencies explicitly`, +- Identify cross-story dependencies explicitly + +Human-readable acceptance criteria (REQUIRED FORMAT): +The acceptance_criteria field is read by humans clicking through the story, not +only by the implementing agent. Write it so a reader understands the INTENT. +- Write 3-6 discrete criteria, ONE per line, each starting with '- '. +- Each line is a single, self-contained, verifiable statement in plain language. +- Lead with the observable outcome (what is true when done), not the command. + Put any exact build/test command at the END of its line in parentheses. + Good: '- The parser rejects unknown verbs without crashing (verified by go test ./parser).' + Avoid: 'go test ./parser green. parser handles verbs. no panic.' +- Never pack multiple checks into one run-on sentence separated by periods. +- A non-author reading only the criteria should be able to explain what the + story delivers.`, RoleSenior: `You are a Senior Developer on Team {team_name}. diff --git a/internal/cli/review_intent_test.go b/internal/cli/review_intent_test.go new file mode 100644 index 0000000..43d61f0 --- /dev/null +++ b/internal/cli/review_intent_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/tzone85/nexus-dispatch/internal/state" +) + +// TestRunReviewStory_ShowsIntent verifies that `nxd review ` — the path a +// human takes to "click through" a story — surfaces the description and the +// acceptance criteria as readable bullet items, so the intent is legible. +func TestRunReviewStory_ShowsIntent(t *testing.T) { + env := setupTestEnv(t) + seedTestReq(t, env, "req-rvi", "Review Intent", env.Dir) + + evt := state.NewEvent(state.EventStoryCreated, "tech-lead", "s-rvi1", map[string]any{ + "id": "s-rvi1", + "req_id": "req-rvi", + "title": "Domain model", + "complexity": 3, + "description": "Define the core domain entities for the world.", + "acceptance_criteria": "Failing tests written first. go test green. WorldState.copy() produces independent instance.", + }) + env.Events.Append(evt) + env.Proj.Project(evt) + + cmd := newReviewStoryCmd() + out, err := execCmd(t, cmd, env.Config, "s-rvi1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(out, "Description:") { + t.Errorf("expected a Description: label, got:\n%s", out) + } + if !strings.Contains(out, "Define the core domain entities for the world.") { + t.Errorf("expected the description text, got:\n%s", out) + } + if !strings.Contains(out, "Acceptance Criteria:") { + t.Errorf("expected an Acceptance Criteria: label, got:\n%s", out) + } + // Each criterion should appear as its own bullet, not a run-on blob. + for _, want := range []string{ + "- Failing tests written first.", + "- go test green.", + "- WorldState.copy() produces independent instance.", + } { + if !strings.Contains(out, want) { + t.Errorf("expected bullet %q in output, got:\n%s", want, out) + } + } +} diff --git a/internal/cli/review_story.go b/internal/cli/review_story.go index 687bc99..2a90c3a 100644 --- a/internal/cli/review_story.go +++ b/internal/cli/review_story.go @@ -4,8 +4,10 @@ import ( "fmt" "os" "os/exec" + "strings" "github.com/spf13/cobra" + "github.com/tzone85/nexus-dispatch/internal/criteria" ) func newReviewStoryCmd() *cobra.Command { @@ -37,6 +39,16 @@ func runReviewStory(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, "Story: %s\nID: %s\nStatus: %s\nComplexity: %d\nBranch: %s\n\n", story.Title, story.ID, story.Status, story.Complexity, story.Branch) + // Surface the story's intent so a human reviewing the change can read the + // description and acceptance criteria as a clean bulleted list rather than + // a run-on technical blob. + if desc := strings.TrimSpace(story.Description); desc != "" { + fmt.Fprintf(out, "Description:\n%s\n\n", desc) + } + if ac := criteria.FormatMarkdown(story.AcceptanceCriteria); ac != "" { + fmt.Fprintf(out, "Acceptance Criteria:\n%s\n\n", ac) + } + if story.Branch != "" { repoDir, _ := os.Getwd() baseBranch := s.Config.Merge.BaseBranch diff --git a/internal/criteria/format.go b/internal/criteria/format.go new file mode 100644 index 0000000..854a9b3 --- /dev/null +++ b/internal/criteria/format.go @@ -0,0 +1,153 @@ +package criteria + +// This file turns a story's acceptance-criteria string — often authored by the +// Tech-Lead LLM as a single run-on technical blob — into a list of discrete, +// human-readable items. The goal is that a person clicking through a story in +// the dashboard or CLI can read the criteria and understand the intent, rather +// than parsing a wall of text. +// +// All functions here are pure (no I/O), so the segmentation rules are pinned by +// unit tests rather than discovered at render time. + +import "strings" + +// abbreviations are lowercased tokens (sans trailing period) after which a +// "period + space" must NOT be treated as a sentence boundary. Without this, +// "e.g. encoding/json" would split mid-thought. +var abbreviations = map[string]bool{ + "e.g": true, "i.e": true, "etc": true, "vs": true, "cf": true, + "al": true, "approx": true, "no": true, "inc": true, "ltd": true, + "mr": true, "ms": true, "mrs": true, "dr": true, "fig": true, + "st": true, "jr": true, "sr": true, +} + +// Format splits a raw acceptance-criteria string into clean, readable items. +// +// - Multi-line input (or input with list markers) is split per line, with any +// leading bullet/number marker stripped. +// - Single-paragraph input is segmented into sentences, guarding against +// abbreviations (e.g.) and intra-identifier periods (WorldState.copy()). +// +// Empty or whitespace-only input returns nil. +func Format(raw string) []string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + + var lines []string + if strings.Contains(trimmed, "\n") { + lines = strings.Split(trimmed, "\n") + } else { + lines = splitSentences(trimmed) + } + + items := make([]string, 0, len(lines)) + for _, line := range lines { + item := strings.TrimSpace(stripMarker(line)) + if item != "" { + items = append(items, item) + } + } + if len(items) == 0 { + return nil + } + return items +} + +// FormatMarkdown renders the formatted items as a dash-bulleted markdown list, +// or "" when there are no items. +func FormatMarkdown(raw string) string { + items := Format(raw) + if len(items) == 0 { + return "" + } + var b strings.Builder + for i, item := range items { + if i > 0 { + b.WriteByte('\n') + } + b.WriteString("- ") + b.WriteString(item) + } + return b.String() +} + +// splitSentences segments a single paragraph on terminal punctuation followed by +// whitespace, keeping the punctuation with its sentence and skipping false +// boundaries (abbreviations, identifier periods with no trailing space). +func splitSentences(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + c := s[i] + if c != '.' && c != '!' && c != '?' { + continue + } + // A boundary requires whitespace immediately after the punctuation. + if i+1 >= len(s) || !isSpace(s[i+1]) { + continue + } + if c == '.' && isAbbreviation(s[start:i]) { + continue + } + out = append(out, strings.TrimSpace(s[start:i+1])) + // Advance past the run of whitespace to the next sentence. + j := i + 1 + for j < len(s) && isSpace(s[j]) { + j++ + } + start = j + i = j - 1 + } + if start < len(s) { + if tail := strings.TrimSpace(s[start:]); tail != "" { + out = append(out, tail) + } + } + return out +} + +// isAbbreviation reports whether the word ending the segment (the token +// immediately before the period) is a known abbreviation. +func isAbbreviation(segment string) bool { + end := len(segment) + wordStart := end + for wordStart > 0 && !isSpace(segment[wordStart-1]) { + wordStart-- + } + word := strings.ToLower(segment[wordStart:end]) + return abbreviations[word] +} + +// stripMarker removes a single leading list marker: "-", "*", "•", "–", or an +// ordinal like "1." / "2)". +func stripMarker(line string) string { + s := strings.TrimSpace(line) + if s == "" { + return s + } + switch s[0] { + case '-', '*': + return strings.TrimSpace(s[1:]) + } + // Unicode bullet/dash glyphs. + for _, glyph := range []string{"•", "–", "—", "‣", "·"} { + if strings.HasPrefix(s, glyph) { + return strings.TrimSpace(s[len(glyph):]) + } + } + // Ordinal marker: digits followed by '.' or ')'. + digits := 0 + for digits < len(s) && s[digits] >= '0' && s[digits] <= '9' { + digits++ + } + if digits > 0 && digits < len(s) && (s[digits] == '.' || s[digits] == ')') { + return strings.TrimSpace(s[digits+1:]) + } + return s +} + +func isSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\v' || b == '\f' +} diff --git a/internal/criteria/format_test.go b/internal/criteria/format_test.go new file mode 100644 index 0000000..fe37248 --- /dev/null +++ b/internal/criteria/format_test.go @@ -0,0 +1,125 @@ +package criteria + +import ( + "reflect" + "testing" +) + +func TestFormat(t *testing.T) { + tests := []struct { + name string + raw string + want []string + }{ + { + name: "empty string yields no items", + raw: "", + want: nil, + }, + { + name: "whitespace only yields no items", + raw: " \n\t ", + want: nil, + }, + { + name: "single sentence is one item", + raw: "go test green", + want: []string{"go test green"}, + }, + { + name: "run-on technical sentences split into items", + raw: "Failing tests written first. go test green. Domain classes have no dependency on encoding/json. WorldState.copy() produces independent instance.", + want: []string{ + "Failing tests written first.", + "go test green.", + "Domain classes have no dependency on encoding/json.", + "WorldState.copy() produces independent instance.", + }, + }, + { + name: "method-call periods do not split", + raw: "WorldState.copy() produces independent instance. go test green.", + want: []string{ + "WorldState.copy() produces independent instance.", + "go test green.", + }, + }, + { + name: "abbreviation e.g. does not split sentence", + raw: "Use a serializer e.g. encoding/json for JSON. All tests pass.", + want: []string{ + "Use a serializer e.g. encoding/json for JSON.", + "All tests pass.", + }, + }, + { + name: "version numbers do not split", + raw: "go.mod declares go 1.26, cobra, pgx. Build succeeds.", + want: []string{ + "go.mod declares go 1.26, cobra, pgx.", + "Build succeeds.", + }, + }, + { + name: "existing dash bullets are preserved and stripped", + raw: "- First criterion\n- Second criterion\n- Third criterion", + want: []string{"First criterion", "Second criterion", "Third criterion"}, + }, + { + name: "numbered list items are stripped of markers", + raw: "1. First criterion\n2. Second criterion\n3. Third criterion", + want: []string{"First criterion", "Second criterion", "Third criterion"}, + }, + { + name: "newline separated lines are split even without markers", + raw: "Tests written first\nAll tests pass\nNo lint errors", + want: []string{"Tests written first", "All tests pass", "No lint errors"}, + }, + { + name: "blank lines between bullets are dropped", + raw: "- First\n\n- Second\n", + want: []string{"First", "Second"}, + }, + { + name: "bullet glyph markers are stripped", + raw: "• First\n• Second", + want: []string{"First", "Second"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Format(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Format(%q)\n got = %#v\nwant = %#v", tt.raw, got, tt.want) + } + }) + } +} + +func TestFormatMarkdown(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "empty yields empty string", + raw: "", + want: "", + }, + { + name: "items rendered as dash bullets", + raw: "First criterion. Second criterion.", + want: "- First criterion.\n- Second criterion.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatMarkdown(tt.raw); got != tt.want { + t.Errorf("FormatMarkdown(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/internal/engine/planner_tools.go b/internal/engine/planner_tools.go index b8442cb..ce61b3c 100644 --- a/internal/engine/planner_tools.go +++ b/internal/engine/planner_tools.go @@ -56,7 +56,7 @@ func PlannerTools() []llm.ToolDefinition { }, "acceptance_criteria": { "type": "string", - "description": "How to verify the story is done" + "description": "3-6 discrete, human-readable criteria, one per line starting with '- '. Each line is a single plain-language verifiable outcome (intent first, exact command in parentheses at the end). A human reading only this must understand what the story delivers." }, "dependencies": { "type": "array", diff --git a/internal/web/criteria_items_test.go b/internal/web/criteria_items_test.go new file mode 100644 index 0000000..c1ce78f --- /dev/null +++ b/internal/web/criteria_items_test.go @@ -0,0 +1,51 @@ +package web + +import ( + "testing" + + "github.com/tzone85/nexus-dispatch/internal/state" +) + +// TestBuildSnapshot_AcceptanceCriteriaItems verifies the snapshot exposes each +// story's acceptance criteria pre-split into readable items, keyed by story ID, +// so the dashboard can render a clean list when a user clicks through a story. +func TestBuildSnapshot_AcceptanceCriteriaItems(t *testing.T) { + s := newTestServer(t) + reqID := seedRequirement(t, s) + + storyID := "story-ac1" + evt := state.NewEvent(state.EventStoryCreated, "system", storyID, map[string]any{ + "id": storyID, + "req_id": reqID, + "title": "Domain model", + "description": "Define the entities.", + "acceptance_criteria": "Failing tests written first. go test green. WorldState.copy() is independent.", + "complexity": 3, + }) + if err := s.eventStore.Append(evt); err != nil { + t.Fatalf("append: %v", err) + } + if err := s.projStore.Project(evt); err != nil { + t.Fatalf("project: %v", err) + } + + snap, err := s.BuildSnapshot() + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + + items := snap.AcceptanceCriteriaItems[storyID] + want := []string{ + "Failing tests written first.", + "go test green.", + "WorldState.copy() is independent.", + } + if len(items) != len(want) { + t.Fatalf("expected %d items, got %d: %#v", len(want), len(items), items) + } + for i := range want { + if items[i] != want[i] { + t.Errorf("item[%d] = %q, want %q", i, items[i], want[i]) + } + } +} diff --git a/internal/web/data.go b/internal/web/data.go index 85e8700..5c324e0 100644 --- a/internal/web/data.go +++ b/internal/web/data.go @@ -5,6 +5,7 @@ import ( "encoding/json" "path/filepath" + "github.com/tzone85/nexus-dispatch/internal/criteria" "github.com/tzone85/nexus-dispatch/internal/graph" "github.com/tzone85/nexus-dispatch/internal/improver" "github.com/tzone85/nexus-dispatch/internal/state" @@ -28,6 +29,10 @@ type StateSnapshot struct { DAG *graph.DAGExport `json:"dag,omitempty"` StoryDBs map[string]StoryDB `json:"story_dbs,omitempty"` DBSummary *DBSummary `json:"db_summary,omitempty"` + // AcceptanceCriteriaItems maps story_id -> the story's acceptance criteria + // split into discrete, human-readable items so the dashboard can render a + // clean checklist instead of a run-on technical blob. + AcceptanceCriteriaItems map[string][]string `json:"acceptance_criteria_items,omitempty"` } // StoryDB is the dashboard-friendly per-story devdb status. @@ -85,6 +90,16 @@ func (s *Server) BuildSnapshot() (StateSnapshot, error) { snap.Stories = append(snap.Stories, stories...) } + // Pre-split each story's acceptance criteria into readable items for the UI. + for _, story := range snap.Stories { + if items := criteria.Format(story.AcceptanceCriteria); len(items) > 0 { + if snap.AcceptanceCriteriaItems == nil { + snap.AcceptanceCriteriaItems = make(map[string][]string, len(snap.Stories)) + } + snap.AcceptanceCriteriaItems[story.ID] = items + } + } + // Pipeline counts for _, story := range snap.Stories { switch mapStatusToBucket(story.Status) { diff --git a/internal/web/static/app.js b/internal/web/static/app.js index e51a049..eb87c57 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -91,7 +91,11 @@ function renderState(data) { renderAgents(data.agents || [], data.agent_traces || []); renderPipeline(data.pipeline || {}); renderDAG(data.dag, data.stories || []); - renderStories(data.stories || [], data.story_dbs || {}); + renderStories( + data.stories || [], + data.story_dbs || {}, + data.acceptance_criteria_items || {}, + ); renderEvents(data.events || []); renderEscalations(data.escalations || []); renderMetrics(data.metrics); @@ -415,7 +419,11 @@ function renderPipeline(p) { document.getElementById("progress-text").textContent = pct + "% complete"; } -function renderStories(stories, storyDBs) { +function renderStories(stories, storyDBs, acItems) { + // acItems is a server-supplied map of story_id -> string[] (criteria already + // split into readable items by the Go criteria package). All values are + // routed through esc() before being placed in innerHTML. + const ac = acItems || {}; const sorted = [...stories].sort((a, b) => { const va = a[sortField] != null ? a[sortField] : ""; const vb = b[sortField] != null ? b[sortField] : ""; @@ -473,8 +481,12 @@ function renderStories(stories, storyDBs) { (s.escalation_tier || 0) + "" + "" + + '" + esc(s.title) + - "" + + "" + "" + dbCell + "" + @@ -498,12 +510,58 @@ function renderStories(stories, storyDBs) { "', target_tier:0}); })\">" + "⇄" + "" + - "" + "" + + storyDetailRow(storyId, s.description, ac[s.id]) ); }) .join(""); } +/** + * Build the hidden detail row for a story: plain-language description plus the + * acceptance criteria as a bulleted checklist, so a user can click the title + * and understand the story's intent. + */ +function storyDetailRow(storyId, description, items) { + const desc = (description || "").trim(); + const list = Array.isArray(items) ? items : []; + if (!desc && !list.length) { + return ( + 'No description or acceptance criteria recorded.' + ); + } + let inner = ""; + if (desc) { + inner += + '
Description

' + + esc(desc) + + "

"; + } + if (list.length) { + inner += + '
Acceptance Criteria
    ' + + list.map((i) => "
  • " + esc(i) + "
  • ").join("") + + "
"; + } + return ( + '' + + inner + + "" + ); +} + +/** Toggle the visibility of a story's detail row. */ +function toggleStoryDetail(storyId) { + const row = document.getElementById("detail-" + storyId); + if (row) { + row.hidden = !row.hidden; + } +} + function eventClass(type) { if (!type) return "event-default"; if (type.startsWith("REQ")) return "event-req"; @@ -1001,7 +1059,11 @@ document.querySelectorAll("#stories-table th[data-sort]").forEach((th) => { sortDir = 1; } if (currentState) - renderStories(currentState.stories || [], currentState.story_dbs || {}); + renderStories( + currentState.stories || [], + currentState.story_dbs || {}, + currentState.acceptance_criteria_items || {}, + ); }); }); diff --git a/internal/web/static/styles.css b/internal/web/static/styles.css index b8c76ca..239be55 100644 --- a/internal/web/static/styles.css +++ b/internal/web/static/styles.css @@ -100,6 +100,40 @@ tbody tr:nth-child(even) { color: #888888; } +/* ── Story detail (description + acceptance criteria) ──────────────── */ +.story-title-toggle { + cursor: pointer; + border-bottom: 1px dotted #5a6da0; +} +.story-title-toggle:hover { + color: #9db4ff; +} +tr.story-detail > td { + background: #161d33; + padding: 12px 16px; + border-left: 3px solid #5a6da0; +} +.detail-desc, +.detail-ac { + margin-bottom: 10px; +} +.detail-desc strong, +.detail-ac strong { + display: block; + color: #9db4ff; + margin-bottom: 4px; + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.detail-ac ul { + margin: 0; + padding-left: 20px; +} +.detail-ac li { + margin: 3px 0; +} + /* ── Status Badges ─────────────────────────────────────────────────── */ .status-active, .status-working,