Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6bf93dd
fix(c4-zoom): re-read the C4 catalog when its files change on disk
JSdotNet Nov 9, 2026
656e150
feat(devbook): show a TDR's decision chain and include guideline ADRs
JSdotNet Nov 9, 2026
d92bec4
feat(devbook): give the Code level its debt, reference and scale
JSdotNet Nov 9, 2026
d282d73
feat(c4-zoom): frame every deployment node, set bands beside the syst…
JSdotNet Nov 9, 2026
c1a4e60
feat(c4-zoom): finish the element inspector against the design
JSdotNet Nov 9, 2026
ce1986f
feat(c4-zoom): surface polish - overlay row, view summary, log filter…
JSdotNet Nov 9, 2026
861108d
fix(c4-zoom): keep Play's walk under reduced motion and follow a mid-…
JSdotNet Nov 9, 2026
1ceda06
Merge branch 'claude/c4-gap-s21' into claude/c4-zoom-gaps
JSdotNet Nov 9, 2026
e916f56
Merge branch 'claude/c4-gap-s22' into claude/c4-zoom-gaps
JSdotNet Nov 9, 2026
6556995
Merge branch 'claude/c4-gap-s24' into claude/c4-zoom-gaps
JSdotNet Nov 9, 2026
b459a49
Merge branch 'claude/c4-gap-s26' into claude/c4-zoom-gaps
JSdotNet Nov 9, 2026
f17a5cd
Merge branch 'claude/c4-gap-sui' into claude/c4-zoom-gaps
JSdotNet Nov 9, 2026
4f592dc
fix(c4-zoom): stamp the catalog's inputs from Backlog.Infrastructure.…
JSdotNet Nov 9, 2026
953c226
fix(c4-zoom): close the review findings on the zoom surface
JSdotNet Nov 9, 2026
93b70fd
chore: leave features.md to the devbook change
JSdotNet Nov 9, 2026
c2b8064
fix(c4-zoom): keep Escape on the zoom surface and close the round-2 gaps
JSdotNet Nov 9, 2026
b0a728c
Merge remote-tracking branch 'origin/main' into claude/c4-zoom-gaps
JSdotNet Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/App/Backlog.Desktop.UI/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -8614,6 +8614,15 @@ a.pull-request-detail-checks__name:hover {
color: inherit;
}

/* A guideline ADR numbers from 0001 as a local one does: the word before its number
tells the two apart without relying on colour. */
.devbook-record-model__scope {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}

