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:
parent
8cf124530c
commit
f1109d54ee
14 changed files with 1031 additions and 65 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 @@
|
|||
· <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)
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 %}
|
||||
|
|
|
|||
307
src/target_revenue/service/guidance.py
Normal file
307
src/target_revenue/service/guidance.py
Normal 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,
|
||||
},
|
||||
]
|
||||
234
src/target_revenue/service/static/control-plane.css
Normal file
234
src/target_revenue/service/static/control-plane.css
Normal 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
88
tests/test_guidance.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue