OXYGENHUB

Name the kind of every number; show the issue -> proposal -> bookmark chain 38833ed78707

Issues and proposals share one number space per repository, so page
headers and the links #N becomes say which: "issue #1", "proposal #2".
A proposal shows the issues it closes (closes/fixes/resolves #N in its
description or its changes) in a strip under its title - each issue with
its state, then the proposal, then its target, marked landed once it is -
and in a Closes list in its sidebar. An issue shows the proposals that
close it by the same rule.

closes #52
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 16:23 UTC
Signature
✔ signed by Andy McCall
Hash
38833ed78707292c7e2db4f3250227e7f024f1ca6e96bfd7d1a372a4e47b927d
Depends on
101740454617 145306044896 18e381ea88c2 2bfe5ef66b0c 93be9ce4653e b64e2537d702 b6b7e4cf6b24 d23464c0ac60 de505df5cf8d e1ec9fbf538e
Depended on by
f2d2776b2a14 e97500b70f9a c8684d44d549 b5a0a69b3622 2e9188ca4f98 d7d182467382 554ccacfc123 8e96499f2705 df71c7892d27 df15b74774ca e9bfea8e26db a1b9869565aa
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
27 atoms, 1 tree operation, 10 files

Browse the tree at this change

added crates/hub/src/web/chain.rs +74 −0
1 +
//! The chain of work between issues and proposals (hub spec §8): the
2 +
//! issues a proposal closes (`closes #N` in its description or its
3 +
//! changes), and the proposals that close an issue.
4 +
5 +
use crate::db::{self, issues as idb, proposals as pdb};
6 +
use crate::render::refs;
7 +
use crate::Shared;
8 +
9 +
/// An issue or proposal as a link in the chain.
10 +
#[derive(Clone, Debug)]
11 +
pub struct Link {
12 +
    /// `issue` or `proposal`.
13 +
    pub kind: &'static str,
14 +
    pub number: i64,
15 +
    pub title: String,
16 +
    /// `open`, `closed`, `draft`, `landed`, …
17 +
    pub state: String,
18 +
    pub url: String,
19 +
}
20 +
21 +
/// The issues of this repository that `texts` close.
22 +
pub async fn issues_closed_by(app: &Shared, row: &db::repos::RepoRow, texts: &[&str]) -> Vec<Link> {
23 +
    let mut numbers: Vec<i64> = texts.iter().flat_map(|t| refs::closes(t)).collect();
24 +
    numbers.sort();
25 +
    numbers.dedup();
26 +
    let mut out = Vec::new();
27 +
    for n in numbers {
28 +
        if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await {
29 +
            out.push(Link { kind: "issue", number: n, title: i.title, state: i.state, url: format!("/{}/issue/{n}", row.slug()) });
30 +
        }
31 +
    }
32 +
    out
33 +
}
34 +
35 +
/// The proposals of this repository that close issue `n`: in their
36 +
/// description, or in the message of one of their changes.
37 +
pub async fn proposals_closing(app: &Shared, row: &db::repos::RepoRow, n: i64) -> Vec<Link> {
38 +
    let all = pdb::list(&app.db, row.id, &["draft", "open", "landed", "closed"]).await.unwrap_or_default();
39 +
    // The changes' messages, read once from the repository.
40 +
    let hashes: Vec<Vec<String>> = all.iter().map(|p| p.projected_changes()).collect();
41 +
    let messages: Vec<Vec<String>> = match crate::repos::RepoName::new(&row.owner, &row.name) {
42 +
        Some(name) => {
43 +
            let wanted = hashes.clone();
44 +
            app.repos
45 +
                .read(&name, move |r, _| {
46 +
                    Ok(wanted
47 +
                        .iter()
48 +
                        .map(|hs| {
49 +
                            hs.iter()
50 +
                                .filter_map(|h| oxy_core::hash::ChangeHash::from_hex(h))
51 +
                                .filter_map(|h| r.store().get_change(&h).ok().map(|c| c.message))
52 +
                                .collect()
53 +
                        })
54 +
                        .collect())
55 +
                })
56 +
                .await
57 +
                .unwrap_or_else(|_| vec![vec![]; hashes.len()])
58 +
        }
59 +
        None => vec![vec![]; hashes.len()],
60 +
    };
61 +
    all.into_iter()
62 +
        .zip(messages)
63 +
        .filter(|(p, msgs)| {
64 +
            refs::closes(&p.body).contains(&n) || refs::closes(&p.title).contains(&n) || msgs.iter().any(|m| refs::closes(m).contains(&n))
65 +
        })
66 +
        .map(|(p, _)| Link {
67 +
            kind: "proposal",
68 +
            number: p.number,
69 +
            url: format!("/{}/proposal/{}", row.slug(), p.number),
70 +
            title: p.title,
71 +
            state: p.state,
72 +
        })
73 +
        .collect()
74 +
}
modified crates/hub/src/render/refs.rs +8 −6
…
9 unchanged lines
10 10
}
11 11
12 12
impl Numbers<'_> {
13 -
    fn url_for(&self, n: i64) -> Option<String> {
13 +
    /// Where `#n` points, and what it is: issues and proposals share one
14 +
    /// number space, so the link says which.
15 +
    fn url_for(&self, n: i64) -> Option<(String, &'static str)> {
14 16
        if self.proposals.contains(&n) {
15 -
            Some(format!("{}/proposal/{n}", self.repo_url))
17 +
            Some((format!("{}/proposal/{n}", self.repo_url), "proposal"))
16 18
        } else if self.issues.contains(&n) {
17 -
            Some(format!("{}/issue/{n}", self.repo_url))
19 +
            Some((format!("{}/issue/{n}", self.repo_url), "issue"))
18 20
        } else {
19 21
            None
20 22
        }
…
41 unchanged lines
62 64
        if let Some(n) = c.name("n") {
63 65
            let num: i64 = n.as_str().parse().unwrap_or(0);
64 66
            return match numbers.url_for(num) {
65 -
                Some(url) => format!("{pre}[#{num}]({url})"),
67 +
                Some((url, kind)) => format!("{pre}[{kind} #{num}]({url})"),
66 68
                None => c[0].to_string(),
67 69
            };
68 70
        }
…
46 unchanged lines
115 117
    fn links_references_outside_code() {
116 118
        let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] };
117 119
        let out = link("see #3 and #7 and #9, `#3` too, by @andy; a/b#3 and other/repo#5; hash deadbeefcafe1234\n```\n#3 @x\n```\n", &n);
118 -
        assert!(out.contains("[#3](/a/b/issue/3)"), "{out}");
119 -
        assert!(out.contains("[#7](/a/b/proposal/7)"), "{out}");
120 +
        assert!(out.contains("[issue #3](/a/b/issue/3)"), "{out}");
121 +
        assert!(out.contains("[proposal #7](/a/b/proposal/7)"), "{out}");
120 122
        assert!(out.contains(" #9,"), "unknown numbers stay: {out}");
121 123
        assert!(out.contains("`#3`"), "code spans untouched: {out}");
122 124
        assert!(out.contains("[@andy](/andy)"), "{out}");
…
11 unchanged lines
modified crates/hub/src/web/issues.rs +4 −0
…
314 unchanged lines
315 315
    can_triage: bool,
316 316
    /// Projects this issue is on that the viewer may see, with the column.
317 317
    projects: Vec<(crate::db::projects::Project, String)>,
318 +
    /// Proposals whose description closes it.
319 +
    closed_by: Vec<super::chain::Link>,
318 320
    notice: Option<String>,
319 321
    error: Option<String>,
320 322
}
…
50 unchanged lines
371 373
    let can_triage = role >= Role::Triage;