.devbook-record-model__citation {
border: 0;
color: inherit;
Expand Down Expand Up @@ -11779,6 +11788,24 @@ a.pull-request-detail-checks__name:hover {
text-transform: uppercase;
}

/* The decision log's status filter: All, Accepted, Proposed, one chosen — the
library's radio ButtonGroup at its small size, placed in line with the list
under it. The rail is narrower than three labelled counts at the default size,
so the group takes the rail's width and its segments share it; a rail too
narrow even for that wraps the last segment rather than clipping it. */
.arc42-surface-nav__filter {
display: flex;
flex-wrap: wrap;
max-width: calc(100% - 2 * var(--spacing-sm));
margin: 0 var(--spacing-sm) var(--spacing-xs);
}

.arc42-surface-nav__filter > .btn {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
}

.arc42-surface-nav__item {
display: flex;
align-items: baseline;
Expand Down
32 changes: 27 additions & 5 deletions src/Core/Backlog.UI.Components/Diagrams/C4/C4CodeLevel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ namespace Backlog.UI.Components.Diagrams.C4;

/// <summary>
/// What the Code level of the C4 zoom tab draws for one component: the types its code
/// folders declare, the uses between them, and the decision records linked to the
/// component.
/// folders declare, the uses between them, and the decision and debt records linked to
/// the component.
/// <para>
/// The component library knows neither the C# reader nor the devbook, so this is the
/// picture already put into words — a stereotype, a name, member lines — and the host
Expand All @@ -26,6 +26,10 @@ public sealed record C4CodeLevel(
/// <summary>Why the level could not be read — a folder outside the repository, a
/// source that would not open — or null when it was.</summary>
public string? Problem { get; init; }

/// <summary>The debt records linked to the component: what the type panel falls back
/// on for a type no debt record names.</summary>
public IReadOnlyList<C4CodeRecord> Debt { get; init; } = [];
}

/// <summary>One class box.</summary>
Expand All @@ -44,15 +48,32 @@ public sealed record C4CodeType(
string Stereotype,
IReadOnlyList<string> FilePaths,
IReadOnlyList<string> PublicMembers,
IReadOnlyList<string> AllMembers);
IReadOnlyList<string> AllMembers)
{
/// <summary>The debt records that name this type, a subset of the level's
/// <see cref="C4CodeLevel.Debt"/>.</summary>
public IReadOnlyList<C4CodeRecord> Debt { get; init; } = [];

/// <summary>The reference this type is held to, or null when it is held to
/// none.</summary>
public C4CodeReference? Reference { get; init; }
}

/// <summary>A reference implementation a type is held to, and the test that holds it
/// there: <c>tools/devbook/build-database.mjs</c>, compared in
/// <c>DevbookBuilderParityTests</c>.</summary>
/// <param name="Source">The reference, as the repository spells its path.</param>
/// <param name="HeldBy">The test that compares the type with it.</param>
public sealed record C4CodeReference(string Source, string HeldBy);

/// <summary>One type naming another: drawn as an arrow from <paramref name="From"/> to
/// <paramref name="To"/>.</summary>
/// <param name="Inherits">Whether it is named in the base list — drawn dashed and
/// labelled "inherits" — rather than anywhere else.</param>
public sealed record C4CodeUse(string From, string To, bool Inherits);

/// <summary>A record linked to the component, as the type panel lists it.</summary>
/// <summary>A decision or debt record linked to the component, as the type panel lists
/// it.</summary>
/// <param name="Number">Its number, "0004", or empty when the file names none.</param>
/// <param name="Title">Its title with the number and prefix taken off.</param>
/// <param name="Path">The chapter file, spelled as the chapter catalogs spell it; what
Expand All @@ -78,6 +99,7 @@ public sealed record C4ZoomCode(
/// open-folder button does.</summary>
public Func<string, Task<string?>>? OpenInEditor { get; init; }

/// <summary>Opens a linked record's chapter. Null leaves the records as text.</summary>
/// <summary>Opens a linked decision or debt record's chapter. Null leaves the records
/// as text.</summary>
public Func<C4CodeRecord, Task>? OpenRecord { get; init; }
}
128 changes: 97 additions & 31 deletions src/Core/Backlog.UI.Components/Diagrams/C4/C4CodeView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
or all of them — and an arrow per use, laid out by C4LayoutEngine through
C4CodeLayout so a use reads top to bottom as a relationship does a level up. Beside
it the type panel: the selected type's files, "Open in editor" where the host can
open one, and the decision records linked to the component.
open one, the reference it is held to where it has one, the decision records linked
to the component, and its technical debt — the records that name the type, else the
component's. Over the picture, the − / Fit / + the other levels have.

Its own component rather than a branch of C4ZoomExplorer: the explorer decides when
the level is open, and this draws it, so either can change without the other.
Expand Down Expand Up @@ -43,10 +45,18 @@

@if (Level is { Problem: null, Types.Count: > 0 })
{
<ButtonGroup Radio="true" AriaLabel="Members" CssClass="c4-code__members" TestId="c4-code-members">
<ToggleButton Pressed="@(!_allMembers)" PressedChanged="_ => ShowAll(false)" TestId="c4-code-members-public">Public</ToggleButton>
<ToggleButton Pressed="@_allMembers" PressedChanged="_ => ShowAll(true)" TestId="c4-code-members-all">All</ToggleButton>
</ButtonGroup>
<div class="c4-code__tools">
<ButtonGroup Radio="true" AriaLabel="Members" CssClass="c4-code__members" TestId="c4-code-members">
<ToggleButton Pressed="@(!_allMembers)" PressedChanged="_ => ShowAll(false)" TestId="c4-code-members-public">Public</ToggleButton>
<ToggleButton Pressed="@_allMembers" PressedChanged="_ => ShowAll(true)" TestId="c4-code-members-all">All</ToggleButton>
</ButtonGroup>

@* The − / Fit / + every other level has, sizing the class boxes. *@
<C4ZoomScale @bind-Index="_scale"
FitTitle="Draw the types at their own size"
CssClass="c4-code__scale"
TestId="c4-code-scale" />
</div>
}
</header>

Expand Down Expand Up @@ -74,7 +84,9 @@
role="group"
aria-label="@($"Types of {Level.Component}")"
data-testid="c4-code-frame">
<div class="c4-code__canvas" style="@Size(Diagram.Width, Diagram.Height)">
@* zoom rather than a transform, so the frame scrolls over the sized
picture and not over the space it took before. *@
<div class="c4-code__canvas" style="@CanvasStyle" data-scale="@C4ZoomScale.Percent(_scale)">
<svg class="c4-code__uses"
width="@Number(Diagram.Width)"
height="@Number(Diagram.Height)"
Expand Down Expand Up @@ -156,6 +168,14 @@
}
</div>

