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 fsGroup: 10001
containers: containers:
- name: bootstrap - name: bootstrap
image: forgejo.coulomb.social/coulomb/target-revenue:0.1.3 image: forgejo.coulomb.social/coulomb/target-revenue:0.1.4
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
env: env:
- name: TRF_BOOTSTRAP_BINKY - name: TRF_BOOTSTRAP_BINKY

View file

@ -25,7 +25,7 @@ spec:
fsGroup: 10001 fsGroup: 10001
containers: containers:
- name: target-revenue - 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 imagePullPolicy: IfNotPresent
ports: ports:
- name: http - name: http

View file

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

View file

@ -26,7 +26,7 @@ from psycopg_pool import ConnectionPool
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from .. import breach_record, control_plane, ledger, metrics, registry 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") _STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates") _TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
@ -201,8 +201,27 @@ def dashboard(
conn: Connection = Depends(get_connection), conn: Connection = Depends(get_connection),
): ):
phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id) 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( 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( phase_attestation = control_plane.get_or_publish_attestation(
conn, phase_id, signing_key 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( return templates.TemplateResponse(
request, request,
"phase_detail.html", "phase_detail.html",
@ -302,6 +342,8 @@ def phase_detail(
metrics=computed_metrics, metrics=computed_metrics,
breaches=breaches, breaches=breaches,
attestation=phase_attestation, attestation=phase_attestation,
next_steps=next_steps,
checklist=checklist,
), ),
) )

View file

@ -13,6 +13,7 @@
} }
</script> </script>
<script type="module" src="{{ root_path }}/static/whynot-design/index.js"></script> <script type="module" src="{{ root_path }}/static/whynot-design/index.js"></script>
<link rel="stylesheet" href="{{ root_path }}/static/control-plane.css">
<style> <style>
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; font-family: system-ui, sans-serif; } 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; } 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 { 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; 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; padding: 0.55rem 1rem; font-size: 1rem; cursor: pointer;
background: #1a1a1a; color: #fff; border: none; border-radius: 4px; width: fit-content; 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> </script>
</body> </body>

View file

@ -1,24 +1,54 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Dashboard — Target Revenue Control Plane{% endblock %} {% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
{% block content %} {% block content %}
<wn-page-header> <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> </wn-page-header>
{{ render_next_steps(next_steps, title='Recommended next step') }}
{% if session_rights in ("operator", "admin") %} {% 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 %} {% endif %}
{% if phases %} {% if phases %}
<table class="wn-plain"> <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> <tbody>
{% for phase in phases %} {% for phase in phases %}
<tr> <tr>
<td>{{ phase.phase.id }}</td> <td>{{ phase.phase.id }}</td>
<td>{{ phase.phase.milestone_release.name }}</td> <td>{{ phase.phase.milestone_release.name }}</td>
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -1,14 +1,34 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Extensions — Target Revenue Control Plane{% endblock %} {% block title %}Extensions — Target Revenue Control Plane{% endblock %}
{% block content %} {% block content %}
<wn-page-header> <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> </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 Rights: <strong>Operator+</strong> may register; <strong>Admin</strong> may
promote to <code>canonical</code> (governance action, never automated). promote to <code>canonical</code> (governance action, never automated).
See <code>specs/TargetRevenueControlPlaneConcept.md</code> §2.
</p> </p>
{% if extensions %} {% if extensions %}
@ -52,6 +72,7 @@
{% endif %} {% endif %}
{% if session_rights in ("operator", "admin") %} {% if session_rights in ("operator", "admin") %}
<div id="ext-form">
<h3>Register a new extension</h3> <h3>Register a new extension</h3>
<form class="wn-form" method="post" action="{{ root_path }}/extensions"> <form class="wn-form" method="post" action="{{ root_path }}/extensions">
<label>Extension id (trsl:extension:...) <label>Extension id (trsl:extension:...)
@ -83,5 +104,6 @@
</label> </label>
<button type="submit">Register extension</button> <button type="submit">Register extension</button>
</form> </form>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,16 +1,51 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Sign in — Target Revenue Control Plane{% endblock %} {% block title %}Sign in — Target Revenue Control Plane{% endblock %}
{% block content %} {% block content %}
<wn-page-header> <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> </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"> <form class="wn-form" method="post" action="{{ root_path }}/login">
<label> <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"> <input type="password" name="token" required autocomplete="current-password">
</label> </label>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form> </form>
</div>
{% endblock %} {% 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" %} {% 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 title %}{{ manifest.phase.id }} — Target Revenue Control Plane{% endblock %}
{% block content %} {% block content %}
<wn-page-header> <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> </wn-page-header>
<p><wn-tag>{{ manifest.phase.id }}</wn-tag></p> <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;"> <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> <p>
Raw reference: <code>{{ manifest.phase.ledger }}</code> Raw reference: <code>{{ manifest.phase.ledger }}</code>
{% if manifest.phase.ledger.startswith('/phases/') %} {% if manifest.phase.ledger.startswith('/phases/') %}
@ -16,12 +32,30 @@
</p> </p>
</details> </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"> <table class="wn-plain">
<tr><td>Initial Target</td><td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td></tr> <tr>
<tr><td>Cumulative Development Credit</td><td>{{ metrics.facts.cumulative_development_credit }}</td></tr> <td>Initial Target {{ help_bubble("Immutable commercial ceiling declared at Phase registration.") }}</td>
<tr><td>Cumulative Remission Credit</td><td>{{ metrics.facts.cumulative_remission_credit }}</td></tr> <td>{{ manifest.phase.initial_target.amount }} {{ manifest.phase.initial_target.currency }}</td>
<tr><td>Outstanding Target</td><td>{{ metrics.facts.outstanding_target }}</td></tr> </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>Target satisfaction</td><td>{{ metrics.calculations.target_satisfaction_percentage }}%</td></tr>
<tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr> <tr><td>Converted</td><td>{{ metrics.facts.is_converted }}</td></tr>
<tr> <tr>
@ -31,11 +65,24 @@
&middot; <a href="{{ root_path }}/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a> &middot; <a href="{{ root_path }}/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
</td> </td>
</tr> </tr>
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr> <tr>
<tr><td>Activated (t0)</td><td>{{ metrics.facts.activated_at or "—" }}</td></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> </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"> <table class="wn-plain">
<tr> <tr>
<td>If applied now</td> <td>If applied now</td>
@ -65,14 +112,21 @@
{% if session_rights in ("operator", "admin") %} {% 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;"> <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> <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> Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
(idempotent — no double-remit if already current). (idempotent — no double-remit if already current).
</p> </p>
</form> </form>
{% endif %} {% 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 %} {% if ledger %}
<table class="wn-plain"> <table class="wn-plain">
<thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead> <thead><tr><th>id</th><th>type</th><th>amount</th><th>recognized_at</th></tr></thead>
@ -87,38 +141,56 @@
{% endif %} {% endif %}
{% if session_rights in ("contributor", "operator", "admin") %} {% if session_rights in ("contributor", "operator", "admin") %}
<div id="section-ledger-form">
<h3> <h3>
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %} {% 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> </h3>
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/ledger"> <form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/ledger">
<label>Entry id (trsl:entry:...) <label>
<input name="entry_id" required> <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>
<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> <input type="number" step="0.01" name="amount" required>
</label> </label>
<label>Currency <label>Currency
<input name="currency" value="{{ manifest.phase.initial_target.currency }}" required> <input name="currency" value="{{ manifest.phase.initial_target.currency }}" required>
</label> </label>
<label>Recognized at (ISO 8601) <label>
<input name="recognized_at" required> <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>
<label>Evidence reference <label>
<input name="evidence_reference" required> <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>
<label>Extension id (trsl:extension:...) <label>
<input name="extension_id" required> <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>
<label>Extension version <label>Extension version
<input name="extension_version" required> <input name="extension_version" required value="1.0">
</label> </label>
<button type="submit"> <button type="submit">
{% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %} {% if session_rights == "contributor" %}Submit for review{% else %}Append{% endif %}
</button> </button>
</form> </form>
</div>
{% endif %} {% 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 %} {% if attestation %}
<table class="wn-plain"> <table class="wn-plain">
<tr><td>Phase</td><td>{{ attestation.phase }}</td></tr> <tr><td>Phase</td><td>{{ attestation.phase }}</td></tr>
@ -136,8 +208,15 @@
{% else %} {% else %}
<wn-empty-state>Not converted — no attestation yet.</wn-empty-state> <wn-empty-state>Not converted — no attestation yet.</wn-empty-state>
{% endif %} {% 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 %} {% if breaches %}
<table class="wn-plain"> <table class="wn-plain">
<thead> <thead>
@ -175,10 +254,9 @@
{% if session_rights in ("operator", "admin") %} {% if session_rights in ("operator", "admin") %}
<h3>Publish a breach/compliance event</h3> <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 Anonymized by default (Phase + category only). Named disclosure requires
an affirmative CUA authorization check (License V1C1 §7.4) — this form an affirmative CUA authorization check.
records that assertion; it does not verify the CUA text.
</p> </p>
<form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/breach"> <form class="wn-form" method="post" action="{{ root_path }}/phases/{{ manifest.phase.id }}/breach">
<label>Record id (unique) <label>Record id (unique)

View file

@ -1,59 +1,121 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import help_bubble, render_next_steps %}
{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %} {% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
{% block content %} {% block content %}
<wn-page-header> <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> </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. This registers a real Phase Manifest against the hosted Trust Service.
Further Phases beyond the T05 pilot still need an explicit declaration act. Further Phases beyond the T05 pilot still need an explicit declaration act.
</p> </p>
<form class="wn-form" method="post" action="{{ root_path }}/phases/new"> <form class="wn-form" method="post" action="{{ root_path }}/phases/new">
<label>Phase id (trsl:phase:...) <div id="step-identity">
<input name="phase_id" required> <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>
<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> <input name="milestone_release_name" required>
</label> </label>
<label>Source revision <label>
<input name="source_revision" required> <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>
<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> <input name="repo_hub" value="forgejo-coulomb" required>
</label> </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> <input name="repo_hub_uri" value="https://forgejo.coulomb.social" required>
</label> </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> <input type="number" name="repo_id" required>
</label> </label>
<label>Repo name (owner/repo) <label>
<input name="repo_name" required> <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>
<label>Base Phase id (optional — only for a successive Phase) <label>
<input name="base_phase_id"> <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>
<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> <input type="number" step="0.01" name="initial_target_amount" required>
</label> </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> <input name="currency" value="EUR" required>
</label> </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"> <select name="future_license">
<option value="MIT">MIT</option> <option value="MIT">MIT</option>
<option value="Apache-2.0">Apache-2.0</option> <option value="Apache-2.0">Apache-2.0</option>
</select> </select>
</label> </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> <input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required>
</label> </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> <a href="{{ root_path }}/reference/policies/linear-longstop-v0">view the linear-longstop-v0 spec</a>
</p> </p>
<label>Longstop date (ISO 8601) <label>
<input name="longstop_at" required> <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> </label>
</div>
<button type="submit">Register Phase</button> <button type="submit">Register Phase</button>
</form> </form>
{% endblock %} {% 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