docs(01): create gap closure plan 01-04 — device CRUD UI + CSS variable fix
This commit is contained in:
@@ -12,7 +12,7 @@ LightSync is built bottom-up: data model first, audio engine second, UDP protoco
|
|||||||
|
|
||||||
Decimal phases appear between their surrounding integers in numeric order.
|
Decimal phases appear between their surrounding integers in numeric order.
|
||||||
|
|
||||||
- [x] **Phase 1: Foundation** - FastAPI skeleton, show file schema (Pydantic), device registry, terminal-aesthetic frontend shell (completed 2026-04-05)
|
- [ ] **Phase 1: Foundation** - FastAPI skeleton, show file schema (Pydantic), device registry, terminal-aesthetic frontend shell (gap closure in progress)
|
||||||
- [ ] **Phase 2: Audio Engine** - MPV IPC bridge, waveform rendering, playback controls, Windows timer fix
|
- [ ] **Phase 2: Audio Engine** - MPV IPC bridge, waveform rendering, playback controls, Windows timer fix
|
||||||
- [ ] **Phase 3: Communication Protocol** - UDP packet spec, DRGB/DNRGB/DRGBW encoders, animation library, software simulator
|
- [ ] **Phase 3: Communication Protocol** - UDP packet spec, DRGB/DNRGB/DRGBW encoders, animation library, software simulator
|
||||||
- [ ] **Phase 4: Timeline Editor** - DAW UI (tracks, drag-place blocks, resize, delete), undo/redo, beat detection, snap-to-beat
|
- [ ] **Phase 4: Timeline Editor** - DAW UI (tracks, drag-place blocks, resize, delete), undo/redo, beat detection, snap-to-beat
|
||||||
@@ -32,12 +32,13 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
3. The web app loads in a browser with terminal/hacker aesthetic — dark background, monospace fonts, no generic SaaS look
|
3. The web app loads in a browser with terminal/hacker aesthetic — dark background, monospace fonts, no generic SaaS look
|
||||||
4. FastAPI serves the frontend and exposes REST stubs for shows and devices
|
4. FastAPI serves the frontend and exposes REST stubs for shows and devices
|
||||||
5. Adding a new strip type requires only a new device class — no changes to core engine code
|
5. Adding a new strip type requires only a new device class — no changes to core engine code
|
||||||
**Plans**: 3 plans
|
**Plans**: 4 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [x] 01-01: Backend scaffold — FastAPI app, Pydantic show/device models, show store (JSON load/save), device registry
|
- [x] 01-01: Backend scaffold — FastAPI app, Pydantic show/device models, show store (JSON load/save), device registry
|
||||||
- [x] 01-02: Frontend shell — HTML/CSS terminal aesthetic, WebSocket client stub, device panel, show panel
|
- [x] 01-02: Frontend shell — HTML/CSS terminal aesthetic, WebSocket client stub, device panel, show panel
|
||||||
- [x] 01-03: REST API — CRUD endpoints for devices and shows, static file serving, integration smoke test
|
- [x] 01-03: REST API — CRUD endpoints for devices and shows, static file serving, integration smoke test
|
||||||
|
- [ ] 01-04: Gap closure — device CRUD UI (add form, remove button), CSS variable naming fix, panel IDs
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 2: Audio Engine
|
### Phase 2: Audio Engine
|
||||||
@@ -151,7 +152,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
|||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 3/3 | Complete | 2026-04-05 |
|
| 1. Foundation | 3/4 | Gap closure | - |
|
||||||
| 2. Audio Engine | 0/3 | Not started | - |
|
| 2. Audio Engine | 0/3 | Not started | - |
|
||||||
| 3. Communication Protocol | 0/3 | Not started | - |
|
| 3. Communication Protocol | 0/3 | Not started | - |
|
||||||
| 4. Timeline Editor | 0/4 | Not started | - |
|
| 4. Timeline Editor | 0/4 | Not started | - |
|
||||||
|
|||||||
354
.planning/phases/01-foundation/01-04-PLAN.md
Normal file
354
.planning/phases/01-foundation/01-04-PLAN.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
---
|
||||||
|
phase: 01-foundation
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on:
|
||||||
|
- 01-03
|
||||||
|
files_modified:
|
||||||
|
- lightsync/frontend/index.html
|
||||||
|
- lightsync/frontend/style.css
|
||||||
|
- lightsync/frontend/app.js
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- DEV-03
|
||||||
|
- UI-02
|
||||||
|
gap_closure: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "The DEVICES panel has an add-device form with inputs for name, strip_type, led_count, ip, port and a submit button"
|
||||||
|
- "Submitting the add-device form creates a device via POST /api/devices and the new device appears in the list"
|
||||||
|
- "Each device row has a remove button that calls DELETE /api/devices/{id} and refreshes the list"
|
||||||
|
- "CSS uses --accent: #00ffff as the canonical accent variable name"
|
||||||
|
- "index.html has panel IDs: devices-panel, animations-panel, timeline-panel, transport-panel"
|
||||||
|
artifacts:
|
||||||
|
- path: "lightsync/frontend/index.html"
|
||||||
|
provides: "Add-device form and panel IDs"
|
||||||
|
contains: "add-device-form"
|
||||||
|
- path: "lightsync/frontend/app.js"
|
||||||
|
provides: "Device CRUD functions: apiPost, apiDelete, removeDevice, form handler"
|
||||||
|
contains: "apiPost"
|
||||||
|
- path: "lightsync/frontend/style.css"
|
||||||
|
provides: "Canonical --accent CSS variable"
|
||||||
|
contains: "--accent: #00ffff"
|
||||||
|
key_links:
|
||||||
|
- from: "lightsync/frontend/app.js"
|
||||||
|
to: "/api/devices"
|
||||||
|
via: "apiPost for form submit, apiDelete for remove button"
|
||||||
|
pattern: "apiPost.*api/devices"
|
||||||
|
- from: "lightsync/frontend/index.html"
|
||||||
|
to: "lightsync/frontend/app.js"
|
||||||
|
via: "form#add-device-form submit event listener"
|
||||||
|
pattern: "add-device-form"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Close two verification gaps from 01-VERIFICATION.md: (1) add device CRUD UI — form, POST, DELETE, remove buttons; (2) fix CSS variable naming to use --accent and add missing panel IDs to index.html.
|
||||||
|
|
||||||
|
Purpose: Completes DEV-03 (device list with UI interaction) and UI-02 (CSS contract alignment with plan spec).
|
||||||
|
Output: Updated index.html, app.js, and style.css with full device management UI and corrected CSS variable naming.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/01-foundation/01-CONTEXT.md
|
||||||
|
@.planning/phases/01-foundation/01-VERIFICATION.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add device CRUD UI — form in index.html, POST/DELETE/remove in app.js</name>
|
||||||
|
<files>
|
||||||
|
lightsync/frontend/index.html
|
||||||
|
lightsync/frontend/app.js
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
lightsync/frontend/index.html
|
||||||
|
lightsync/frontend/app.js
|
||||||
|
lightsync/api/devices.py
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**index.html changes:**
|
||||||
|
|
||||||
|
In the sidebar, the first panel (DEVICES panel, currently lines 23-28) must get an id and have the add-device form inserted after the device-list div. Replace the current DEVICES panel block:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="panel" id="devices-panel" style="flex: 1; min-height: 0;">
|
||||||
|
<div class="panel-header">DEVICES</div>
|
||||||
|
<div class="panel-content" id="device-list">
|
||||||
|
<span class="text-dim">loading...</span>
|
||||||
|
</div>
|
||||||
|
<form id="add-device-form" class="device-form">
|
||||||
|
<input type="text" name="name" placeholder="Device name" required>
|
||||||
|
<select name="strip_type" required>
|
||||||
|
<option value="sk6812">SK6812</option>
|
||||||
|
<option value="ws2801">WS2801</option>
|
||||||
|
<option value="generic">Generic</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" name="led_count" placeholder="LED count" min="1" max="1000" required>
|
||||||
|
<input type="text" name="ip" placeholder="IP address" required>
|
||||||
|
<input type="number" name="port" placeholder="Port" min="1" max="65535" value="21324" required>
|
||||||
|
<button type="submit">+ ADD DEVICE</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The form goes AFTER the panel-content div but still inside the panel div, so it sits at the bottom of the devices panel. The form class is "device-form" (styles added in Task 2).
|
||||||
|
|
||||||
|
**app.js changes:**
|
||||||
|
|
||||||
|
Add two API helper functions before the LightSyncClient class (near the top, after the WS_URL const):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function apiPost(path, data) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST ${path}: HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiDelete(path) {
|
||||||
|
const res = await fetch(path, { method: "DELETE" });
|
||||||
|
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${path}: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Modify the `loadDevices()` function: change the device row rendering to include a remove button. Replace the current `list.innerHTML = devices.map(...)` block with:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
list.innerHTML = devices.map(d =>
|
||||||
|
`<div class="device-row">
|
||||||
|
<div class="device-row-info">
|
||||||
|
<span class="text-accent">${escapeHtml(d.name)}</span>
|
||||||
|
<span class="text-dim">${escapeHtml(d.strip_type)} / ${d.led_count} LEDs / ${escapeHtml(d.ip)}:${d.port}</span>
|
||||||
|
</div>
|
||||||
|
<button class="device-remove" onclick="removeDevice('${d.id}')" title="Remove device">×</button>
|
||||||
|
</div>`
|
||||||
|
).join("");
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `removeDevice` function (after `loadDevices`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function removeDevice(id) {
|
||||||
|
try {
|
||||||
|
await apiDelete(`/api/devices/${id}`);
|
||||||
|
await loadDevices();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[devices] remove failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a form submit handler. After the `loadDevices()` call at the bottom of the file, add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.getElementById("add-device-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const data = {
|
||||||
|
name: form.name.value.trim(),
|
||||||
|
strip_type: form.strip_type.value,
|
||||||
|
led_count: parseInt(form.led_count.value, 10),
|
||||||
|
ip: form.ip.value.trim(),
|
||||||
|
port: parseInt(form.port.value, 10),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await apiPost("/api/devices", data);
|
||||||
|
form.reset();
|
||||||
|
form.port.value = "21324";
|
||||||
|
await loadDevices();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[devices] add failed:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Since app.js uses `type="module"`, the `removeDevice` function must be exported to global scope. Add this line after the function definition:
|
||||||
|
```javascript
|
||||||
|
window.removeDevice = removeDevice;
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && grep -c "add-device-form" lightsync/frontend/index.html && grep -c "apiPost" lightsync/frontend/app.js && grep -c "apiDelete" lightsync/frontend/app.js && grep -c "removeDevice" lightsync/frontend/app.js && grep -c "device-remove" lightsync/frontend/index.html</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- lightsync/frontend/index.html contains `id="add-device-form"` with a form element
|
||||||
|
- lightsync/frontend/index.html contains `name="name"` input, `name="strip_type"` select with options sk6812/ws2801/generic, `name="led_count"` input, `name="ip"` input, `name="port"` input
|
||||||
|
- lightsync/frontend/index.html contains `type="submit"` button with text "+ ADD DEVICE"
|
||||||
|
- lightsync/frontend/index.html contains `class="device-remove"` button with onclick calling removeDevice
|
||||||
|
- lightsync/frontend/app.js contains `async function apiPost(path, data)` with `method: "POST"` and `Content-Type: application/json`
|
||||||
|
- lightsync/frontend/app.js contains `async function apiDelete(path)` with `method: "DELETE"`
|
||||||
|
- lightsync/frontend/app.js contains `async function removeDevice(id)` that calls `apiDelete` with `/api/devices/${id}`
|
||||||
|
- lightsync/frontend/app.js contains `addEventListener("submit"` on `add-device-form` that calls `apiPost("/api/devices", data)`
|
||||||
|
- lightsync/frontend/app.js contains `window.removeDevice = removeDevice` to expose function globally for onclick handler
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>The DEVICES panel has a working add-device form (name, strip_type select, led_count, ip, port, submit button). Each device row has a remove button. Form submit calls POST /api/devices, remove calls DELETE /api/devices/{id}, both refresh the device list.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Fix CSS variable naming to --accent and add panel IDs to index.html</name>
|
||||||
|
<files>
|
||||||
|
lightsync/frontend/style.css
|
||||||
|
lightsync/frontend/index.html
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
lightsync/frontend/style.css
|
||||||
|
lightsync/frontend/index.html
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**style.css changes:**
|
||||||
|
|
||||||
|
In the `:root` block (line 1-14), replace the current variable declarations with these renamed variables. The key change is adding `--accent: #00ffff` as the canonical accent variable. Keep `--text-accent` and `--border-bright` as aliases that reference `--accent`:
|
||||||
|
|
||||||
|
Replace lines 1-14 with:
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0a;
|
||||||
|
--bg-panel: #0f0f0f;
|
||||||
|
--bg-panel-dark: #080808;
|
||||||
|
--border-dim: #1a4a4a;
|
||||||
|
--accent: #00ffff;
|
||||||
|
--border-bright: var(--accent);
|
||||||
|
--text-primary: #cccccc;
|
||||||
|
--text-dim: #555555;
|
||||||
|
--text-accent: var(--accent);
|
||||||
|
--text-warning: #ff6600;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
--font-size: 13px;
|
||||||
|
--panel-gap: 1px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes `--accent` the single source of truth for `#00ffff`. The existing `--text-accent` and `--border-bright` usages throughout the CSS file continue to work because they now reference `--accent`.
|
||||||
|
|
||||||
|
Add CSS rules for the device form and remove button (append after the existing `button:hover` rule at the end of the file):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Device form */
|
||||||
|
.device-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-form button[type="submit"] {
|
||||||
|
margin-top: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device remove button */
|
||||||
|
.device-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-remove:hover {
|
||||||
|
color: #ff3333;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--bg-panel-dark);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The existing `.device-row` rule (lines 143-152) should be replaced by this updated version that adds `align-items: center` and `justify-content: space-between` to accommodate the remove button alongside the info.
|
||||||
|
|
||||||
|
**index.html changes:**
|
||||||
|
|
||||||
|
Add panel IDs to the remaining panels that are missing them. After Task 1, devices-panel already has its ID. The following still need IDs:
|
||||||
|
|
||||||
|
1. The ANIMATIONS panel (currently line 29 `<div class="panel" style="flex: 0 0 120px;">`): Add `id="animations-panel"`:
|
||||||
|
```html
|
||||||
|
<div class="panel" id="animations-panel" style="flex: 0 0 120px;">
|
||||||
|
```
|
||||||
|
|
||||||
|
2. The main-area timeline (currently line 35 `<main class="main-area">`): Wrap or add ID to the main element. Add a panel wrapper inside:
|
||||||
|
```html
|
||||||
|
<main class="main-area">
|
||||||
|
<div class="panel" id="timeline-panel" style="flex: 1; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<span>[ TIMELINE — PHASE 2+ ]</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The transport-bar footer (currently line 39 `<footer class="transport-bar">`): Add ID to the footer or add an inner panel:
|
||||||
|
```html
|
||||||
|
<footer class="transport-bar" id="transport-panel">
|
||||||
|
<span>[ TRANSPORT — PHASE 2+ ]</span>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/claude/led2 && grep -c "\-\-accent.*#00ffff" lightsync/frontend/style.css && grep "devices-panel" lightsync/frontend/index.html | head -1 && grep "animations-panel" lightsync/frontend/index.html | head -1 && grep "timeline-panel" lightsync/frontend/index.html | head -1 && grep "transport-panel" lightsync/frontend/index.html | head -1</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- lightsync/frontend/style.css contains `--accent: #00ffff` (not `--accent: var(...)`)
|
||||||
|
- lightsync/frontend/style.css contains `--text-accent: var(--accent)` (alias, not a separate #00ffff value)
|
||||||
|
- lightsync/frontend/style.css contains `--border-bright: var(--accent)` (alias, not a separate #00ffff value)
|
||||||
|
- lightsync/frontend/style.css contains `.device-form` rule with `display: flex` and `flex-direction: column`
|
||||||
|
- lightsync/frontend/style.css contains `.device-remove` rule with `cursor: pointer`
|
||||||
|
- lightsync/frontend/index.html contains `id="devices-panel"`
|
||||||
|
- lightsync/frontend/index.html contains `id="animations-panel"`
|
||||||
|
- lightsync/frontend/index.html contains `id="timeline-panel"`
|
||||||
|
- lightsync/frontend/index.html contains `id="transport-panel"`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>CSS uses --accent: #00ffff as the canonical variable with --text-accent and --border-bright as aliases. All four panel IDs present in index.html. Device form and remove button have proper styling.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `grep "add-device-form" lightsync/frontend/index.html` returns a match
|
||||||
|
2. `grep "apiPost\|apiDelete\|removeDevice" lightsync/frontend/app.js` returns 3+ matches
|
||||||
|
3. `grep "\-\-accent:.*#00ffff" lightsync/frontend/style.css` returns a match
|
||||||
|
4. `grep "devices-panel\|animations-panel\|timeline-panel\|transport-panel" lightsync/frontend/index.html` returns 4 matches
|
||||||
|
5. After deploying: open https://lightsync.groll.cloud, fill the add-device form, submit, see device appear in list, click remove, device disappears
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- The add-device form is visible in the DEVICES panel with all 5 inputs and submit button
|
||||||
|
- Submitting the form creates a device via POST /api/devices and refreshes the list
|
||||||
|
- Each device row shows a remove button that calls DELETE /api/devices/{id}
|
||||||
|
- CSS variable --accent: #00ffff exists as the single source of truth for the cyan accent color
|
||||||
|
- All four panel IDs (devices-panel, animations-panel, timeline-panel, transport-panel) are present in index.html
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user