Add Control Plane bubble help and step-by-step guidance

Context ? help popovers, lifecycle checklist, and recommended-next-step
cards on login, dashboard, phase detail/new, and extensions. Pure
guidance helpers + CSS; deploy image 0.1.4.
This commit is contained in:
tegwick 2026-08-06 22:35:05 +02:00
parent 8cf124530c
commit f1109d54ee
14 changed files with 1031 additions and 65 deletions

View file

@ -30,7 +30,7 @@ spec:
fsGroup: 10001
containers:
- name: bootstrap
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
imagePullPolicy: IfNotPresent
env:
- name: TRF_BOOTSTRAP_BINKY

View file

@ -25,7 +25,7 @@ spec:
fsGroup: 10001
containers:
- name: target-revenue
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
imagePullPolicy: IfNotPresent
ports:
- name: http

View file

@ -28,7 +28,7 @@ spec:
fsGroup: 10001
containers:
- name: migrate
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
imagePullPolicy: IfNotPresent
env:
- name: TRF_RUN_MIGRATIONS

View file

@ -26,7 +26,7 @@ from psycopg_pool import ConnectionPool
from starlette.middleware.sessions import SessionMiddleware
from .. import breach_record, control_plane, ledger, metrics, registry
from . import keys, reference_docs
from . import guidance, keys, reference_docs
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
@ -201,8 +201,27 @@ def dashboard(
conn: Connection = Depends(get_connection),
):
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
pending = 0
if registry.has_right(licensor.rights, "operator"):
pending = len(control_plane.list_proposed_entries(conn, status="pending"))
first_id = phases[0]["phase"]["id"] if phases else None
next_steps = guidance.dashboard_guidance(
rights=licensor.rights,
phase_count=len(phases),
pending_proposals=pending,
root_path=_root_path(request),
first_phase_id=first_id,
)
return templates.TemplateResponse(
request, "dashboard.html", _template_context(request, licensor, phases=phases)
request,
"dashboard.html",
_template_context(
request,
licensor,
phases=phases,
next_steps=next_steps,
pending_proposals=pending,
),
)
@ -291,6 +310,27 @@ def phase_detail(
phase_attestation = control_plane.get_or_publish_attestation(
conn, phase_id, signing_key
)
extension_count = len(registry.list_extensions(conn))
pending_for_phase = 0
if registry.has_right(licensor.rights, "operator"):
pending_for_phase = len(
control_plane.list_proposed_entries(conn, phase_id=phase_id, status="pending")
)
next_steps = guidance.phase_guidance(
rights=licensor.rights,
root_path=_root_path(request),
phase_id=phase_id,
metrics=computed_metrics,
ledger_len=len(entries),
extension_count=extension_count,
has_attestation=phase_attestation is not None,
pending_proposals_for_phase=pending_for_phase,
)
checklist = guidance.phase_lifecycle_checklist(
ledger_len=len(entries),
metrics=computed_metrics,
has_attestation=phase_attestation is not None,
)
return templates.TemplateResponse(
request,
"phase_detail.html",
@ -302,6 +342,8 @@ def phase_detail(
metrics=computed_metrics,
breaches=breaches,
attestation=phase_attestation,
next_steps=next_steps,
checklist=checklist,
),
)

View file

@ -13,6 +13,7 @@
}
</script>
<script type="module" src="{{ root_path }}/static/whynot-design/index.js"></script>
<link rel="stylesheet" href="{{ root_path }}/static/control-plane.css">
<style>
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; font-family: system-ui, sans-serif; }
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
@ -20,7 +21,7 @@
form.wn-form input, form.wn-form select, form.wn-form textarea {
padding: 0.5rem 0.6rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px;
}
form.wn-form button[type=submit] {
form.wn-form button[type=submit], form.wn-form button[type=submit].secondary {
padding: 0.55rem 1rem; font-size: 1rem; cursor: pointer;
background: #1a1a1a; color: #fff; border: none; border-radius: 4px; width: fit-content;
}
@ -93,6 +94,19 @@
});
});
});
// Guided-step anchors: smooth-scroll + brief highlight.
document.querySelectorAll("[data-cp-scroll]").forEach(function (link) {
link.addEventListener("click", function (ev) {
var id = link.getAttribute("data-cp-scroll");
var el = id && document.getElementById(id);
if (!el) return;
ev.preventDefault();
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("cp-section-highlight");
setTimeout(function () { el.classList.remove("cp-section-highlight"); }, 1800);
});
});
})();
</script>
</body>

View file