@if (type.Reference is { } reference)
{
<section class="c4-code__records" data-testid="c4-code-reference">
<h4 class="c4-code__records-title">Held to a reference</h4>
<p class="c4-code__note c4-code__note--body"><code class="c4-code__folder">@reference.Source</code>, compared with it in <code class="c4-code__folder">@reference.HeldBy</code>.</p>
</section>
}

<section class="c4-code__records" data-testid="c4-code-decisions">
<h4 class="c4-code__records-title">Decisions it implements · @Level.Decisions.Count</h4>
@if (Level.Decisions.Count == 0)
Expand All @@ -164,31 +184,26 @@
}
else
{
<ul class="c4-code__record-list">
@foreach (var record in Level.Decisions)
{
<li>
@if (OpenRecord is not null)
{
<AppButton BaseClass="c4-code__record"
Variant="ButtonVariant.None"
title="@record.Path"
TestId="c4-code-decision"
OnClick="() => OpenRecord(record)">
<span class="c4-code__record-number">@record.Number</span>
<span class="c4-code__record-title">@record.Title</span>
</AppButton>
}
else
{
<span class="c4-code__record" title="@record.Path" data-testid="c4-code-decision">
<span class="c4-code__record-number">@record.Number</span>
<span class="c4-code__record-title">@record.Title</span>
</span>
}
</li>
}
</ul>
@Records(Level.Decisions, "c4-code-decision")
}
</section>

@* The type's own debt, else the component's: a debt record names a type
by its name, and one that names none is about the component. *@
<section class="c4-code__records" data-testid="c4-code-debt">
<h4 class="c4-code__records-title">Technical debt</h4>
@if (type.Debt.Count > 0)
{
@Records(type.Debt, "c4-code-debt-record")
}
else if (Level.Debt.Count > 0)
{
<p class="c4-code__note" data-testid="c4-code-debt-fallback">No TDR names @type.Name. Its component, @Level.Component, carries @Count(Level.Debt.Count, "TDR", "TDRs"):</p>
@Records(Level.Debt, "c4-code-debt-record")
}
else
{
<p class="c4-code__note">No TDR names @type.Name or @Level.Component.</p>
}
</section>
</aside>
Expand All @@ -210,6 +225,20 @@
private bool _opening;
private string? _openError;

private int _scale = C4ZoomScale.FitIndex;

/// <summary>The component <see cref="_scale"/> was set on: another component's
/// level opens at its own size, as every view the explorer opens does.</summary>
private string? _scaleFor;

protected override void OnParametersSet()
{
if (Level is null || string.Equals(_scaleFor, Level.Component, StringComparison.Ordinal)) return;

_scaleFor = Level.Component;
_scale = C4ZoomScale.FitIndex;
}

private C4CodeDiagram _diagram = C4CodeDiagram.Empty;
private C4CodeLevel? _diagramLevel;
private bool _diagramAll;
Expand Down Expand Up @@ -314,6 +343,43 @@
private Task OnKeyDownAsync(KeyboardEventArgs args) =>
string.Equals(args.Key, "Backspace", StringComparison.Ordinal) ? OnZoomOut.InvokeAsync() : Task.CompletedTask;

/// <summary>A list of records, each opening its chapter where the host can open
/// one and plain text where it cannot.</summary>
private RenderFragment Records(IReadOnlyList<C4CodeRecord> records, string testId) => @<ul class="c4-code__record-list">
@foreach (var record in records)
{
<li @key="record.Path">
@if (OpenRecord is not null)
{
<AppButton BaseClass="c4-code__record"
Variant="ButtonVariant.None"
title="@record.Path"
TestId="@testId"
OnClick="() => OpenRecord(record)">
<span class="c4-code__record-number">@record.Number</span>
<span class="c4-code__record-title">@record.Title</span>
</AppButton>
}
else
{
<span class="c4-code__record" title="@record.Path" data-testid="@testId">
<span class="c4-code__record-number">@record.Number</span>
<span class="c4-code__record-title">@record.Title</span>
</span>
}
</li>
}
</ul>;

/// <summary>The canvas at the picture's size, drawn at the step − / Fit / + chose.</summary>
private string CanvasStyle =>
_scale == C4ZoomScale.FitIndex
? Size(Diagram.Width, Diagram.Height)
: $"{Size(Diagram.Width, Diagram.Height)}; zoom: {C4ZoomScale.Factor(_scale)}";

