docs: add Spec B (new input) and Spec C (platform & events) designs
Spec B: keybinding + browser packages, window file drop enhancement. Spec C: dock/badge + lifecycle event packages. Both reviewed — fixes applied for safe type assertions, EventType constants, platform mapping table, service logic clarity, JSON tags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bba743d2cb
commit
bf685aa8d3
2 changed files with 536 additions and 0 deletions
242
docs/superpowers/specs/2026-03-13-gui-new-input-design.md
Normal file
242
docs/superpowers/specs/2026-03-13-gui-new-input-design.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# CoreGUI Spec B: New Input
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add keybinding and browser packages as core.Services, enhance window service with file drop events
|
||||
|
||||
## Context
|
||||
|
||||
Spec A extracted 5 packages from display. This spec adds input capabilities not yet present in core/gui: keyboard shortcuts, browser delegation, and file drop events. Each follows the three-layer pattern (IPC Bus → Service → Platform Interface).
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Packages
|
||||
|
||||
| Package | Platform methods | IPC pattern |
|
||||
|---------|-----------------|-------------|
|
||||
| `pkg/keybinding` | `Add()`, `Remove()`, `GetAll()` | Task add/remove, Query list, Action triggered |
|
||||
| `pkg/browser` | `OpenURL()`, `OpenFile()` | Tasks (side-effects) |
|
||||
|
||||
### Window Service Enhancement
|
||||
|
||||
File drop is not a separate package — it extends `pkg/window` with an `OnFileDrop` callback on `PlatformWindow` and a new `ActionFilesDropped` broadcast.
|
||||
|
||||
---
|
||||
|
||||
## Package Designs
|
||||
|
||||
### 1. pkg/keybinding
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
Add(accelerator string, handler func()) error
|
||||
Remove(accelerator string) error
|
||||
GetAll() []string
|
||||
}
|
||||
```
|
||||
|
||||
Platform-aware accelerator syntax: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux). Special keys: `F1-F12`, `Escape`, `Enter`, `Space`, `Tab`, `Backspace`, `Delete`, arrow keys.
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type BindingInfo struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryList struct{} // → []BindingInfo
|
||||
|
||||
// Tasks
|
||||
type TaskAdd struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
} // → error
|
||||
|
||||
type TaskRemove struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
} // → error
|
||||
|
||||
// Actions
|
||||
type ActionTriggered struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
```
|
||||
|
||||
**Service logic:** The Service maintains a `map[string]BindingInfo` registry. When `TaskAdd` is received, it returns `ErrAlreadyRegistered` if the accelerator exists (callers must `TaskRemove` first to rebind). Otherwise, the Service calls `platform.Add(accelerator, callback)` where the callback broadcasts `ActionTriggered` via `s.Core().ACTION()`. `QueryList` reads from the in-memory registry (not `platform.GetAll()`) — `platform.GetAll()` is for adapter-level reconciliation only. The display orchestrator bridges `ActionTriggered` → `Event{Type: "keybinding.triggered"}` for WS clients.
|
||||
|
||||
**Wails adapter:** Wraps `app.KeyBinding.Add()`, `app.KeyBinding.Remove()`, `app.KeyBinding.GetAll()`.
|
||||
|
||||
**Config:** None — bindings are registered programmatically.
|
||||
|
||||
**WS bridge events:** `keybinding.triggered` (broadcast). TS apps call `keybinding:add`, `keybinding:remove`, `keybinding:list` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
### 2. pkg/browser
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
OpenURL(url string) error
|
||||
OpenFile(path string) error
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages (all Tasks):**
|
||||
|
||||
```go
|
||||
type TaskOpenURL struct{ URL string `json:"url"` } // → error
|
||||
type TaskOpenFile struct{ Path string `json:"path"` } // → error
|
||||
```
|
||||
|
||||
**Wails adapter:** Wraps `app.Browser.OpenURL()` and `app.Browser.OpenFile()`.
|
||||
|
||||
**Config:** None — stateless.
|
||||
|
||||
**WS bridge:** No actions. TS apps call `browser:open-url`, `browser:open-file` via WS→IPC Tasks.
|
||||
|
||||
---
|
||||
|
||||
### 3. Window Service Enhancement — File Drop
|
||||
|
||||
**PlatformWindow interface addition:**
|
||||
|
||||
```go
|
||||
// Added to existing PlatformWindow interface in pkg/window/platform.go
|
||||
OnFileDrop(handler func(paths []string, targetID string))
|
||||
```
|
||||
|
||||
**New Action in pkg/window/messages.go:**
|
||||
|
||||
```go
|
||||
type ActionFilesDropped struct {
|
||||
Name string `json:"name"` // window name
|
||||
Paths []string `json:"paths"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Service wiring:** The existing `trackWindow()` method in `pkg/window/service.go` gains a call to `pw.OnFileDrop()` that broadcasts `ActionFilesDropped`. Adding `OnFileDrop` to `PlatformWindow` is a breaking interface change — `MockWindow` in `mock_platform.go` and the Wails adapter must both gain no-op stubs.
|
||||
|
||||
**WS bridge:** Display orchestrator adds a case for `window.ActionFilesDropped` → `Event{Type: "window.filedrop"}`.
|
||||
|
||||
**Note:** File drop is opt-in per window via the existing `EnableFileDrop` field in `PlatformWindowOptions`. The HTML target element uses `data-file-drop-target` attribute (Wails v3 convention). HTML5 internal drag-and-drop is purely frontend (JS/CSS) — no Go package needed.
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New HandleIPCEvents Cases
|
||||
|
||||
```go
|
||||
case keybinding.ActionTriggered:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: "keybinding.triggered",
|
||||
Data: map[string]any{"accelerator": m.Accelerator}})
|
||||
}
|
||||
case window.ActionFilesDropped:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: "window.filedrop", Window: m.Name,
|
||||
Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}})
|
||||
}
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "keybinding:add":
|
||||
accelerator, _ := msg.Data["accelerator"].(string)
|
||||
description, _ := msg.Data["description"].(string)
|
||||
result, handled, err = s.Core().PERFORM(keybinding.TaskAdd{
|
||||
Accelerator: accelerator, Description: description,
|
||||
})
|
||||
case "keybinding:remove":
|
||||
accelerator, _ := msg.Data["accelerator"].(string)
|
||||
result, handled, err = s.Core().PERFORM(keybinding.TaskRemove{
|
||||
Accelerator: accelerator,
|
||||
})
|
||||
case "keybinding:list":
|
||||
result, handled, err = s.Core().QUERY(keybinding.QueryList{})
|
||||
case "browser:open-url":
|
||||
url, _ := msg.Data["url"].(string)
|
||||
result, handled, err = s.Core().PERFORM(browser.TaskOpenURL{URL: url})
|
||||
case "browser:open-file":
|
||||
path, _ := msg.Data["path"].(string)
|
||||
result, handled, err = s.Core().PERFORM(browser.TaskOpenFile{Path: path})
|
||||
```
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/keybinding (message types only)
|
||||
├── imports pkg/browser (message types only)
|
||||
└── ... existing imports ...
|
||||
|
||||
pkg/keybinding (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
|
||||
pkg/browser (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
```
|
||||
|
||||
No circular dependencies.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```go
|
||||
func TestKeybinding_AddAndTrigger_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(keybinding.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(keybinding.TaskAdd{
|
||||
Accelerator: "Ctrl+S", Description: "Save",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Simulate shortcut trigger via mock
|
||||
mock.trigger("Ctrl+S")
|
||||
|
||||
// Verify action was broadcast (captured via registered handler)
|
||||
}
|
||||
|
||||
func TestBrowser_OpenURL_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(browser.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(browser.TaskOpenURL{URL: "https://example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "https://example.com", mock.lastURL)
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Per-window keybindings**: Currently global only. Per-window scoping requires handler-receives-window pattern.
|
||||
- **Browser fallback**: Copy URL to clipboard when browser open fails.
|
||||
- **HTML5 drag-drop helpers**: TS SDK utilities for internal drag-drop — purely frontend.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
294
docs/superpowers/specs/2026-03-13-gui-platform-events-design.md
Normal file
294
docs/superpowers/specs/2026-03-13-gui-platform-events-design.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
# CoreGUI Spec C: Platform & Events
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add dock/badge and application lifecycle event packages as core.Services
|
||||
|
||||
## Context
|
||||
|
||||
Spec A extracted display features, Spec B added input capabilities. This spec covers platform-specific features (dock icon, badge) and application lifecycle events — the remaining Wails v3 features needed for full IPC coverage.
|
||||
|
||||
Cursor management is excluded — CSS `cursor:` property handles it in the webview without Go involvement.
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Packages
|
||||
|
||||
| Package | Platform methods | IPC pattern |
|
||||
|---------|-----------------|-------------|
|
||||
| `pkg/dock` | `ShowIcon()`, `HideIcon()`, `SetBadge()`, `RemoveBadge()` | Tasks (mutations), Query visibility |
|
||||
| `pkg/lifecycle` | `OnApplicationEvent()` | Actions broadcast for each event type |
|
||||
|
||||
---
|
||||
|
||||
## Package Designs
|
||||
|
||||
### 1. pkg/dock
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
ShowIcon() error
|
||||
HideIcon() error
|
||||
SetBadge(label string) error
|
||||
RemoveBadge() error
|
||||
IsVisible() bool
|
||||
}
|
||||
```
|
||||
|
||||
macOS: dock icon show/hide + badge. Windows: taskbar badge only (show/hide not supported). Linux: not supported — adapter returns nil. Platform adapter returns nil on unsupported OS.
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryVisible struct{} // → bool
|
||||
|
||||
// Tasks
|
||||
type TaskShowIcon struct{} // → error
|
||||
type TaskHideIcon struct{} // → error
|
||||
type TaskSetBadge struct{ Label string } // → error
|
||||
type TaskRemoveBadge struct{} // → error
|
||||
|
||||
// Actions
|
||||
type ActionVisibilityChanged struct{ Visible bool }
|
||||
```
|
||||
|
||||
**Badge conventions:**
|
||||
- Empty string `""`: default system badge indicator
|
||||
- Numeric `"3"`, `"99"`: unread count
|
||||
- Text `"New"`, `"Paused"`: brief status labels
|
||||
|
||||
**Wails adapter:** Wraps `app.Dock.HideAppIcon()`, `app.Dock.ShowAppIcon()` (macOS only), and badge APIs.
|
||||
|
||||
**Service logic:** After a successful `TaskShowIcon`, the Service broadcasts `ActionVisibilityChanged{Visible: true}`. After `TaskHideIcon`, broadcasts `ActionVisibilityChanged{Visible: false}`. `QueryVisible` delegates to `platform.IsVisible()`.
|
||||
|
||||
**Config:** None — stateless. No config section required in display orchestrator.
|
||||
|
||||
**WS bridge events:** `dock.visibility-changed` (on show/hide). TS apps call `dock:show`, `dock:hide`, `dock:badge`, `dock:badge-remove`, `dock:visible` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
### 2. pkg/lifecycle
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
// OnApplicationEvent registers a handler for a fire-and-forget event type.
|
||||
// Events that carry data (e.g. file path) use dedicated methods.
|
||||
OnApplicationEvent(eventType EventType, handler func()) func() // returns cancel
|
||||
OnOpenedWithFile(handler func(path string)) func() // returns cancel
|
||||
}
|
||||
```
|
||||
|
||||
Separate method for file-open because it carries data (the file path).
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type EventType int
|
||||
|
||||
const (
|
||||
EventApplicationStarted EventType = iota
|
||||
EventWillTerminate
|
||||
EventDidBecomeActive
|
||||
EventDidResignActive
|
||||
EventPowerStatusChanged // Windows: APMPowerStatusChange
|
||||
EventSystemSuspend // Windows: APMSuspend
|
||||
EventSystemResume // Windows: APMResume
|
||||
)
|
||||
```
|
||||
|
||||
**IPC messages (all Actions — lifecycle events are broadcasts):**
|
||||
|
||||
```go
|
||||
type ActionApplicationStarted struct{}
|
||||
type ActionOpenedWithFile struct{ Path string }
|
||||
type ActionWillTerminate struct{}
|
||||
type ActionDidBecomeActive struct{}
|
||||
type ActionDidResignActive struct{}
|
||||
type ActionPowerStatusChanged struct{}
|
||||
type ActionSystemSuspend struct{}
|
||||
type ActionSystemResume struct{}
|
||||
```
|
||||
|
||||
**Service logic:** During `OnStartup`, the Service registers a platform callback for each `EventType` and for file-open. Each callback broadcasts the corresponding Action via `s.Core().ACTION()`. `OnShutdown` cancels all registrations.
|
||||
|
||||
**Wails adapter:** Wraps `app.Event.OnApplicationEvent()` for each Wails event type:
|
||||
|
||||
| Our EventType | Wails event | Platforms |
|
||||
|---|---|---|
|
||||
| `EventApplicationStarted` | `events.Common.ApplicationStarted` | all |
|
||||
| `EventWillTerminate` | `events.Mac.ApplicationWillTerminate` | macOS only |
|
||||
| `EventDidBecomeActive` | `events.Mac.ApplicationDidBecomeActive` | macOS only |
|
||||
| `EventDidResignActive` | `events.Mac.ApplicationDidResignActive` | macOS only |
|
||||
| `EventPowerStatusChanged` | `events.Windows.APMPowerStatusChange` | Windows only |
|
||||
| `EventSystemSuspend` | `events.Windows.APMSuspend` | Windows only |
|
||||
| `EventSystemResume` | `events.Windows.APMResume` | Windows only |
|
||||
|
||||
Platform-specific events no-op silently on unsupported OS (adapter registers nothing).
|
||||
|
||||
**Note:** `ActionApplicationStarted` maps to the Wails `ApplicationStarted` event, which fires when the platform application starts. This is distinct from `core.ActionServiceStartup`, which fires after all core.Services complete `OnStartup`. TS clients should use `app.started` for platform-level readiness and subscribe to services individually for service-level readiness.
|
||||
|
||||
**Config:** None — event-driven, no state. No config section required in display orchestrator.
|
||||
|
||||
**WS bridge events:**
|
||||
|
||||
| Action | WS Event Type |
|
||||
|--------|--------------|
|
||||
| `ActionApplicationStarted` | `app.started` |
|
||||
| `ActionOpenedWithFile` | `app.opened-with-file` |
|
||||
| `ActionWillTerminate` | `app.will-terminate` |
|
||||
| `ActionDidBecomeActive` | `app.active` |
|
||||
| `ActionDidResignActive` | `app.inactive` |
|
||||
| `ActionPowerStatusChanged` | `system.power-change` |
|
||||
| `ActionSystemSuspend` | `system.suspend` |
|
||||
| `ActionSystemResume` | `system.resume` |
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New EventType Constants (in `pkg/display/events.go`)
|
||||
|
||||
```go
|
||||
EventDockVisibility EventType = "dock.visibility-changed"
|
||||
EventAppStarted EventType = "app.started"
|
||||
EventAppOpenedWithFile EventType = "app.opened-with-file"
|
||||
EventAppWillTerminate EventType = "app.will-terminate"
|
||||
EventAppActive EventType = "app.active"
|
||||
EventAppInactive EventType = "app.inactive"
|
||||
EventSystemPowerChange EventType = "system.power-change"
|
||||
EventSystemSuspend EventType = "system.suspend"
|
||||
EventSystemResume EventType = "system.resume"
|
||||
```
|
||||
|
||||
### New HandleIPCEvents Cases
|
||||
|
||||
```go
|
||||
case dock.ActionVisibilityChanged:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventDockVisibility,
|
||||
Data: map[string]any{"visible": m.Visible}})
|
||||
}
|
||||
case lifecycle.ActionApplicationStarted:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppStarted})
|
||||
}
|
||||
case lifecycle.ActionOpenedWithFile:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppOpenedWithFile,
|
||||
Data: map[string]any{"path": m.Path}})
|
||||
}
|
||||
case lifecycle.ActionWillTerminate:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppWillTerminate})
|
||||
}
|
||||
case lifecycle.ActionDidBecomeActive:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppActive})
|
||||
}
|
||||
case lifecycle.ActionDidResignActive:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventAppInactive})
|
||||
}
|
||||
case lifecycle.ActionPowerStatusChanged:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventSystemPowerChange})
|
||||
}
|
||||
case lifecycle.ActionSystemSuspend:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventSystemSuspend})
|
||||
}
|
||||
case lifecycle.ActionSystemResume:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventSystemResume})
|
||||
}
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "dock:show":
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskShowIcon{})
|
||||
case "dock:hide":
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskHideIcon{})
|
||||
case "dock:badge":
|
||||
label, _ := msg.Data["label"].(string)
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskSetBadge{Label: label})
|
||||
case "dock:badge-remove":
|
||||
result, handled, err = s.Core().PERFORM(dock.TaskRemoveBadge{})
|
||||
case "dock:visible":
|
||||
result, handled, err = s.Core().QUERY(dock.QueryVisible{})
|
||||
```
|
||||
|
||||
Lifecycle events are outbound-only (Actions → WS). No inbound WS→IPC needed.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/dock (message types only)
|
||||
├── imports pkg/lifecycle (message types only)
|
||||
└── ... existing imports ...
|
||||
|
||||
pkg/dock (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
|
||||
pkg/lifecycle (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
```
|
||||
|
||||
No circular dependencies.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```go
|
||||
func TestDock_SetBadge_Good(t *testing.T) {
|
||||
mock := &mockPlatform{visible: true}
|
||||
c, _ := core.New(
|
||||
core.WithService(dock.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(dock.TaskSetBadge{Label: "3"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "3", mock.lastBadge)
|
||||
}
|
||||
|
||||
func TestLifecycle_BecomeActive_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(lifecycle.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
var received bool
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if _, ok := msg.(lifecycle.ActionDidBecomeActive); ok {
|
||||
received = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
mock.simulateEvent(lifecycle.EventDidBecomeActive)
|
||||
assert.True(t, received)
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Window close hooks**: Cancellable `OnClose` (return false to prevent). Requires hook vs listener distinction in the window service — follow-up enhancement.
|
||||
- **Custom badge styling**: Windows-specific font/colour options for `SetCustomBadge`. macOS uses system styling only.
|
||||
- **Notification centre integration**: Deep integration with macOS/Windows notification centres beyond simple badge.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
Loading…
Add table
Reference in a new issue