diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md
index e667a68..235ad08 100644
--- a/docs/maturity-and-projects.md
+++ b/docs/maturity-and-projects.md
@@ -132,6 +132,12 @@ Every attribute has:
Values are never secret (`secret` is not allowed; secret markers are refused). Only references to secrets are allowed.
+### Managing attribute sets in the UI
+
+The **Attribute sets** section lists every set with where it lives, what it extends and who reuses it (projects, templates, other sets). A set's
+page shows what it resolves to, with the source of each value and a *modified* mark where a later layer replaced one. Sets stored in a repository
+can be created and edited there; your own `user-attributes` set is edited on the settings page. Deleting sets is not offered yet.
+
### The active user: `user-attributes`
One attribute set lives in the app rather than in any project: `user-attributes`, holding the active user's `operator` (one word) and an
diff --git a/rtut/attrs.py b/rtut/attrs.py
index aaead93..ef6cf99 100644
--- a/rtut/attrs.py
+++ b/rtut/attrs.py
@@ -144,3 +144,88 @@ def check_value(pack: P.Pack, name: str, value) -> str | None:
except (ValueError, KeyError) as e:
return str(e)
return None
+
+
+# ---- managing attribute sets ---------------------------------------------------------------------------------------
+
+import re as _re
+
+SET_ID = _re.compile(r"[a-z0-9]+([.-][a-z0-9]+)*")
+ATTR_NAME = _re.compile(r"[a-z][a-z0-9_]*")
+
+
+def used_by(reg: Registry, set_id: str) -> dict[str, list[str]]:
+ """Who reuses an attribute set: projects and templates (also through targets), and sets that extend it."""
+ out = {"projects": [], "templates": [], "sets": []}
+ for kind, docs in (("projects", reg.projects), ("templates", reg.templates)):
+ for did, d in docs.items():
+ refs = list(d.data.get("attribute_sets", []))
+ for t in d.data.get("targets", {}).values():
+ refs += t.get("attribute_sets", [])
+ if set_id in refs:
+ out[kind].append(did)
+ for sid, d in reg.sets.items():
+ if sid != set_id and set_id in d.data.get("extends", []):
+ out["sets"].append(sid)
+ return out
+
+
+def _would_cycle(reg: Registry, set_id: str, extends: list[str]) -> bool:
+ def reaches(start: str, seen: set) -> bool:
+ if start == set_id:
+ return True
+ if start in seen or start not in reg.sets:
+ return False
+ seen.add(start)
+ return any(reaches(x, seen) for x in reg.sets[start].data.get("extends", []))
+ return any(reaches(x, set()) for x in extends)
+
+
+def save_set(reg: Registry, root: Path, set_id: str, title: str, extends: list[str],
+ attributes: dict[str, dict], existing_path: Path | None = None) -> Path:
+ """Write an attribute set file. `attributes` maps name -> {value, requirement, note} (any part may be empty).
+
+ Validates the id, title, names, secrets, that extended sets exist and that nothing becomes circular.
+ """
+ if not SET_ID.fullmatch(set_id or ""):
+ raise ValueError("the id is lower-case letters, digits, dots and hyphens")
+ if len((title or "").strip()) < 3:
+ raise ValueError("the title needs at least 3 characters")
+ extends = [x for x in extends if x]
+ for x in extends:
+ if x == set_id:
+ raise ValueError("a set cannot extend itself")
+ if x not in reg.sets:
+ raise ValueError(f"unknown attribute set {x} in extends")
+ if _would_cycle(reg, set_id, extends):
+ raise ValueError("that extends list would make the sets circular")
+ out: dict = {}
+ for name, a in attributes.items():
+ if not ATTR_NAME.fullmatch(name):
+ raise ValueError(f"bad attribute name {name!r}: lower-case letters, digits and underscores, starting with a letter")
+ value = a.get("value")
+ value = None if value is None or str(value).strip() == "" else str(value).strip()
+ req = a.get("requirement") or None
+ if req not in (None, "required", "optional"):
+ raise ValueError(f"{name}: requirement is required or optional")
+ note = (a.get("note") or "").strip() or None
+ if value is None and req is None and note is None:
+ raise ValueError(f"{name}: give a value, a requirement or a note")
+ for text in (value or "", note or ""):
+ if SECRET_RE.search(text):
+ raise ValueError(f"{name}: looks like a secret; attribute sets are never secret")
+ entry = {k: v for k, v in (("value", value), ("requirement", req), ("note", note)) if v is not None}
+ out[name] = entry["value"] if list(entry) == ["value"] else entry
+ if SECRET_RE.search(title):
+ raise ValueError("the title looks like a secret")
+ doc: dict = {"spec": "attribute-set/v0.1", "id": set_id, "title": title.strip()}
+ if extends:
+ doc["extends"] = extends
+ doc["attributes"] = out
+ errs = _schema_errors(doc, "attribute-set.schema.json")
+ if errs:
+ raise ValueError(errs[0])
+ path = existing_path or (Path(root) / "attribute-sets" / f"{set_id}.yaml")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(yaml.safe_dump(doc, sort_keys=False))
+ return path
diff --git a/rtut/project.py b/rtut/project.py
index 57c2b0f..61aad2e 100644
--- a/rtut/project.py
+++ b/rtut/project.py
@@ -295,3 +295,27 @@ def set_attributes(reg: Registry, doc: Doc, values: dict[str, str]) -> Path:
text["attributes"] = attrs
doc.path.write_text(yaml.safe_dump(text, sort_keys=False))
return doc.path
+
+
+def update_project(doc: Doc, title: str, description: str = "", goal: str = "") -> Path:
+ """Rename a project and edit its description and goal, in its own file."""
+ if doc.data.get("spec") != "project/v0.1":
+ raise ValueError("only a project can be edited this way")
+ from .pack import SECRET_RE
+ title = title.strip()
+ if len(title) < 3:
+ raise ValueError("the project name needs at least 3 characters")
+ for label, text in (("name", title), ("description", description), ("goal", goal)):
+ if SECRET_RE.search(text):
+ raise ValueError(f"the {label} looks like a secret; projects are never secret")
+ data = yaml.safe_load(doc.path.read_text())
+ data["title"] = title
+ for key, text in (("description", description), ("goal", goal)):
+ if text.strip():
+ data[key] = text.strip()
+ else:
+ data.pop(key, None)
+ ordered = {k: data[k] for k in ("spec", "id", "title", "description", "goal") if k in data}
+ ordered.update({k: v for k, v in data.items() if k not in ordered})
+ doc.path.write_text(yaml.safe_dump(ordered, sort_keys=False))
+ return doc.path
diff --git a/rtut/ui.py b/rtut/ui.py
index d7d3921..17a4ef5 100644
--- a/rtut/ui.py
+++ b/rtut/ui.py
@@ -14,13 +14,19 @@ from . import pack as P, run as R
from . import ui_projects as UP
from . import ui_v2 as UI2
from . import activity as ACT, user as USER
+from . import ui_attrs as UA
from .attrs import Registry
+LIGHT = "--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--side:#f3f3ef"
+DARK = "--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72;--side:#0e0e0d"
CSS = """
-:root{--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--radius:6px}
-@media(prefers-color-scheme:dark){:root{--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72}}
+:root{%s;--radius:6px;color-scheme:light}
+@media(prefers-color-scheme:dark){:root:not([data-theme=light]){%s;color-scheme:dark}}
+:root[data-theme=dark]{%s;color-scheme:dark}
+:root[data-theme=light]{color-scheme:light}
+""" % (LIGHT, DARK, DARK) + """
*{box-sizing:border-box}
-body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;max-width:56rem;margin:2rem auto;padding:0 1.2rem;color:var(--fg);background:var(--bg)}
+body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;color:var(--fg);background:var(--bg)}
h1{font-size:1.6rem;font-weight:650;letter-spacing:-.01em;margin:.4rem 0 .6rem}
h2{font-size:1.1rem;font-weight:650;margin:1.8rem 0 .5rem;padding-bottom:.25rem;border-bottom:1px solid var(--line)}
h3{font-size:.95rem;margin:1.2rem 0 .3rem}
@@ -42,6 +48,7 @@ table{border-collapse:collapse;width:100%;margin:.5rem 0;background:var(--card);
td,th{border-bottom:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}th{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
tr:last-child td{border-bottom:0}
table.data td:first-child{width:42%}
+table.edit input,table.edit select{width:100%}
td label{display:block}
td input:not([type=checkbox]):not([type=hidden]):not([type=radio]){width:100%}
.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}
@@ -70,16 +77,47 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
.pill[data-s=nil],.pill[data-s=todo],.pill[data-s=open],.pill[data-s=planned],.pill[data-s="not checked"],.pill[data-s=not-applied]{color:var(--mut);border-style:dashed}
.feed{list-style:none;padding:0}.feed li{padding:.3rem 0;border-bottom:1px dashed var(--line)}
.checks{list-style:none;padding:0;margin:.4rem 0}.checks li{padding:.35rem 0;border-bottom:1px dashed var(--line)}.checks li:last-child{border:0}
-#current{scroll-margin-top:1rem}details>summary{list-style-position:outside}
+#current{scroll-margin-top:1rem}
+.app{display:grid;grid-template-columns:15.5rem minmax(0,1fr);min-height:100vh}
+main{max-width:58rem;width:100%;padding:1.8rem 1.6rem 4rem}
+.side{position:sticky;top:0;align-self:start;height:100vh;display:flex;flex-direction:column;gap:.2rem;padding:1.1rem .8rem;background:var(--side);border-right:1px solid var(--line)}
+.brand{font:12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;text-decoration:none;padding:.3rem .6rem 1rem;color:var(--fg)}
+.side nav{display:flex;flex-direction:column;gap:.1rem}
+.side a[data-nav]{display:block;padding:.4rem .7rem;border-radius:var(--radius);text-decoration:none;color:var(--mut)}
+.side a[data-nav]:hover{background:var(--hi);color:#141414}
+.side a[data-nav][aria-current=page]{background:var(--card);color:var(--fg);font-weight:650;box-shadow:inset 3px 0 0 var(--fg)}
+.side-foot{margin-top:auto;display:flex;flex-direction:column;gap:.5rem;border-top:1px solid var(--line);padding-top:.8rem}
+.side-foot a.me{display:flex;gap:.5rem;align-items:center}
+.avatar{display:inline-grid;place-items:center;width:1.7rem;height:1.7rem;border-radius:99px;background:var(--fg);color:var(--bg);font:12px ui-monospace,monospace}
+.side-foot button{background:var(--card);color:var(--fg);border-color:var(--line);font-size:.85rem}
+@media(max-width:52rem){.app{grid-template-columns:1fr}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:.6rem .8rem}
+.brand{padding:.3rem .6rem}.side nav{flex-direction:row;flex-wrap:wrap}.side-foot{margin:0 0 0 auto;border:0;padding:0;flex-direction:row;align-items:center}main{padding:1.2rem .9rem 3rem}}details>summary{list-style-position:outside}
@media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}}
-@media print{button,.tabs,form{display:none}.step,.stage{break-inside:avoid}}
+@media print{.side,button,.tabs,form{display:none}.app{display:block}.step,.stage{break-inside:avoid}}
"""
+NAV = (("Home", "/"), ("Projects", "/projects"), ("Runbooks", "/runbooks"), ("Attribute sets", "/attribute-sets"), ("Activity", "/activity"))
+
+# applied before the page paints, so a saved theme never flashes; ?theme=dark|light|system also sets it
+THEME_INIT = ("(function(){try{var q=new URLSearchParams(location.search).get('theme');"
+ "if(q==='dark'||q==='light')localStorage.setItem('rtut-theme',q);else if(q==='system')localStorage.removeItem('rtut-theme');"
+ "var t=localStorage.getItem('rtut-theme');"
+ "if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);"
+ "else document.documentElement.removeAttribute('data-theme')}catch(x){}})()")
+
+
def page(title: str, body: str) -> bytes:
- return (f""
- f"
{body}"
+ u = USER.current()
+ label = u["display_name"] or u["operator"]
+ nav = "".join(f"{html.escape(t)}" for t, h in NAV)
+ side = (f"")
+ return (f""
+ f"{html.escape(title)}"
+ f"
")
+ roots = "".join(f"" for i, r in enumerate(app.roots))
+ extends_help = ", ".join(sorted(reg.sets)) or "none yet"
+ body = ("
Attribute sets
"
+ "
Named, reusable, non-secret facts. Runbooks receive them as inputs, projects and templates compose them, and one set "
+ "can extend another (later layers win, and every value keeps its source). Nothing here may be a secret.
"
+ + (f"
{e(msg)}
" if msg else "")
+ + ("
Set
Where it lives
Extends
Attributes
Reused by
" + rows + "
"
+ if rows else "
No attribute sets yet.
")
+ + "New attribute set"
+ f"")
+ return page("Attribute sets", body)
+
+
+def set_page(app, sid: str, msg: str = "") -> bytes | None:
+ from .ui import page
+ reg = app.registry()
+ doc = reg.sets.get(sid)
+ if doc is None:
+ return None
+ d = doc.data
+ out = [f"
{e(d.get('title', sid))}
{pill('app' if _is_app_level(doc) else 'repository')} {e(sid)} "
+ f"{e(_where(app, doc))}
")
+ folded = {}
+ rows = ""
+ for name, a in folded.items():
+ val = "" if a["value"] is None else a["value"]
+ rows += (f"
{e(name)}
{e(val) if val != '' else '(no value)'}
"
+ f"
{e(a['requirement'] or '-')}
{e(a.get('note', ''))}
{e(a['source'] or '-')}"
+ f"{' ' + pill('modified') if a['modified'] else ''}
")
+ out.append("
What this set resolves to
Including everything it extends; the source shows which set gave each value.
"
+ "
Attribute
Value
Requirement
Note
Source
" + rows + "
")
+ if _is_app_level(doc):
+ out.append("
This is your set: it follows you across every project. Change it on "
+ "your settings page. A repository may define its own set with the same id, which then wins.
")
+ return page(d.get("title", sid), "".join(out))
+ own = d.get("attributes", {})
+ trs = ""
+ from .attrs import normalize
+ for i, (name, a) in enumerate(normalize(own).items()):
+ req = a.get("requirement") or ""
+ opts = "".join(f"" for o in ("", "required", "optional"))
+ trs += (f"
"
+ f"
"
+ f"
")
+ base = len(own)
+ for j in range(EXTRA_ROWS):
+ i = base + j
+ trs += (f"
"
+ f"
"
+ f"
")
+ warn = ""
+ u = used_by(reg, sid)
+ if u["projects"] or u["templates"] or u["sets"]:
+ warn = ("
Other things reuse this set. Changing a value changes what their runbooks receive from now on; "
+ "runs that already started keep the values they started with.
")
+ out.append("
Edit
" + warn +
+ f"")
+ return page(d.get("title", sid), "".join(out))
+
+
+def _split(text: str) -> list[str]:
+ return [x for x in text.replace(",", " ").split() if x]
+
+
+def handle_post(app, parts: list[str], form: dict) -> str:
+ """Create or edit an attribute set. Returns the URL to go to; raises ValueError with the reason if refused."""
+ reg = app.registry()
+ if parts == ["attribute-sets", "new"]:
+ try:
+ root = app.roots[int(form.get("root", "0"))]
+ except (ValueError, IndexError):
+ raise ValueError("choose a repository")
+ sid = form.get("set_id", "").strip()
+ if sid in reg.sets:
+ raise ValueError(f"an attribute set {sid} already exists")
+ save_set(reg, root, sid, form.get("title", ""), _split(form.get("extends", "")), {})
+ return f"/attribute-set/{sid}"
+ if len(parts) == 2 and parts[0] == "attribute-set":
+ sid = parts[1]
+ doc = reg.sets.get(sid)
+ if doc is None:
+ raise KeyError(sid)
+ if _is_app_level(doc):
+ raise ValueError("this is your app-level set: change it on your settings page")
+ attrs: dict = {}
+ for i in range(int(form.get("rows", "0") or 0)):
+ name = form.get(f"n{i}", "").strip()
+ if not name or form.get(f"x{i}"):
+ continue
+ if name in attrs:
+ raise ValueError(f"{name} appears twice")
+ attrs[name] = {"value": form.get(f"v{i}", ""), "requirement": form.get(f"r{i}", ""), "note": form.get(f"t{i}", "")}
+ save_set(reg, Path(doc.path).parent.parent, sid, form.get("title", ""), _split(form.get("extends", "")), attrs, existing_path=Path(doc.path))
+ return f"/attribute-set/{sid}"
+ raise ValueError("unknown action")
diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py
index a8199ff..313508a 100644
--- a/rtut/ui_projects.py
+++ b/rtut/ui_projects.py
@@ -120,6 +120,12 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
out.append(f"