private static string Count(int count, string one, string many) =>
count == 1 ? $"1 {one}" : $"{count.ToString(CultureInfo.InvariantCulture)} {many}";

private static string TypeCount(int count) => count == 1 ? "1 type" : $"{count.ToString(CultureInfo.InvariantCulture)} types";

private static string Number(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
Expand Down
68 changes: 61 additions & 7 deletions src/Core/Backlog.UI.Components/Diagrams/C4/C4DynamicSteps.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@

Previous is disabled on the first step and Next on the last. Play walks forward
from the current step and stops on the last; pressed on the last it starts again
from the first. With reduced motion asked for, nothing advances on its own: Play
moves one step per press, and the panel says so. The current step is announced
from the first. With reduced motion asked for, Play still walks every step on
its own — reduced motion takes the animation away, never the function — and the
panel says the steps change without animation. The current step is announced
whole — its ends and what it does — not only its number.

Under the steps, "Decisions on this path": the decisions that name an element the
flow passes through, each a way to open it, a proposed one marked — the design's
Dynamic screen. The explorer works them out from the host's records; with none
handed the section is left out rather than claiming there are none.
*@

<aside class="c4-dynamic-steps" aria-label="Steps" data-testid="@TestId">
Expand Down Expand Up @@ -55,7 +61,7 @@

@if (ReducedMotion)
{
<p class="c4-dynamic-steps__note" data-testid="c4-dynamic-reduced-motion">Reduced motion is on, so Play moves one step per press.</p>
<p class="c4-dynamic-steps__note" data-testid="c4-dynamic-reduced-motion">Reduced motion is on, so the steps change without animation.</p>
}

<ol class="c4-dynamic-steps__list">
Expand All @@ -81,9 +87,60 @@
</li>
}
</ol>

@if (Decisions is { } decisions)
{
<section class="c4-dynamic-steps__section" aria-labelledby="@($"{_id}-decisions")" data-testid="c4-dynamic-decisions">
<h3 class="c4-dynamic-steps__heading" id="@($"{_id}-decisions")">Decisions on this path</h3>

@if (decisions.Count == 0)
{
<p class="c4-dynamic-steps__none" data-testid="c4-dynamic-decisions-none">No decision names an element on this path.</p>
}
else
{
<ul class="c4-dynamic-steps__decisions">
@foreach (var record in decisions)
{
<li @key="record.Path">
<AppButton BaseClass="c4-dynamic-steps__decision"
Variant="ButtonVariant.None"
title="@record.Title"
aria-label="@($"{(record.Guideline ? "Guideline ADR" : "ADR")} {record.Number} {record.Title}{(record.Proposed ? ", proposed" : string.Empty)}")"
data-path="@record.Path"
data-scope="@(record.Guideline ? "guideline" : "local")"
TestId="c4-dynamic-decision"
OnClick="() => OnOpenRecord.InvokeAsync(record.Path)">
@if (record.Guideline)
{
<span class="c4-dynamic-steps__decision-scope" data-testid="c4-dynamic-decision-guideline">guideline</span>
}
<span class="c4-dynamic-steps__decision-number">@(record.Number ?? record.Title)</span>
@if (record.Proposed)
{
<Badge Kind="status" Slug="proposed" CssClass="c4-dynamic-steps__decision-chip" TestId="c4-dynamic-decision-proposed">proposed</Badge>
}
</AppButton>
</li>
}
</ul>
}
</section>
}
</aside>

@code {
private readonly string _id = "c4-dynamic-steps-" + Guid.NewGuid().ToString("N");

/// <summary>The decisions that name an element on the path, or null when the host
/// has no records to say, which leaves the section out.</summary>
[Parameter]
public IReadOnlyList<C4InspectorRecord>? Decisions { get; set; }

/// <summary>A decision on the path was pressed, by its path.</summary>
[Parameter]
public EventCallback<string> OnOpenRecord { get; set; }

/// <summary>The steps, in the order they play — <see cref="C4DynamicPlayback.Steps"/>.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<C4PlaybackStep> Steps { get; set; } = [];
Expand Down Expand Up @@ -116,10 +173,7 @@
[Parameter]
public string? TestId { get; set; } = "c4-dynamic-steps";

private string PlayLabel =>
Playing ? "Pause"
: ReducedMotion ? "Play the next step"
: "Play all steps";
private string PlayLabel => Playing ? "Pause" : "Play all steps";

/// <summary>The description, then the technology, the way the design writes them:
/// "Creates the issue · HTTPS".</summary>
Expand Down
Loading
Loading