@ -1,24 +1,54 @@
{% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Phases for {{ session_licensor_id }}</span>
<span slot="title">
Phases for {{ session_licensor_id }}
{{ help_bubble(
"A Phase is one monetization undertaking: an Initial Target, Milestone Release, "
"Future License, and degeneration policy. Ledger activity (Development Credit and "
"Remission Credit) always belongs to a Phase."
) }}
</span>
</wn-page-header>
{{ render_next_steps(next_steps, title='Recommended next step') }}
{% if session_rights in ("operator", "admin") %}
<p><a href="{{ root_path }}/phases/new">+ Register a new Phase</a></p>
<p>
<a href="{{ root_path }}/phases/new">+ Register a new Phase</a>
{{ help_bubble(
"Operators declare Phases. Registration is append-only: the Manifest cannot be "
"edited in place after publish. Choose Initial Target carefully."
) }}
</p>
{% endif %}
{% if pending_proposals and pending_proposals > 0 and session_rights in ("operator", "admin") %}
<p>
<a href="{{ root_path }}/proposals"><strong>{{ pending_proposals }}</strong> proposal(s) awaiting review</a>
{{ help_bubble("Contributors submit Development Credit; Operators approve or reject.") }}
</p>
{% endif %}
{% if phases %}
<table class="wn-plain">
<thead><tr><th>Phase</th><th>Milestone Release</th><th>Initial Target</th><th></th></tr></thead>
<thead>
<tr>
<th>Phase {{ help_bubble("Globally unique id, usually trsl:phase:…") }}</th>
<th>Milestone Release</th>
<th>Initial Target {{ help_bubble("Immutable commercial ceiling for this Phase. Outstanding Target starts here.") }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for phase in phases %}
<tr>
<td>{{ phase.phase.id }}</td>
<td>{{ phase.phase.milestone_release.name }}</td>
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
<td><a href="{{ root_path }}/phases/{{ phase.phase.id }}">View</a></td>
<td><a href="{{ root_path }}/phases/{{ phase.phase.id }}">Open →</a></td>
</tr>
{% endfor %}
</tbody>

View file

@ -1,14 +1,34 @@
{% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Extensions — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Monetization Extension Registry</span>
<span slot="title">
Monetization Extension Registry
{{ help_bubble(
"Extensions describe how commercial value becomes Development Credit "
"(pricing, allocation, recognition, evidence). Ledger credit rows reference them."
) }}
</span>
</wn-page-header>
<p style="color:#888;font-size:0.9rem;">
{% if session_rights in ("operator", "admin") %}
{% set ext_steps = [
{
"key": "register",
"title": "Register an extension if you need a new profile",
"body": "Start with trsl:extension:development-license@1.0 for license-style sales. Operator+ can register; only Admin can promote to canonical.",
"priority": "recommended",
"section": "ext-form",
"cta": "Jump to form",
},
] %}
{{ render_next_steps(ext_steps, title='Extension workflow') }}
{% endif %}
<p class="cp-field-hint">
Rights: <strong>Operator+</strong> may register; <strong>Admin</strong> may
promote to <code>canonical</code> (governance action, never automated).
See <code>specs/TargetRevenueControlPlaneConcept.md</code> §2.
</p>
{% if extensions %}
@ -52,6 +72,7 @@
{% endif %}
{% if session_rights in ("operator", "admin") %}
<div id="ext-form">
<h3>Register a new extension</h3>
<form class="wn-form" method="post" action="{{ root_path }}/extensions">
<label>Extension id (trsl:extension:...)
@ -83,5 +104,6 @@
</label>
<button type="submit">Register extension</button>
</form>
</div>
{% endif %}
{% endblock %}

View file

@ -1,16 +1,51 @@
{% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Sign in — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Sign in</span>
<span slot="title">
Sign in
{{ help_bubble(
"This UI is multi-user: you authenticate with a personal credential token "
"issued under the binky Licensor tenant, not a shared password."
) }}
</span>
</wn-page-header>
<p>Paste the credential token you were issued by an Admin.</p>
{# Native controls: reliable under /ui mount even if Lit components fail to hydrate. #}
{% set login_steps = [
{
"key": "get_token",
"title": "1. Obtain your credential token",
"body": "Admins issue tokens in Credentials. The founding pilot token is stored in OpenBao at platform/operators/founding-admin/revenue (field token).",
"priority": "recommended",
},
{
"key": "paste",
"title": "2. Paste the token below",
"body": "Use the raw 64-character hex value only — no quotes. Whitespace is stripped automatically.",
"priority": "recommended",
"section": "login-form",
"cta": "Jump to form",
},
{
"key": "continue",
"title": "3. Follow the dashboard guide",
"body": "After sign-in, the dashboard suggests the next recommended action for your rights tier.",
"priority": "optional",
},
] %}
{{ render_next_steps(login_steps, title='How to sign in') }}
<div id="login-form">
<form class="wn-form" method="post" action="{{ root_path }}/login">
<label>
Credential token
<span class="cp-label-row">
Credential token
{{ help_bubble("OpenBao: platform/operators/founding-admin/revenue → field `token`. Never commit tokens to git.") }}
</span>
<input type="password" name="token" required autocomplete="current-password">
</label>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,54 @@
{# Shared Control Plane UI macros: bubble help + next-step guide. #}
{% macro help_bubble(text, label='Help') -%}
<span class="cp-help">
<button type="button" class="cp-help__btn" aria-label="{{ label }}">?</button>
<span class="cp-help__bubble" role="tooltip">{{ text }}</span>
</span>
{%- endmacro %}
{% macro render_next_steps(steps, title='What to do next') -%}
{% if steps %}
<section class="cp-guide" aria-label="{{ title }}">
<div class="cp-guide__header">
<div>
<div class="cp-guide__eyebrow">Guided steps</div>
<h2 class="cp-guide__title">{{ title }}</h2>
</div>
</div>
<ol class="cp-guide__list">
{% for s in steps %}
<li class="cp-guide__item cp-guide__item--{{ s.priority }}">
<span class="cp-guide__badge">
{% if s.priority == 'recommended' %}Next
{% elif s.priority == 'optional' %}Optional
{% else %}Status{% endif %}
</span>
<div>
<p class="cp-guide__item-title">{{ s.title }}</p>
<p class="cp-guide__item-body">{{ s.body }}</p>
</div>
{% if s.href and s.cta %}
<a class="cp-guide__cta" href="{{ s.href }}">{{ s.cta }}</a>
{% elif s.section and s.cta %}
<a class="cp-guide__cta" href="#{{ s.section }}" data-cp-scroll="{{ s.section }}">{{ s.cta }}</a>
{% endif %}
</li>
{% endfor %}
</ol>
</section>
{% endif %}
{%- endmacro %}
{% macro lifecycle_checklist(items) -%}
{% if items %}
<ul class="cp-checklist" aria-label="Phase lifecycle progress">
{% for item in items %}
<li class="cp-checklist__item{% if item.done %} cp-checklist__item--done{% endif %}">
<span class="cp-checklist__dot" aria-hidden="true"></span>
{{ item.label }}
</li>
{% endfor %}
</ul>
{% endif %}
{%- endmacro %}

View file

@ -1,13 +1,29 @@
{% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps, lifecycle_checklist %}
{% block title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">{{ manifest.phase.milestone_release.name }}</span>
<span slot="title">
{{ manifest.phase.milestone_release.name }}
{{ help_bubble(
"This page is the operator cockpit for one Phase: status, ledger, remission, "
"conversion attestation, and breach records."
) }}
</span>
</wn-page-header>
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p>
{{ lifecycle_checklist(checklist) }}
{{ render_next_steps(next_steps, title='What to do next on this Phase') }}
<details style="margin-bottom:1rem;color:#888;font-size:0.9rem;">
<summary style="cursor:pointer;">Ledger reference (internal — not this Phase's ledger entries, see below)</summary>
<summary style="cursor:pointer;">
Ledger reference (internal)
{{ help_bubble(
"The raw ledger URI stored on the Manifest. Live entries are listed below; "
"this drill-down is for federation-ready reference, not day-to-day use."
) }}
</summary>
<p>
Raw reference: <code>{{ manifest.phase.ledger }}</code>
{% if manifest.phase.ledger.startswith('/phases/') %}
@ -16,12 +32,30 @@
</p>
</details>
<h3>Status</h3>
<h3>
Status
{{ help_bubble(
"Outstanding Target = max(0, Initial Target Development Credit Remission Credit). "
"When it reaches zero, the Phase converts to the Future License automatically."
) }}
</h3>
<table class="wn-plain">
<tr><td>Initial Target</td><td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td></tr>
<tr><td>Cumulative Development Credit</td><td>{{ metrics.facts.cumulative_development_credit }}</td></tr>
<tr><td>Cumulative Remission Credit</td><td>{{ metrics.facts.cumulative_remission_credit }}</td></tr>
<tr><td>Outstanding Target</td><td>{{ metrics.facts.outstanding_target }}</td></tr>
<tr>
<td>Initial Target {{ help_bubble("Immutable commercial ceiling declared at Phase registration.") }}</td>
<td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td>
</tr>
<tr>
<td>Cumulative Development Credit {{ help_bubble("Settled commercial payments allocated to this Phase (not revenue accounting).") }}</td>
<td>{{ metrics.facts.cumulative_development_credit }}</td>
</tr>
<tr>
<td>Cumulative Remission Credit {{ help_bubble("Time-based reduction under the degeneration policy — never represented as captured revenue.") }}</td>
<td>{{ metrics.facts.cumulative_remission_credit }}</td>
</tr>
<tr>
<td>Outstanding Target</td>
<td><strong>{{ metrics.facts.outstanding_target }}</strong></td>
</tr>
<tr><td>Target satisfaction</td><td>{{ metrics.calculations.target_satisfaction_percentage }}%</td></tr>
<tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr>
<tr>
@ -31,11 +65,24 @@
&middot; <a href="{{ root_path }}/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
</td>
</tr>
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr>
<tr><td>Activated (t0)</td><td>{{ metrics.facts.activated_at or "—" }}</td></tr>
<tr>
<td>Longstop {{ help_bubble("Full-remission instant: by this time Remission Credit alone can exhaust the Initial Target.") }}</td>
<td>{{ metrics.facts.longstop_at or "—" }}</td>
</tr>
<tr>
<td>Activated (t0) {{ help_bubble("Phase activation time for remission math — Trust Service registration timestamp.") }}</td>
<td>{{ metrics.facts.activated_at or "—" }}</td>
</tr>
</table>
<h3>Remission forecast <wn-tag>forecast</wn-tag></h3>
<div id="section-remission">
<h3>
Remission forecast <wn-tag>forecast</wn-tag>
{{ help_bubble(
"Forecasts are projections, not ledger facts. Applying remission writes a real "
"remission-credit entry only when the cumulative formula is ahead of what is already recorded."
) }}
</h3>
<table class="wn-plain">
<tr>
<td>If applied now</td>
@ -65,14 +112,21 @@
{% if session_rights in ("operator", "admin") %}
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/remission" style="margin-bottom:1.5rem;">
<button type="submit" class="secondary">Apply policy remission now</button>
<p style="color:#888;font-size:0.85rem;margin-top:0.5rem;">
<p class="cp-field-hint">
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
(idempotent — no double-remit if already current).
</p>
</form>
{% endif %}
</div>
<h3>Ledger ({{ ledger | length }} entries)</h3>
<h3>
Ledger ({{ ledger | length }} entries)
{{ help_bubble(
"Append-only. Corrections are new compensating entries (credit-reversal / "
"remission-correction), never edits of prior rows."
) }}
</h3>
{% if ledger %}
<table class="wn-plain">
<thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead>
@ -87,38 +141,56 @@
{% endif %}
{% if session_rights in ("contributor", "operator", "admin") %}
<div id="section-ledger-form">
<h3>
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
{{ help_bubble(
"Use settlement time (not invoice time). Extension id must match a registered "
"monetization extension. Evidence can use the confidential: scheme for private refs."
) }}
</h3>
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/ledger">
<label>Entry id (trsl:entry:...)
<input name="entry_id" required>
<label>
<span class="cp-label-row">Entry id (trsl:entry:…) {{ help_bubble("Globally unique; letters and digits only after trsl:entry: (no hyphens).") }}</span>
<input name="entry_id" required placeholder="trsl:entry:dc001">
</label>
<label>Amount
<label>
<span class="cp-label-row">Amount {{ help_bubble("Positive amount in the Phase native currency.") }}</span>
<input type="number" step="0.01" name="amount" required>
</label>
<label>Currency
<input name="currency" value="{{ manifest.phase.initial_target.currency }}" required>
</label>
<label>Recognized at (ISO 8601)
<input name="recognized_at" required>
<label>
<span class="cp-label-row">Recognized at (ISO 8601) {{ help_bubble("Payment-settled time, e.g. 2026-08-06T12:00:00Z.") }}</span>
<input name="recognized_at" required placeholder="2026-08-06T12:00:00Z">
</label>
<label>Evidence reference
<input name="evidence_reference" required>
<label>
<span class="cp-label-row">Evidence reference {{ help_bubble("URI or confidential:… id pointing at settlement evidence.") }}</span>
<input name="evidence_reference" required placeholder="confidential:evidence:…">
</label>
<label>Extension id (trsl:extension:...)
<input name="extension_id" required>
<label>
<span class="cp-label-row">Extension id {{ help_bubble("Usually trsl:extension:development-license for license sales.") }}</span>
<input name="extension_id" required value="trsl:extension:development-license">
</label>
<label>Extension version
<input name="extension_version" required>
<input name="extension_version" required value="1.0">
</label>
<button type="submit">
{% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %}
</button>
</form>
</div>
{% endif %}
<h3>Conversion Attestation</h3>
<div id="section-attestation">
<h3>
Conversion Attestation
{{ help_bubble(
"Signed public evidence that conversion occurred. Conversion itself is true "
"the moment the fold hits zero — the attestation only observes and publishes it."
) }}
</h3>
{% if attestation %}
<table class="wn-plain">
<tr><td>Phase</td><td>{{ attestation.phase }}</td></tr>
@ -136,8 +208,15 @@
{% else %}
<wn-empty-state>Not converted — no attestation yet.</wn-empty-state>
{% endif %}
</div>
<h3>Breach / Compliance Records ({{ breaches | length }})</h3>
<h3>
Breach / Compliance Records ({{ breaches | length }})
{{ help_bubble(
"Licensor determinations only — anonymized by default. Named disclosure requires "
"an explicit CUA authorization assertion (License §7.4)."
) }}
</h3>
{% if breaches %}
<table class="wn-plain">
<thead>
@ -175,10 +254,9 @@
{% if session_rights in ("operator", "admin") %}
<h3>Publish a breach/compliance event</h3>
<p style="color:#888;font-size:0.85rem;">
<p class="cp-field-hint">
Anonymized by default (Phase + category only). Named disclosure requires
an affirmative CUA authorization check (License V1C1 §7.4) — this form
records that assertion; it does not verify the CUA text.
an affirmative CUA authorization check.
</p>
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/breach">
<label>Record id (unique)

View file

@ -1,59 +1,121 @@
{% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
{% block content %}
<wn-page-header>
<span slot="title">Register a new Phase (Operator+)</span>
<span slot="title">
Register a new Phase (Operator+)
{{ help_bubble(
"Registration is permanent for this Phase id. Validate numbers and repo "
"provenance before submit — there is no in-place Manifest edit."
) }}
</span>
</wn-page-header>
<p style="color:#888;">
{% set form_steps = [
{
"key": "identity",
"title": "1. Identify the Phase and repo",
"body": "Choose a unique phase id, name the Milestone Release, and pin the Forgejo repo (hub, id, name, revision).",
"priority": "recommended",
"section": "step-identity",
"cta": "Jump to identity fields",
},
{
"key": "target",
"title": "2. Set the commercial target",
"body": "Initial Target amount + currency is immutable after registration. Use the effort calculator offline if needed.",
"priority": "recommended",
"section": "step-target",
"cta": "Jump to target fields",
},
{
"key": "policy",
"title": "3. Future License, policy, longstop",
"body": "Declare MIT or Apache-2.0, the linear-longstop policy, and a hard full-remission date.",
"priority": "recommended",
"section": "step-policy",
"cta": "Jump to policy fields",
},
] %}
{{ render_next_steps(form_steps, title='How to fill this form') }}
<p class="cp-field-hint">
This registers a real Phase Manifest against the hosted Trust Service.
Further Phases beyond the T05 pilot still need an explicit declaration act.
</p>
<form class="wn-form" method="post" action="{{ root_path }}/phases/new">
<label>Phase id (trsl:phase:...)
<input name="phase_id" required>
<div id="step-identity">
<h3>Identity &amp; repository</h3>
<label>
<span class="cp-label-row">Phase id (trsl:phase:…) {{ help_bubble("Globally unique, immutable. Example: trsl:phase:my-product-v1") }}</span>
<input name="phase_id" required placeholder="trsl:phase:…">
</label>
<label>Milestone Release name
<label>
<span class="cp-label-row">Milestone Release name {{ help_bubble("Human-readable name of the governed release / deliverable.") }}</span>
<input name="milestone_release_name" required>
</label>
<label>Source revision
<input name="source_revision" required>
<label>
<span class="cp-label-row">Source revision {{ help_bubble("Git commit SHA of the Milestone Release being governed.") }}</span>
<input name="source_revision" required placeholder="abcdef…">
</label>
<label>Repo hub (stable Forgejo instance slug)
<label>
<span class="cp-label-row">Repo hub {{ help_bubble("Stable slug for the Forgejo instance, e.g. forgejo-coulomb.") }}</span>
<input name="repo_hub" value="forgejo-coulomb" required>
</label>
<label>Repo hub URI
<label>
<span class="cp-label-row">Repo hub URI {{ help_bubble("Current base URL of that Forgejo, kept for repair if the domain changes.") }}</span>
<input name="repo_hub_uri" value="https://forgejo.coulomb.social" required>
</label>
<label>Repo id (Forgejo's own numeric id)
<label>
<span class="cp-label-row">Repo id {{ help_bubble("Forgejo numeric repo id (GET /api/v1/repos/{owner}/{repo} → id). Survives renames.") }}</span>
<input type="number" name="repo_id" required>
</label>
<label>Repo name (owner/repo)
<input name="repo_name" required>
<label>
<span class="cp-label-row">Repo name (owner/repo) {{ help_bubble("Human-readable slug at registration time.") }}</span>
<input name="repo_name" required placeholder="coulomb/my-repo">
</label>
<label>Base Phase id (optional — only for a successive Phase)
<input name="base_phase_id">
<label>
<span class="cp-label-row">Base Phase id (optional) {{ help_bubble("Only for a successive Phase on a repo that already converted an earlier Phase (FR-11).") }}</span>
<input name="base_phase_id" placeholder="trsl:phase:prior-…">
</label>
<label>Initial Target amount
</div>
<div id="step-target">
<h3>Commercial target</h3>
<label>
<span class="cp-label-row">Initial Target amount {{ help_bubble("Cannot be increased for commercial convenience after the Phase begins.") }}</span>
<input type="number" step="0.01" name="initial_target_amount" required>
</label>
<label>Currency (ISO 4217)
<label>
<span class="cp-label-row">Currency (ISO 4217) {{ help_bubble("One native currency per Phase. All ledger entries must match.") }}</span>
<input name="currency" value="EUR" required>
</label>
<label>Future License
</div>
<div id="step-policy">
<h3>License &amp; degeneration</h3>
<label>
<span class="cp-label-row">Future License {{ help_bubble("Permissive license that takes effect automatically at conversion.") }}</span>
<select name="future_license">
<option value="MIT">MIT</option>
<option value="Apache-2.0">Apache-2.0</option>
</select>
</label>
<label>Degeneration policy
<label>
<span class="cp-label-row">Degeneration policy {{ help_bubble("v1 pilot norm: linear remission toward the longstop date.") }}</span>
<input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required>
</label>
<p style="margin-top:-0.5rem;color:#888;font-size:0.85rem;">
<p class="cp-field-hint">
<a href="{{ root_path }}/reference/policies/linear-longstop-v0">view the linear-longstop-v0 spec</a>
</p>
<label>Longstop date (ISO 8601)
<input name="longstop_at" required>
<label>
<span class="cp-label-row">Longstop date (ISO 8601) {{ help_bubble("Required full-remission / maximum-protection instant, e.g. 2029-01-01T00:00:00Z.") }}</span>
<input name="longstop_at" required placeholder="2029-01-01T00:00:00Z">
</label>
</div>
<button type="submit">Register Phase</button>
</form>
{% endblock %}

View file

@ -0,0 +1,307 @@
"""Step-by-step next-action guidance for the Control Plane UI.
Pure helpers: given Phase/metrics/ledger/proposal context, return ordered
recommended steps so templates can show a clear "what next" card without
embedding lifecycle rules in Jinja.
"""
from __future__ import annotations
from typing import Any
def _step(
key: str,
title: str,
body: str,
*,
priority: str = "recommended",
href: str | None = None,
cta: str | None = None,
section: str | None = None,
) -> dict[str, Any]:
return {
"key": key,
"title": title,
"body": body,
"priority": priority, # recommended | optional | done_hint
"href": href,
"cta": cta,
"section": section, # HTML id to scroll/highlight
}
def dashboard_guidance(
*,
rights: str,
phase_count: int,
pending_proposals: int,
root_path: str,
first_phase_id: str | None = None,
) -> list[dict[str, Any]]:
"""Guidance for the tenant dashboard."""
steps: list[dict[str, Any]] = []
if pending_proposals > 0 and rights in ("operator", "admin"):
steps.append(
_step(
"review_proposals",
f"Review {pending_proposals} pending proposal(s)",
"A Contributor submitted Development Credit for approval. "
"Approving appends it to the live ledger under your credential.",
priority="recommended",
href=f"{root_path}/proposals",
cta="Open proposals",
)
)
if phase_count == 0:
if rights in ("operator", "admin"):
steps.append(
_step(
"register_phase",
"Register your first Phase",
"A Phase is the bounded monetization unit: Initial Target, "
"Milestone Release, Future License, and degeneration policy. "
"Nothing is tracked until a Phase exists.",
priority="recommended",
href=f"{root_path}/phases/new",
cta="Register a Phase",
)
)
else:
steps.append(
_step(
"wait_for_phase",
"No Phases yet",
"An Operator must register a Phase before Development Credit "
"or Remission Credit can be recorded.",
priority="recommended",
)
)
else:
if first_phase_id:
steps.append(
_step(
"open_phase",
"Continue on an existing Phase",
"Open a Phase to record Development Credit, apply Remission "
"Credit, or check conversion status.",
priority="recommended",
href=f"{root_path}/phases/{first_phase_id}",
cta="Open Phase",
)
)
if rights in ("operator", "admin"):
steps.append(
_step(
"register_another",
"Register another Phase (optional)",
"Each product line / Milestone Release needs its own Phase. "
"T05 only authorized the first pilot — further Phases are "
"deliberate declaration acts.",
priority="optional",
href=f"{root_path}/phases/new",
cta="New Phase",
)
)
return steps
def phase_guidance(
*,
rights: str,
root_path: str,
phase_id: str,
metrics: dict[str, Any],
ledger_len: int,
extension_count: int,
has_attestation: bool,
pending_proposals_for_phase: int = 0,
) -> list[dict[str, Any]]:
"""Ordered next steps for one Phase detail page."""
facts = metrics.get("facts") or {}
forecasts = metrics.get("forecasts") or {}
steps: list[dict[str, Any]] = []
if facts.get("is_converted"):
if has_attestation:
steps.append(
_step(
"converted",
"Phase is converted",
"Outstanding Target reached zero. The Future License applies "
"to the Milestone Release. Review the Conversion Attestation below.",
priority="done_hint",
section="section-attestation",
cta="Jump to attestation",
)
)
else:
steps.append(
_step(
"publish_attestation",
"Attestation will appear on refresh",
"Conversion is already true from the ledger fold. Reload this "
"page to publish/view the signed Conversion Attestation.",
priority="recommended",
section="section-attestation",
)
)
return steps
if pending_proposals_for_phase > 0 and rights in ("operator", "admin"):
steps.append(
_step(
"phase_proposals",
f"Approve or reject {pending_proposals_for_phase} proposal(s)",
"Pending Development Credit for this Phase is waiting in the "
"proposals queue.",
priority="recommended",
href=f"{root_path}/proposals",
cta="Review proposals",
)
)
if extension_count == 0 and rights in ("operator", "admin"):
steps.append(
_step(
"register_extension",
"Register a monetization extension",
"Development Credit entries reference an extension "
"(e.g. trsl:extension:development-license@1.0). Register one "
"before appending credits, or reuse an already-registered id.",
priority="recommended",
href=f"{root_path}/extensions",
cta="Open extensions",
)
)
rem_now = forecasts.get("remission_if_applied_now")
if (
rem_now is not None
and float(rem_now) > 0
and rights in ("operator", "admin")
):
cur = facts.get("initial_target_currency") or ""
steps.append(
_step(
"apply_remission",
f"Apply policy remission (~{rem_now} {cur})",
"Linear-longstop has accrued Remission Credit that is not yet "
"on the ledger. Applying writes an idempotent remission-credit "
"entry under system:policy-engine.",
priority="recommended",
section="section-remission",
cta="Jump to remission",
)
)
if ledger_len == 0:
if rights in ("contributor", "operator", "admin"):
who = (
"Propose a Development Credit"
if rights == "contributor"
else "Record the first Development Credit"
)
steps.append(
_step(
"first_credit",
who,
"When a Commercial Entitlement payment settles, append a "
"development-credit entry (amount, evidence, extension). "
"That is how real monetization progress is tracked.",
priority="recommended" if not steps else "optional",
section="section-ledger-form",
cta="Jump to credit form",
)
)
else:
steps.append(
_step(
"viewer_empty",
"No ledger activity yet",
"Operators will record Development Credit and Remission Credit "
"as commercial activity and time progress occur.",
priority="recommended",
)
)
else:
if rights in ("contributor", "operator", "admin"):
steps.append(
_step(
"more_credit",
"Add further Development Credit when payments settle",
"Each settled commercial payment that allocates to this Phase "
"should produce a new ledger entry (append-only; corrections "
"are compensating entries, never edits).",
priority="optional",
section="section-ledger-form",
cta="Jump to credit form",
)
)
next_at = forecasts.get("next_scheduled_remission_at")
if next_at and rights in ("operator", "admin"):
steps.append(
_step(
"schedule_remission",
"Keep Remission on schedule",
f"Next scheduled recognition boundary: {next_at}. "
"Run Apply policy remission after that date (or set a monthly cron "
"against POST /remission/run).",
priority="optional",
section="section-remission",
)
)
if not steps:
steps.append(
_step(
"monitor",
"Monitor Outstanding Target",
"No urgent action. Watch Development Credit and Remission Credit "
"until Outstanding Target reaches zero and conversion fires.",
priority="done_hint",
)
)
return steps
def phase_lifecycle_checklist(
*,
ledger_len: int,
metrics: dict[str, Any],
has_attestation: bool,
) -> list[dict[str, Any]]:
"""Visual checklist of lifecycle milestones for a Phase."""
facts = metrics.get("facts") or {}
converted = bool(facts.get("is_converted"))
has_dev = float(facts.get("cumulative_development_credit") or 0) > 0
has_rem = float(facts.get("cumulative_remission_credit") or 0) > 0
return [
{"id": "declared", "label": "Phase declared", "done": True},
{
"id": "activity",
"label": "Ledger activity started",
"done": ledger_len > 0,
},
{
"id": "dev_credit",
"label": "Development Credit recorded",
"done": has_dev,
},
{
"id": "remission",
"label": "Remission Credit recorded",
"done": has_rem,
},
{
"id": "converted",
"label": "Converted (Outstanding Target = 0)",
"done": converted,
},
{
"id": "attestation",
"label": "Conversion Attestation published",
"done": has_attestation,
},
]

View file

@ -0,0 +1,234 @@
/* Control Plane guidance + bubble help (native CSS; no Lit required). */
/* ---- Bubble help (? popovers) ---- */
.cp-help {
position: relative;
display: inline-flex;
align-items: center;
vertical-align: middle;
margin-left: 0.3rem;
}
.cp-help__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
border: 1px solid #888;
background: #f4f4f4;
color: #444;
font: 600 0.7rem/1 system-ui, sans-serif;
cursor: help;
padding: 0;
}
.cp-help__btn:hover,
.cp-help__btn:focus {
background: #1a1a1a;
color: #fff;
border-color: #1a1a1a;
outline: none;
}
.cp-help__bubble {
display: none;
position: absolute;
z-index: 40;
left: 50%;
bottom: calc(100% + 0.45rem);
transform: translateX(-50%);
width: min(18rem, 70vw);
padding: 0.65rem 0.75rem;
background: #1a1a1a;
color: #f5f5f5;
border-radius: 8px;
font: 400 0.8rem/1.35 system-ui, sans-serif;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
text-align: left;
white-space: normal;
}
.cp-help__bubble::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #1a1a1a;
}
.cp-help:hover .cp-help__bubble,
.cp-help:focus-within .cp-help__bubble {
display: block;
}
.cp-help__bubble a { color: #9ecbff; }
/* ---- Next-step guide card ---- */
.cp-guide {
border: 1px solid #d0d7de;
border-radius: 10px;
background: linear-gradient(180deg, #f8fafc 0%, #fff 48%);
padding: 1rem 1.15rem 1.1rem;
margin: 0 0 1.5rem;
}
.cp-guide__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.cp-guide__title {
margin: 0;
font-size: 1.05rem;
font-weight: 650;
}
.cp-guide__eyebrow {
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #666;
font-weight: 600;
}
.cp-guide__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.cp-guide__item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.65rem 0.85rem;
align-items: start;
padding: 0.7rem 0.8rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #fff;
}
.cp-guide__item--recommended {
border-color: #93c5fd;
background: #eff6ff;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
}
.cp-guide__item--optional {
opacity: 0.95;
}
.cp-guide__item--done_hint {
border-color: #bbf7d0;
background: #f0fdf4;
}
.cp-guide__badge {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.2rem 0.4rem;
border-radius: 999px;
background: #e5e7eb;
color: #374151;
white-space: nowrap;
}
.cp-guide__item--recommended .cp-guide__badge {
background: #2563eb;
color: #fff;
}
.cp-guide__item--done_hint .cp-guide__badge {
background: #16a34a;
color: #fff;
}
.cp-guide__item-title {
margin: 0 0 0.2rem;
font-weight: 650;
font-size: 0.95rem;
}
.cp-guide__item-body {
margin: 0;
color: #444;
font-size: 0.88rem;
line-height: 1.4;
}
.cp-guide__cta {
display: inline-block;
align-self: center;
padding: 0.4rem 0.7rem;
border-radius: 6px;
background: #1a1a1a;
color: #fff !important;
text-decoration: none;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.cp-guide__item--recommended .cp-guide__cta {
background: #2563eb;
}
.cp-guide__cta:hover { filter: brightness(1.08); }
/* ---- Lifecycle checklist ---- */
.cp-checklist {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0 0 1.25rem;
padding: 0;
list-style: none;
}
.cp-checklist__item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.55rem;
border-radius: 999px;
border: 1px solid #ddd;
font-size: 0.78rem;
color: #555;
background: #fafafa;
}
.cp-checklist__item--done {
border-color: #86efac;
background: #f0fdf4;
color: #166534;
font-weight: 600;
}
.cp-checklist__dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #ccc;
}
.cp-checklist__item--done .cp-checklist__dot {
background: #16a34a;
}
/* ---- Highlight target sections ---- */
.cp-section-highlight {
animation: cp-pulse 1.6s ease-out 1;
outline: 2px solid #93c5fd;
outline-offset: 4px;
border-radius: 6px;
}
@keyframes cp-pulse {
0% { outline-color: #2563eb; background-color: #eff6ff; }
100% { outline-color: #93c5fd; background-color: transparent; }
}
/* Field label row with help */
.cp-label-row {
display: flex;
align-items: center;
gap: 0.15rem;
}
.cp-field-hint {
margin: 0.15rem 0 0;
font-size: 0.78rem;
color: #666;
line-height: 1.35;
}
@media (max-width: 640px) {
.cp-guide__item {
grid-template-columns: 1fr;
}
.cp-guide__cta { justify-self: start; }
}

88
tests/test_guidance.py Normal file
View file

@ -0,0 +1,88 @@
"""Unit tests for Control Plane next-step guidance."""
from target_revenue.service import guidance
def test_dashboard_empty_operator_recommends_register():
steps = guidance.dashboard_guidance(
rights="operator",
phase_count=0,
pending_proposals=0,
root_path="/ui",
)
assert steps[0]["key"] == "register_phase"
assert steps[0]["priority"] == "recommended"
assert steps[0]["href"] == "/ui/phases/new"
def test_dashboard_with_phase_opens_first():
steps = guidance.dashboard_guidance(
rights="viewer",
phase_count=1,
pending_proposals=0,
root_path="/ui",
first_phase_id="trsl:phase:demo",
)
assert steps[0]["key"] == "open_phase"
assert "trsl:phase:demo" in steps[0]["href"]
def test_phase_empty_ledger_recommends_credit_or_remission():
metrics = {
"facts": {
"is_converted": False,
"cumulative_development_credit": 0,
"cumulative_remission_credit": 0,
"initial_target_currency": "EUR",
},
"forecasts": {"remission_if_applied_now": 12.5, "next_scheduled_remission_at": None},
}
steps = guidance.phase_guidance(
rights="operator",
root_path="/ui",
phase_id="trsl:phase:demo",
metrics=metrics,
ledger_len=0,
extension_count=1,
has_attestation=False,
)
keys = [s["key"] for s in steps]
assert "apply_remission" in keys
assert keys[0] == "apply_remission" # material remission first
def test_phase_converted_done_hint():
metrics = {
"facts": {"is_converted": True, "cumulative_development_credit": 1, "cumulative_remission_credit": 0},
"forecasts": {},
}
steps = guidance.phase_guidance(
rights="operator",
root_path="/ui",
phase_id="trsl:phase:demo",
metrics=metrics,
ledger_len=2,
extension_count=1,
has_attestation=True,
)
assert steps[0]["key"] == "converted"
assert steps[0]["priority"] == "done_hint"
def test_lifecycle_checklist_progress():
metrics = {
"facts": {
"is_converted": False,
"cumulative_development_credit": 10,
"cumulative_remission_credit": 0,
}
}
items = guidance.phase_lifecycle_checklist(
ledger_len=1, metrics=metrics, has_attestation=False
)
by_id = {i["id"]: i["done"] for i in items}
assert by_id["declared"] is True
assert by_id["activity"] is True
assert by_id["dev_credit"] is True
assert by_id["remission"] is False
assert by_id["converted"] is False