Compare commits
20 Commits
mai/picass
...
mai/miro/r
| Author | SHA1 | Date | |
|---|---|---|---|
| fee9bc5d26 | |||
| 8df5de193a | |||
| a675c499c3 | |||
| 78bce498b4 | |||
| 359ed892ac | |||
| 0ecd9c8b4a | |||
| fca9fb0a0f | |||
| 40ab3d2630 | |||
| 17e6b5e91c | |||
| 9107a9f7b2 | |||
| 89686d0c1f | |||
| 57a9154f18 | |||
| 6c31802522 | |||
| 46e8474c2b | |||
| 9aa395854d | |||
| f08c48e9b5 | |||
| 6cd5925f4c | |||
| 9773063008 | |||
| 61bc1dcf43 | |||
| 056777f1c1 |
@@ -15,7 +15,7 @@ data
|
||||
|
||||
# Build artefacts
|
||||
bin
|
||||
mcables
|
||||
/mcables
|
||||
|
||||
# Editor cruft
|
||||
.vscode
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,7 +8,7 @@ data/*.db-shm
|
||||
|
||||
# Build artefacts
|
||||
bin/
|
||||
mcables
|
||||
/mcables
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
|
||||
64
cmd/mcables/main.go
Normal file
64
cmd/mcables/main.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/mcables/internal/db"
|
||||
"mgit.msbls.de/m/mcables/internal/server"
|
||||
"mgit.msbls.de/m/mcables/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := envOr("MCABLES_ADDR", "0.0.0.0:7777")
|
||||
dbPath := envOr("MCABLES_DB", "./data/mcables.db")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
log.Fatalf("mkdir data dir: %v", err)
|
||||
}
|
||||
|
||||
store, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := db.Migrate(store.DB()); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: server.New(store, web.Static()),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("mcables listening on %s (db=%s)", addr, dbPath)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
226
docs/design.md
226
docs/design.md
@@ -1438,4 +1438,228 @@ gitignored.
|
||||
|
||||
---
|
||||
|
||||
DESIGN v4.1 READY FOR REVIEW
|
||||
## 11. v5 — Cable routing via clamps
|
||||
|
||||
m's bundling primitive: a **clamp** is a physical anchor on the canvas
|
||||
(think cable tie / clip). A cable routes from its `from` endpoint,
|
||||
through zero or more clamps **in order**, to its `to` endpoint. Two
|
||||
cables that share an ordered pair of consecutive clamps are visibly
|
||||
bundled along that segment — no detection pass, no inference: the
|
||||
overlap *is* the bundle.
|
||||
|
||||
This replaces the abandoned waypoints + segment-detection approach.
|
||||
v0's straight-line schematic stays as the empty-clamps case
|
||||
(`cable_clamps` is empty for a fresh solver-emitted cable).
|
||||
|
||||
### 11.1 Schema (migration 007)
|
||||
|
||||
```sql
|
||||
CREATE TABLE clamps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
|
||||
excalidraw_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (project_id, excalidraw_id)
|
||||
);
|
||||
CREATE INDEX clamps_project_idx ON clamps(project_id);
|
||||
|
||||
CREATE TABLE cable_clamps (
|
||||
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
|
||||
clamp_id INTEGER NOT NULL REFERENCES clamps(id) ON DELETE CASCADE,
|
||||
ord INTEGER NOT NULL, -- 1..N along from→to
|
||||
PRIMARY KEY (cable_id, ord),
|
||||
UNIQUE (cable_id, clamp_id) -- a cable can't visit the same clamp twice
|
||||
);
|
||||
CREATE INDEX cable_clamps_clamp_idx ON cable_clamps(clamp_id);
|
||||
```
|
||||
|
||||
`frame_id` on clamps mirrors devices + IO markers — m can put a clamp
|
||||
inside a frame and the frame-drag carries it.
|
||||
|
||||
`UNIQUE (cable_id, clamp_id)` blocks loops. `ord` is a small int, 1-based;
|
||||
nothing requires it to be contiguous (m can renumber 1, 2, 3 → 1, 3, 5
|
||||
during edits and the renderer is fine with that), but the UI keeps them
|
||||
contiguous on every mutation for sanity.
|
||||
|
||||
### 11.2 Cable rendering model
|
||||
|
||||
Each cable resolves to a polyline `[from-anchor, clamp₁, clamp₂, …, clampₙ, to-anchor]`
|
||||
where:
|
||||
- `from-anchor` / `to-anchor` come from the existing `anchorForEndpoint`
|
||||
resolver (port / device / IO).
|
||||
- clamp anchors are `(clamp.x, clamp.y)` directly — clamps don't have a
|
||||
width/height to centre.
|
||||
|
||||
For N=0 clamps the result is the v0 straight line. For N≥1 we render
|
||||
a `<polyline>` instead of a `<line>`.
|
||||
|
||||
The endpoint-replug handles from §10 (cable-replug) stay on the **first
|
||||
and last** vertices. Mid-polyline vertices get their own clamp-handle —
|
||||
small grab points only on the selected cable, which behave like
|
||||
clamp-detach when dragged onto empty canvas (drop a clamp off the
|
||||
cable's path).
|
||||
|
||||
### 11.3 Bundle visualisation — derived from shared segments
|
||||
|
||||
A **segment** is a directed pair `(A, B)` where A and B are consecutive
|
||||
nodes of a cable's polyline. Two cables share a segment when their
|
||||
polyline contains the same A→B (or B→A — segment matching is
|
||||
undirected).
|
||||
|
||||
For each segment, compute `cables[]` — the cables that traverse it.
|
||||
If `len(cables) ≥ 2`, render the segment as a single thick line on top
|
||||
of the individual ones:
|
||||
|
||||
- **Width**: `2 + N` px (N = cable count). Caps at ~12 px.
|
||||
- **Colour**: a striped pattern, one stripe per distinct cable type in
|
||||
the bundle, ordered by cable_type.id. SVG `<linearGradient>` with
|
||||
hard stops produces the stripe band cheaply; render it on a sibling
|
||||
`<polyline>` over the individual lines.
|
||||
- **Tooltip**: `<title>` child listing the cables ("Power · USB · HDMI").
|
||||
|
||||
At a clamp where ≥ 2 cables meet, the clamp icon (10×10 rounded square)
|
||||
shows a small count badge (`×N`) when N > 1. At fan-out points
|
||||
(endpoint with no clamp before it on the polyline) the individual
|
||||
coloured lines re-emerge, so m sees which port each strand goes to.
|
||||
|
||||
Shared-segment computation is O(C·N̄) where C = #cables and N̄ = average
|
||||
polyline length. For a v0-sized project (≤ ~30 cables, ≤ ~5 clamps per
|
||||
cable) this is trivial. We rebuild the segment map on every renderCanvas
|
||||
— no caching layer.
|
||||
|
||||
### 11.4 UI gestures
|
||||
|
||||
**+ Clamp tool (`C` shortcut, also a sidebar button):**
|
||||
- Click empty canvas → place a clamp at the cursor (POST `/clamps`).
|
||||
Standalone clamp — not on any cable yet.
|
||||
- Click a cable line → insert this clamp into that cable. The new clamp
|
||||
sits at the click position (snapped to the nearest point on the
|
||||
cable's polyline) and its `ord` is computed so it falls between the
|
||||
two existing vertices it lies between.
|
||||
|
||||
**Drag a cable's mid-segment:**
|
||||
- Pointerdown on a cable line (not on an endpoint handle) and drag.
|
||||
Live preview shows a bend at the cursor. Pointerup:
|
||||
- If the cursor is within snap-radius (~16 px) of an existing clamp:
|
||||
insert that clamp into the cable's polyline at the right `ord`.
|
||||
- Otherwise: create a fresh clamp at the release point and insert it.
|
||||
|
||||
**Clamp inspector** (selecting a clamp on the canvas):
|
||||
- Position (x, y editable + label)
|
||||
- "Cables through this clamp": list with each cable's two endpoints,
|
||||
click → select that cable
|
||||
- "Remove from this cable" (per row) → DELETE the matching cable_clamps
|
||||
row; cable's polyline collapses around the gap.
|
||||
- "Delete clamp" → cascade-removes from every cable_clamps row.
|
||||
|
||||
**Right-click on a clamp icon ON a cable** → "Remove from this cable"
|
||||
inline.
|
||||
|
||||
**Frame drag** carries clamps the same way it carries devices + IO
|
||||
markers (clamp.frame_id mirrors the existing pattern, drag handler
|
||||
already iterates frame-contained items).
|
||||
|
||||
### 11.5 Relationship to the existing `bundles` table
|
||||
|
||||
**Recommendation: keep `bundles` and `bundle_cables`, repurpose them.**
|
||||
|
||||
- Implicit/auto bundles → derived live from shared clamp segments. No
|
||||
DB rows. The §5 `GET /bundles/suggestions` endpoint stays useful as a
|
||||
"you might want to route these through the same clamps" hint.
|
||||
- Explicit named bundles → still in the `bundles` table. m names a
|
||||
group ("desk → wall trunk"), the UI offers "route all members through
|
||||
these clamps" as a one-click action. Useful for the case where m
|
||||
wants a stable label on a logical bundle that isn't yet routed.
|
||||
|
||||
Migration 007 leaves `bundles` + `bundle_cables` untouched. A v6 cleanup
|
||||
can drop them if m decides the explicit-named path isn't worth keeping.
|
||||
|
||||
### 11.6 Solver coupling
|
||||
|
||||
The v0 solver still emits **straight cables** — no clamp rows. m
|
||||
hand-routes after Solve. The solver's preview-diff is unaffected
|
||||
(solver compares endpoint pairs; clamp routing is independent of the
|
||||
endpoint identity).
|
||||
|
||||
Future v5.1: solver-suggested clamps based on shared paths between
|
||||
endpoint pairs. Out of scope here.
|
||||
|
||||
### 11.7 Export to mxdrw
|
||||
|
||||
Clamps map to small diamond elements (separate from IO markers — IO
|
||||
diamonds are red wall-outlets; clamps are grey routing points).
|
||||
`excalidraw_id` is stable across re-exports per the existing pattern.
|
||||
|
||||
Cable arrows become Excalidraw `arrow` elements with mid-points (the
|
||||
clamp positions) when N≥1 — Excalidraw supports multi-vertex arrows
|
||||
via the `points` array. Each `startBinding` / `endBinding` resolves to
|
||||
the from/to anchor's excalidraw_id; mid-vertices are unbound.
|
||||
|
||||
Bundle visualisation (thick striped lines on shared segments) is **not
|
||||
exported** in v0 — Excalidraw doesn't natively support gradient strokes,
|
||||
and the mxdrw round-trip would lose them. We export each cable as its
|
||||
own polyline; bundling is a viewer-only concept.
|
||||
|
||||
### 11.8 API additions
|
||||
|
||||
```
|
||||
POST /api/projects/:pid/clamps { x, y, label?, frame_id? } → Clamp
|
||||
PATCH /api/projects/:pid/clamps/:id { x?, y?, label?, frame_id? } → Clamp
|
||||
DELETE /api/projects/:pid/clamps/:id
|
||||
|
||||
POST /api/projects/:pid/cables/:cid/clamps { clamp_id, ord? } → CableClamp
|
||||
DELETE /api/projects/:pid/cables/:cid/clamps/:cmid
|
||||
|
||||
# Convenience: re-order clamps on a cable in one call
|
||||
PUT /api/projects/:pid/cables/:cid/clamps { clamp_ids: [int, int, …] }
|
||||
```
|
||||
|
||||
Snapshot endpoint grows two arrays:
|
||||
- `clamps: []Clamp`
|
||||
- `cable_clamps: []{ cable_id, clamp_id, ord }`
|
||||
|
||||
### 11.9 Open questions for m
|
||||
|
||||
1. **Clamp icon shape.** Diamond (overlaps visually with IO markers
|
||||
when zoomed out), small filled circle (overlaps with port circles),
|
||||
or rounded square `▢` 10×10? Recommend rounded square — distinct from
|
||||
everything else on the canvas today.
|
||||
2. **Snap radius when inserting onto a cable.** ~16 px world-units feels
|
||||
right at 1× zoom. Should it scale with zoom (visual constant) or stay
|
||||
world-constant (gesture stays the same regardless of zoom)? Recommend
|
||||
visual constant — divide by current zoom.
|
||||
3. **Clamp deletion when shared.** If a clamp is used by 4 cables and m
|
||||
clicks "Delete clamp", do we (a) refuse with a "still in use" prompt,
|
||||
(b) cascade-remove from all 4 cables, or (c) cascade silently? Current
|
||||
draft says cascade silently. Worth a confirmation?
|
||||
4. **Bundle stripe order.** Cable-type id is stable but arbitrary; visual
|
||||
order on a thick line affects readability. Order by stripe-count
|
||||
(Power first if 3 Power + 1 USB), or by cable-type-id (deterministic
|
||||
but unrelated to importance)? Recommend by-count, ties broken by id.
|
||||
5. **Solver respect for existing routing.** When m re-runs Solve after
|
||||
hand-routing, should the solver preserve existing clamp routing on
|
||||
user-owned (`auto=0`) cables? Auto cables are wiped + rebuilt, so
|
||||
their clamps disappear with them — that's expected. But manual cables
|
||||
with clamps should clearly keep them. Confirm.
|
||||
|
||||
### 11.10 Slice plan (post-design)
|
||||
|
||||
1. Schema migration + tx-aware store helpers (Create/Update/DeleteClamp,
|
||||
AttachClampToCable, DetachClampFromCable, ReorderClamps).
|
||||
2. HTTP endpoints + snapshot extension.
|
||||
3. Frontend: clamp render + + Clamp tool + canvas placement (no
|
||||
cable attach yet).
|
||||
4. Cable polyline render via clamps, mid-segment drag-to-clamp,
|
||||
clamp inspector.
|
||||
5. Shared-segment bundle visualisation (gradient stripe + count badge).
|
||||
6. Export pipeline extension — mxdrw arrows with mid-points + clamp
|
||||
diamonds. Bundle viz stays viewer-only.
|
||||
|
||||
---
|
||||
|
||||
DESIGN v5 READY FOR REVIEW
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
<button type="button" id="btn-apply-template" class="btn">Apply template…</button>
|
||||
<button type="button" id="btn-solve" class="btn btn-primary">Solve</button>
|
||||
<button type="button" id="btn-export" class="btn">Export</button>
|
||||
<button type="button" id="btn-admin" class="btn" title="Admin: projects, cable types, device types, setup templates">⚙ Admin</button>
|
||||
<span class="zoom-cluster">
|
||||
<span id="zoom-pct" title="Zoom — scroll on canvas, or 0/Home to reset">100%</span>
|
||||
<button type="button" id="btn-fit" class="btn btn-tiny" title="Fit content to view">Fit</button>
|
||||
</span>
|
||||
<span id="toast" class="toast" hidden></span>
|
||||
</header>
|
||||
|
||||
@@ -31,12 +36,6 @@
|
||||
<section class="legend">
|
||||
<h2 class="sidebar-heading">Cable types</h2>
|
||||
<ul id="legend-list" class="legend-list"></ul>
|
||||
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
|
||||
</section>
|
||||
<section class="requirements">
|
||||
<h2 class="sidebar-heading">Requirements</h2>
|
||||
<ul id="requirement-list" class="requirement-list"></ul>
|
||||
<button type="button" id="btn-add-requirement" class="btn btn-tiny">+ Requirement</button>
|
||||
</section>
|
||||
<section class="tools">
|
||||
<h2 class="sidebar-heading">Tools</h2>
|
||||
@@ -224,6 +223,24 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Admin: projects + cable types + device types + setup templates -->
|
||||
<dialog id="modal-admin" class="modal modal-wide" aria-labelledby="adm-title">
|
||||
<div class="admin-shell">
|
||||
<header class="admin-header">
|
||||
<h2 id="adm-title">Admin</h2>
|
||||
<button type="button" class="btn btn-link admin-close" data-close>✕</button>
|
||||
</header>
|
||||
<nav class="admin-tabs" role="tablist">
|
||||
<button type="button" class="admin-tab" data-admin-tab="projects" role="tab" aria-selected="true">Projects</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="cable-types" role="tab">Cable types</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="device-types" role="tab">Device types</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="setup-templates" role="tab">Setup templates</button>
|
||||
<button type="button" class="admin-tab" data-admin-tab="requirements" role="tab">Requirements</button>
|
||||
</nav>
|
||||
<section class="admin-body" id="admin-body" role="tabpanel"></section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1095
web/static/main.js
1095
web/static/main.js
File diff suppressed because it is too large
Load Diff
@@ -192,6 +192,18 @@ body {
|
||||
.device-rect.selected { stroke-width: 3; }
|
||||
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
|
||||
|
||||
/* Bottom-right resize affordance per device. Subtle grey by default,
|
||||
stronger on hover so m can find it without it dominating the rect. */
|
||||
.device-resize-handle {
|
||||
fill: rgba(120, 120, 120, 0.35);
|
||||
stroke: rgba(60, 60, 60, 0.45);
|
||||
stroke-width: 1;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.device-resize-handle:hover {
|
||||
fill: rgba(60, 60, 60, 0.65);
|
||||
}
|
||||
|
||||
.device-label {
|
||||
fill: var(--text);
|
||||
font-size: 12px;
|
||||
@@ -215,8 +227,6 @@ body {
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas *,
|
||||
.canvas-wrap.tool-port #canvas,
|
||||
.canvas-wrap.tool-port #canvas *,
|
||||
.canvas-wrap.tool-cable #canvas,
|
||||
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
|
||||
|
||||
@@ -237,6 +247,27 @@ body {
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
|
||||
/* Zoom cluster — % + Fit button next to Admin. */
|
||||
.zoom-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
#zoom-pct {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.canvas-wrap.panning #canvas,
|
||||
.canvas-wrap.panning #canvas * { cursor: grabbing !important; }
|
||||
.canvas-wrap.space-pan-ready #canvas,
|
||||
.canvas-wrap.space-pan-ready #canvas * { cursor: grab !important; }
|
||||
|
||||
/* Header toast — slice 8 export feedback */
|
||||
.toast {
|
||||
display: inline-block;
|
||||
@@ -296,8 +327,11 @@ body {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 2px 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.port-row:hover { background: var(--surface-2); }
|
||||
.port-row .swatch,
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
@@ -373,8 +407,121 @@ body {
|
||||
.cable-line:hover { stroke-width: 4; }
|
||||
.cable-line.selected { stroke-width: 4; }
|
||||
|
||||
/* Endpoint handles — only rendered for the currently-selected cable.
|
||||
Grab cursor on idle, grabbing while dragging (.replugging on root). */
|
||||
.cable-handle {
|
||||
cursor: grab;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.cable-handle:hover { stroke-width: 3; }
|
||||
.canvas-wrap.replugging .cable-handle,
|
||||
.canvas-wrap.replugging #canvas * { cursor: grabbing !important; }
|
||||
|
||||
/* Solve preview-diff modal */
|
||||
.modal-wide { width: 560px; }
|
||||
|
||||
/* Admin modal — wider, tabbed */
|
||||
.modal-wide.admin-shell-host { width: 760px; }
|
||||
#modal-admin { width: 760px; max-width: 90vw; }
|
||||
.admin-shell { padding: 16px; min-height: 460px; }
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-header h2 { margin: 0; }
|
||||
.admin-close { font-size: 16px; padding: 4px 8px; }
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-tab {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-tab:hover { color: var(--text); }
|
||||
.admin-tab[aria-selected="true"] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.admin-body {
|
||||
font-size: 13px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.admin-row {
|
||||
display: grid;
|
||||
gap: 6px 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-row:last-child { border-bottom: 0; }
|
||||
.admin-row .field { display: grid; grid-template-columns: 110px 1fr; align-items: center; }
|
||||
.admin-row .field span { color: var(--text-muted); font-size: 12px; }
|
||||
.admin-row .field input,
|
||||
.admin-row .field textarea,
|
||||
.admin-row .field select {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.admin-row .actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.admin-row.locked { opacity: 0.85; }
|
||||
.admin-row .locked-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.admin-row-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.admin-row-title .swatch { display: inline-block; }
|
||||
.admin-empty { color: var(--text-muted); padding: 16px 0; }
|
||||
.admin-add-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.port-profile-list {
|
||||
margin: 4px 0 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.port-profile-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.tmpl-detail {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tmpl-detail ul { margin: 4px 0 0 16px; padding: 0; }
|
||||
|
||||
.sv-body { font-size: 13px; }
|
||||
.sv-body h3 {
|
||||
font-size: 11px;
|
||||
|
||||
Reference in New Issue
Block a user