372 374
    let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage;
373 375
    let projects = super::projects::placements_for(app, viewer, &row, i.number).await;
376 +
    let closed_by = super::chain::proposals_closing(app, &row, i.number).await;
374 377
    Ok(Page(IssuePage {
375 378
        shell,
376 379
        repo: RepoShell::new(app, &row, "issues").with_role(role),
377 380
        i,
381 +
        closed_by,
378 382
        body_html,
379 383
        timeline: timeline.into_iter().map(|(_, it)| it).collect(),
380 384
        comments,
…
625 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
4 unchanged lines
5 5
6 6
mod admin;
7 7
mod avatar;
8 +
pub mod chain;
8 9
pub mod edit;
9 10
pub mod filters;
10 11
pub mod flow;
…
590 unchanged lines
modified crates/hub/src/web/proposals.rs +5 −0
…
545 unchanged lines
546 546
    shell: Shell,
547 547
    repo: RepoShell,
548 548
    p: pdb::ProposalRow,
549 +
    /// Issues it closes, from its description and its changes.
550 +
    closes: Vec<super::chain::Link>,
549 551
    body_html: String,
550 552
    tab: String,
551 553
    changes: Vec<ChangeView>,
…
115 unchanged lines
667 669
        _ => p.source_bookmark.clone(),
668 670
    };
669 671
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
672 +
    let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect();
673 +
    let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await;
670 674
    Ok(Page(ProposalPage {
675 +
        closes,
671 676
        projects,
672 677
        shell,
673 678
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
…
500 unchanged lines
modified crates/hub/tests/api.rs +69 −0
…
1043 unchanged lines
1044 1044
    // What has landed on main is not rearranged.
1045 1045
    assert!(plan(&second, &base).await["error"].as_str().unwrap().contains("already on main"));
1046 1046
}
1047 +
1048 +
/// Issues and proposals share a number space, so every number says what it
1049 +
/// is, and the chain of work shows from both ends (hub spec §8).
1050 +
#[tokio::test(flavor = "multi_thread")]
1051 +
async fn proposals_and_issues_show_their_chain() {
1052 +
    let h = hub().await;
1053 +
    let app = &h.app;
1054 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1055 +
        .await
1056 +
        .cookie
1057 +
        .unwrap();
1058 +
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1059 +
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
1060 +
    let url = format!("{}/andy/demo", h.base);
1061 +
    let work = tempfile::tempdir().unwrap();
1062 +
    let w = work.path().to_path_buf();
1063 +
    tokio::task::spawn_blocking(move || {
1064 +
        let mut c = Repo::init(&w).unwrap();
1065 +
        c.config.set("remote.origin.url", &url).unwrap();
1066 +
        c.config.set("remote.origin.token", "site").unwrap();
1067 +
        c.save_config().unwrap();
1068 +
        write(&w, "a.txt", b"a\n");
1069 +
        c.commit("base", &[]).unwrap();
1070 +
        assert!(c.push("origin", false).unwrap()[0].ok);
1071 +
        c.bookmark_create("feature", None).unwrap();
1072 +
        c.bookmark_switch("feature", false).unwrap();
1073 +
        write(&w, "a.txt", b"a\nfixed\n");
1074 +
        c.commit("Fix the crash\n\nfixes #2", &[]).unwrap();
1075 +
        assert!(c.push_bookmark("origin", "feature", false).unwrap()[0].ok);
1076 +
    })
1077 +
    .await
1078 +
    .unwrap();
1079 +
    for title in ["Slow start", "Crash on save"] {
1080 +
        let (st, _) = api(app, "POST", "/api/v1/repos/andy/demo/issues", Some("site"), Some(&format!(r#"{{"title":"{title}"}}"#))).await;
1081 +
        assert!(st.is_success());
1082 +
    }
1083 +
    let (st, v) = api(
1084 +
        app,
1085 +
        "POST",
1086 +
        "/api/v1/repos/andy/demo/proposals",
1087 +
        Some("site"),
1088 +
        Some(r#"{"title":"Faster, safer","body":"Closes #1. See also #2.","target":"main","source":"feature"}"#),
1089 +
    )
1090 +
    .await;
1091 +
    assert!(st.is_success(), "{st} {v}");
1092 +
    assert_eq!(v["number"], 3, "one number space: issues 1 and 2, then proposal 3");
1093 +
1094 +
    // The proposal names its kind, links its references by kind, and shows
1095 +
    // the chain: the issues it closes (description and changes) → it → main.
1096 +
    let p = call(app, "GET", "/andy/demo/proposal/3", None, None, None, None).await.body;
1097 +
    assert!(p.contains(">proposal #3</small>"), "header");
1098 +
    assert!(p.contains(">issue #1</a>") && p.contains(">issue #2</a>"), "links in the description say what they point at");
1099 +
    let chain = &p[p.find("class=\"chain\"").expect("a chain")..];
1100 +
    let chain = &chain[..chain.find("</p>").unwrap()];
1101 +
    for want in ["issue #1</span> Slow start", "issue #2</span> Crash on save", "proposal #3", ">main<"] {
1102 +
        assert!(chain.contains(want), "chain lacks {want:?}: {chain}");
1103 +
    }
1104 +
    assert!(p.contains("<h3>Closes</h3>"));
1105 +
1106 +
    // The issue shows the proposal that closes it, and names its own kind.
1107 +
    let i = call(app, "GET", "/andy/demo/issue/1", None, None, None, None).await.body;
1108 +
    assert!(i.contains(">issue #1</small>"));
1109 +
    let chain = &i[i.find("class=\"chain\"").expect("a chain on the issue")..];
1110 +
    assert!(chain.contains("closed by") && chain.contains("proposal #3</span> Faster, safer"), "{chain}");
1111 +
    // Closed by the proposal's change, not its description: still shown.
1112 +
    let two = call(app, "GET", "/andy/demo/issue/2", None, None, None, None).await.body;
1113 +
    let chain = &two[two.find("class=\"chain\"").expect("a chain on issue 2")..];
1114 +
    assert!(chain.contains("proposal #3"), "{chain}");
1115 +
}
modified docs/spec.md +3 −1
…
373 unchanged lines
374 374
375 375
## 8. Issues
376 376
377 -
Per-repository numbered issues sharing the number space with proposals (as GitHub does), with title, Markdown body, author, assignees, labels (name, colour, description), a milestone (title, due date, description, open/closed), state (`open`, `closed` with reason `completed` or `not planned`), comments, lock, and pin. Issue templates come from `.oxyhub/ISSUE_TEMPLATE/*.md` in the default state.
377 +
Per-repository numbered issues sharing the number space with proposals (as GitHub does; so every number is shown with its kind, "issue #1" or "proposal #2", in page headers and in the links `#N` becomes), with title, Markdown body, author, assignees, labels (name, colour, description), a milestone (title, due date, description, open/closed), state (`open`, `closed` with reason `completed` or `not planned`), comments, lock, and pin. Issue templates come from `.oxyhub/ISSUE_TEMPLATE/*.md` in the default state.
378 +
379 +
**The chain of work.** A proposal shows the issues it closes (`closes`, `fixes` or `resolves #N` in its description or in any of its changes' messages) in a strip under its title: each issue with its state, then the proposal, then its target bookmark, marked landed once it is. A Closes list sits in its sidebar. An issue shows the proposals that close it, by the same rule, with their states. So the path from a report to the change that landed it can be followed from either end.
378 380
379 381
Relative links and images in issue, comment and proposal text resolve against the repository's default bookmark, and in release notes against the release's tag. Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark ("closed by change"). Any other `#12` in a pushed change's message, on any bookmark, puts "referenced by change <hash> on <bookmark>" on that issue's or proposal's timeline, the hash linking to the change; other repositories' `owner/name#12` are left alone.
380 382
…
254 unchanged lines
modified templates/issue.html +4 −2
1 1
{% extends "base.html" %}
2 2
{% import "repo_tabs.html" as m %}
3 -
{% block title %}#{{ i.number }} {{ i.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
3 +
{% block title %}Issue #{{ i.number }} {{ i.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %}
6 6
{% block content %}
7 -
<div class="head"><h1>{{ i.title }} <small class="px">#{{ i.number }}</small>
7 +
<div class="head"><h1>{{ i.title }} <small class="px kind-issue">issue #{{ i.number }}</small>
8 8
  <small><span class="tag{% if i.state == "closed" %} bad{% else %} head{% endif %}">{{ i.state }}{% if !i.reason.is_empty() %}: {{ i.reason.replace("_", " ") }}{% endif %}</span> {{ i.author }} opened this <time title="{{ i.created_ms|when_i }}">{{ i.created_ms|ago_i }}</time> · {{ comments.len()|plural("comment", "comments") }}{% if i.locked %} · <span class="tag">locked</span>{% endif %}</small>
9 9
</h1></div>
10 +
{% if !closed_by.is_empty() %}<p class="chain" aria-label="The chain of work"><span class="chain-link kind-issue st-{{ i.state }}"><span class="chain-kind">issue #{{ i.number }}</span> <span class="chain-state">{{ i.state }}</span></span>
11 +
<span class="chain-arrow">←</span> closed by {% for c in closed_by %}<a class="chain-link kind-proposal st-{{ c.state }}" href="{{ c.url }}" title="{{ c.title }}"><span class="chain-kind">proposal #{{ c.number }}</span> {{ c.title }} <span class="chain-state">{{ c.state }}</span></a>{% if !loop.last %} <span class="chain-and">·</span> {% endif %}{% endfor %}</p>{% endif %}
10 12
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
11 13
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
12 14
<div class="side-layout">
…
57 unchanged lines
modified templates/proposal.html +6 −2
1 1
{% extends "base.html" %}
2 2
{% import "repo_tabs.html" as m %}
3 -
{% block title %}#{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
3 +
{% block title %}Proposal #{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %}
6 6
{% block mainclass %} class="wide"{% endblock %}
7 7
{% block content %}
8 -
<div class="head"><h1>{{ p.title }} <small class="px">#{{ p.number }}</small>
8 +
<div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small>
9 9
  <small>
10 10
    <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span>
11 11
    {{ p.author }} wants <span class="lbl lbl-bm">{{ source_label }}</span> into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>
12 12
    · {{ changes.len()|plural("change", "changes") }} · opened <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time>
13 13
  </small>
14 14
</h1></div>
15 +
{% if !closes.is_empty() %}<p class="chain" aria-label="The chain of work">{% for c in closes %}<a class="chain-link kind-issue st-{{ c.state }}" href="{{ c.url }}" title="{{ c.title }}"><span class="chain-kind">issue #{{ c.number }}</span> {{ c.title }} <span class="chain-state">{{ c.state }}</span></a>{% if !loop.last %} <span class="chain-and">+</span> {% endif %}{% endfor %}
16 +
<span class="chain-arrow">→</span> <span class="chain-link kind-proposal st-{{ p.state }}"><span class="chain-kind">proposal #{{ p.number }}</span> <span class="chain-state">{{ p.state }}</span></span>
17 +
<span class="chain-arrow">→</span> <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>{% if p.state == "landed" %} <span class="chain-state st-landed">landed</span>{% endif %}</p>{% endif %}
15 18
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
16 19
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
17 20
{% if conflicted %}<div class="strip bad">Landing would carry {{ conflicts.len()|plural("conflict", "conflicts") }}. Any collaborator can push a resolution change to the source; the proposal follows it. <a href="?tab=files#conflicts">See them</a></div>{% endif %}
…
95 unchanged lines
113 116
      {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %}
114 117
    {% endif %}
115 118
  {% endif %}
119 +
  {% if !closes.is_empty() %}<h3>Closes</h3><ul class="plain">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="small mute">{{ c.state }}</span></li>{% endfor %}</ul>{% endif %}
116 120
  <h3>Labels</h3>
117 121
  {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %}
118 122
  {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %}<button type="submit" class="small">SET</button></form>{% endif %}
…
36 unchanged lines
modified templates/style.css +15 −0
…
578 unchanged lines
579 579
.runner-status.st-offline { color: var(--mute); }
580 580
.runner-status.st-draining { color: var(--warn); border-color: var(--warn); }
581 581
.runner-status.st-disabled { color: var(--bad); border-color: var(--bad); }
582 +
583 +
/* The chain of work: issue → proposal → bookmark (hub spec §8). Issues and
584 +
   proposals share one number space, so every number says which it is. */
585 +
.kind-issue::before { content: "◇ "; color: var(--warn); }
586 +
.kind-proposal::before { content: "⇄ "; color: var(--acc); }
587 +
p.chain { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 10px 0 14px; padding: 8px 12px; border: 1px solid var(--line); border-left: 3px solid var(--acc); background: var(--panel); font-size: 13px; }
588 +
.chain-link { display: inline-flex; align-items: baseline; gap: 6px; padding: 2px 8px; border: 1px solid var(--line); background: var(--soft); color: var(--ink); text-decoration: none; }
589 +
a.chain-link:hover { border-color: var(--acc); text-decoration: none; }
590 +
.chain-kind { font-weight: 600; }
591 +
.chain-state { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--mute); }
592 +
.st-open .chain-state, .chain-state.st-open { color: var(--ok); }
593 +
.st-landed .chain-state, .chain-state.st-landed { color: var(--acc); }
594 +
.st-closed .chain-state { color: var(--mute); }
595 +
.chain-arrow { color: var(--acc); font-weight: 700; }
596 +
.chain-and { color: var(--mute); }