Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ c3.php
# composer
composer.lock

# local notes and scaffold drafts (if present)
examples/
internal/

# gitHub copilot config (if present)
.github/agents/**
.github/copilot-instructions.md
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.2.1 Under development
## 0.3.0 Under development

- refactor(panel)!: remove activity API, document provider defaults, and update README cache/configuration registration.

## 0.2.0 September 15, 2026

Expand Down
57 changes: 36 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,7 @@ final class CachePanel extends Panel

$view = PanelView::create()
->summary(count($operations) === 1 ? ' operation' : ' operations', count($operations))
->toolbar('Cache', count($operations))
->active($operations !== []);
->toolbar('Cache', count($operations));

return $operations === []
? $view->emptyState('No cache operations', 'The cache was observed, but nothing happened.')
Expand All @@ -120,36 +119,53 @@ host encodes that array strictly, so omit secrets and keep values JSON-encodable

## Register it

Merge these fragments into an application whose debugger is already enabled. The collector's `id()` and the panel's
`ID` must match: that is how the host pairs a capture with its panel.
Merge these fragments into an application whose debugger is already enabled. The array key is the stable ID: it must
equal the collector's `id()` and the panel's `ID`, and both hosts reject a mismatch. `TITLE` and `ICON` are only the
defaults the panel ships with, so the host configuration may override both without touching the class.

```php
// Yii2: inside the YII_DEBUG guard. Declare 'modules' => [] in the application configuration so the offset stays
// typed under PHPStan level max; the guard then only fills in the debug entry.
$config['modules']['debug'] = [
'class' => DebugModule::class,
'collectors' => ['cache-operations' => new CacheCollector()],
'panels' => ['cache-operations' => new CachePanel()],
'collectors' => ['cache' => $cacheCollector],
'panels' => [
'cache' => ['class' => CachePanel::class, 'title' => 'Cache operations', 'icon' => 'db', 'position' => 1],
],
];
```

```php
// Yii3: return the extended registry from the application's development DI factory.
$collector = new CacheCollector();
A plain `CachePanel::class` string or `new CachePanel()` registers the panel with the provider defaults. Inject the
same `$cacheCollector` instance into the application service that calls `record()`.

Yii3 reads the same shape from the application configuration, not from a registry object.

$registry = $registry
->withCollector($collector)
->withPanel(new CachePanel());
```php
// config/web/params.php
'yii3/debug' => [
'collectors' => ['cache' => CacheCollector::class],
'panels' => [
'cache' => ['class' => CachePanel::class, 'title' => 'Cache operations', 'icon' => 'db', 'position' => 1],
],
],
```

Both hosts derive the IDs from the objects themselves. `CollectorInterface` is the only collector contract the
debugger has, so the collector is registered as it is, and only the panel is adapted to the host's own panel type.
No catalog entry, icon enum, storage dispatch entry, or change to an official package is needed. Inject the same `$collector` into the application service that
calls `record()`.
The container resolves `CacheCollector::class`, so the same instance serves the application service and the capture.

Entry options:

- `class`: the collector or panel class, required unless the value is a class string or, in Yii2, an instance.
- `title`: the panel title, defaulting to the panel's `TITLE` constant.
- `icon`: a Debug Core icon key, defaulting to the panel's `ICON` constant.
- `enabled`: set to `false` to skip an entry without installing its package.
- `position`: order among extensions; unpositioned extensions follow alphabetically.

`CollectorInterface` is the only collector contract the debugger has, so the collector is registered as it is, and
only the panel is adapted to the host's own panel type. No catalog entry, icon enum, storage dispatch entry, or
change to an official package is needed.

A runnable version of this example, capturing through PSR-3 instead of a direct call, lives in
[tests/Support](tests/Support); `python3 tools/check-consumer.py` installs it as an independent Composer package and
replays a stored capture with no debugger host present.
[tests/Support](tests/Support).

## Presentation vocabulary

Expand All @@ -170,16 +186,15 @@ PanelView::create()
->table(['Prop', 'Value'], [['auth', PanelView::value(['id' => 1])]], styles: [0 => ColumnStyle::IDENTIFIER])
->group('Component', PanelView::create()->paragraph('Nested content'))
->emptyState('No operations', 'The cache was observed, but nothing happened.')
->disclosure('Raw payload', $json)
->active(true);
->disclosure('Raw payload', $json);
```

Plain scalars and `null` become text. `PanelView::text()`, `::strong()`, `::code()`, `::preview()`, `::badge()`, and
`::value()` produce validated inline values accepted wherever a scalar is accepted. Methods with explicit value
validation reject invalid input with an `InvalidArgumentException`; arguments with incompatible declared types raise
PHP's native `TypeError`.

The host reads the finished description through `summaryMetrics()`, `toolbarMetrics()`, `blocks()`, and `isActive()`.
The host reads the finished description through `summaryMetrics()`, `toolbarMetrics()`, and `blocks()`.
Those accessors return `PHPForge\Debug\Presenter` value objects: `SummaryMetric`, `ToolbarMetric`, and the blocks,
entries, and inline values behind them. A renderer narrows each value with `instanceof` over the sealed `Block` and
`Inline` unions, which static analysis proves exhaustive, and reads its public properties. Inline text carries a
Expand Down
23 changes: 12 additions & 11 deletions src/Panel.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,24 @@
/**
* Describes a panel from captured data through metadata constants and a single presentation method.
*
* Extensions own all titles, icons, columns, and content. The host owns their rendering. Subclasses supply non-empty
* metadata constants and build the view from the selected capture, never from live services.
* Extensions own all columns and content and declare the default title and icon; the host owns their rendering and
* may override the title and icon through its own configuration. Subclasses supply non-empty metadata constants and
* build the view from the selected capture, never from live services.
*/
abstract class Panel
{
/**
* @var string Host-interpreted icon identifier supplied by the extension.
* @var string Default host-interpreted icon identifier; the host configuration may override it.
*/
protected const string ICON = '';

/**
* @var string Stable identifier associating the panel with its captured data.
* @var string Stable identifier associating the panel with its captured data, validated against the registration.
*/
protected const string ID = '';

/**
* @var string Human-readable panel title used in navigation.
* @var string Default human-readable panel title used in navigation; the host configuration may override it.
*/
protected const string TITLE = '';

Expand All @@ -34,16 +35,16 @@ abstract class Panel
*
* @param array<string, mixed> $data Provider-owned captured data, decoded by the integration when necessary.
*
* @return PanelView Panel content, metrics, and activity described for the host frontend.
* @return PanelView Panel content and metrics described for the host frontend.
*/
abstract public function present(array $data): PanelView;

/**
* Returns the icon identifier declared by the extension.
* Returns the default icon identifier declared by the extension.
*
* @throws InvalidArgumentException if the icon identifier is empty.
*
* @return string Host-interpreted icon identifier.
* @return string Default icon identifier declared by the extension; the host may override it.
*/
final public function icon(): string
{
Expand All @@ -55,19 +56,19 @@ final public function icon(): string
*
* @throws InvalidArgumentException if the panel identifier is empty.
*
* @return string Identifier used to associate the panel with captured data.
* @return string Identifier associating the panel with captured data, validated against the registration.
*/
final public function id(): string
{
return self::required(static::ID, 'ID');
}

/**
* Returns the navigation title declared by the extension.
* Returns the default navigation title declared by the extension.
*
* @throws InvalidArgumentException if the panel title is empty.
*
* @return string Human-readable panel title.
* @return string Default panel title declared by the extension; the host may override it.
*/
final public function name(): string
{
Expand Down
46 changes: 11 additions & 35 deletions src/PanelView.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,22 @@
* come from the static factories and are accepted wherever a scalar is accepted.
*
* The finished description is a tree of `PHPForge\Debug\Presenter` value objects. The host reads it through
* {@see self::summaryMetrics()}, {@see self::toolbarMetrics()}, {@see self::blocks()}, and {@see self::isActive()},
* then narrows each value with `instanceof` over the sealed {@see Block} and {@see Inline} unions.
* {@see self::summaryMetrics()}, {@see self::toolbarMetrics()}, and {@see self::blocks()}, then narrows each value
* with `instanceof` over the sealed {@see Block} and {@see Inline} unions.
*/
final readonly class PanelView
{
/**
* @param list<Presenter\SummaryMetric> $summary Summary metrics in display order.
* @param list<Presenter\Block> $blocks Panel content blocks in display order.
* @param list<Presenter\ToolbarMetric> $toolbar Toolbar metrics in display order, separate from the summary.
* @param bool $active Whether the panel is marked active for host navigation.
*/
private function __construct(
private array $summary,
private array $blocks,
private array $toolbar,
private bool $active,
) {}

/**
* Changes the activity flag without altering content or metrics.
*
* @param bool $active Whether the panel is marked active for host navigation.
*
* @return self New view with the requested activity flag.
*/
public function active(bool $active): self
{
return new self($this->summary, $this->blocks, $this->toolbar, $active);
}

/**
* Creates an inline status label with a semantic tone.
*
Expand Down Expand Up @@ -156,13 +142,13 @@ public static function column(string $title, self $content): Presenter\ColumnEnt
}

/**
* Creates an active view without content or metrics as the starting point for fluent composition.
* Creates a view without content or metrics as the starting point for fluent composition.
*
* @return self Empty active view.
* @return self Empty view.
*/
public static function create(): self
{
return new self([], [], [], true);
return new self([], [], []);
}

/**
Expand Down Expand Up @@ -252,12 +238,12 @@ public function files(Presenter\FileEntry ...$files): self
}

/**
* Groups only the child's content; metrics and activity belong to the root view.
* Groups only the child's content; metrics belong to the root view.
*
* @param string $label Accessible group label.
* @param self $content Child view contributing only its ordered content blocks.
*
* @return self New view with the group appended and the current metrics and activity preserved.
* @return self New view with the group appended and the current metrics preserved.
*/
public function group(string $label, self $content): self
{
Expand All @@ -277,16 +263,6 @@ public function heading(string $title, bool $section = false): self
return $this->append(new Presenter\HeadingBlock($title, $section));
}

/**
* Reports whether the panel is marked active for host navigation.
*
* @return bool Activity flag.
*/
public function isActive(): bool
{
return $this->active;
}

/**
* Creates an inline navigation link the host renders as an anchor.
*
Expand Down Expand Up @@ -528,7 +504,7 @@ public function summary(string $label, string|int|float $value, bool $emphasized
$emphasized ? self::strong((string) $value) : self::text((string) $value),
);

return new self([...$this->summary, $metric], $this->blocks, $this->toolbar, $this->active);
return new self([...$this->summary, $metric], $this->blocks, $this->toolbar);
}

/**
Expand Down Expand Up @@ -596,7 +572,7 @@ public function toolbar(string $label, string|int|float $value): self
{
$metric = new Presenter\ToolbarMetric($label, (string) $value);

return new self($this->summary, $this->blocks, [...$this->toolbar, $metric], $this->active);
return new self($this->summary, $this->blocks, [...$this->toolbar, $metric]);
}

/**
Expand Down Expand Up @@ -639,15 +615,15 @@ public static function value(mixed $value, bool $typeOnly = false): Presenter\Va
}

/**
* Appends a content block while retaining metrics and activity.
* Appends a content block while retaining metrics.
*
* @param Presenter\Block $block Content block placed after the existing blocks.
*
* @return self New view containing the additional block.
*/
private function append(Presenter\Block $block): self
{
return new self($this->summary, [...$this->blocks, $block], $this->toolbar, $this->active);
return new self($this->summary, [...$this->blocks, $block], $this->toolbar);
}

/**
Expand Down
4 changes: 0 additions & 4 deletions tests/CacheExampleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,6 @@ public function testRealOperationsAndTwoRequestLifecycles(): void
$collector->capture(),
'An observed empty cache is still a capture.',
);
self::assertTrue(
$panel->present(['schema' => 1, 'operations' => []])->isActive(),
'An empty capture must still open the panel.',
);

$collector->shutdown();
}
Expand Down
16 changes: 1 addition & 15 deletions tests/FluentPanelViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ public function testDefinitionKeepsEveryContentOptionAndOrder(): void
$view = PanelView::create()
->summary(' hits', 3)
->toolbar('Hits', 3)
->active(false)
->overview(['Driver' => 'redis', 'State' => $badge, 'Null' => null, 'Ratio' => 1.5], compact: true)
->heading('Entries', section: true)
->table(
Expand Down Expand Up @@ -88,19 +87,14 @@ public function testDefinitionKeepsEveryContentOptionAndOrder(): void
$view->blocks(),
'Content, options, and ordering must survive fluent composition.',
);
self::assertFalse(
$view->isActive(),
'Navigation visibility must remain explicit.',
);
}

public function testDefinitionNeverMutatesAnEarlierView(): void
{
$base = PanelView::create();
$nested = PanelView::create()
->summary(' ignored', 9)
->toolbar('Ignored', 8)
->active(false);
->toolbar('Ignored', 8);

$view = $base
->summary(' hits', 3)
Expand All @@ -117,10 +111,6 @@ public function testDefinitionNeverMutatesAnEarlierView(): void
$base->summaryMetrics(),
'A reusable base view must keep no metrics.',
);
self::assertTrue(
$base->isActive(),
'A reusable base view must keep its own activity flag.',
);
self::assertCount(
1,
$view->summaryMetrics(),
Expand All @@ -131,10 +121,6 @@ public function testDefinitionNeverMutatesAnEarlierView(): void
$view->toolbarMetrics(),
'Nested metrics must not reach the root toolbar.',
);
self::assertTrue(
$view->isActive(),
'Nested activity must not override the root flag.',
);
}

public function testNumericOverviewKeysBecomeExplicitLabels(): void
Expand Down
4 changes: 0 additions & 4 deletions tests/PanelViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,6 @@ public function testDefaultsPreserveNonIntrusivePresentation(): void
->overview([])
->table([], []);

self::assertTrue(
PanelView::create()->isActive(),
'A described panel must be active by default.',
);
self::assertEquals(
[
new HeadingBlock('Title', false),
Expand Down
Loading