diff --git a/CLAUDE.md b/CLAUDE.md
index 1358b2f..5c830d6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -130,6 +130,41 @@ is not on the screen, peers cannot dox each other. Candidate types
`rem.address` / `rem.port` are not. The WebRTC stack already obfuscates host
candidates via mDNS by default — do not undo that work in the UI.
+### CSS layout — grid only, no flex
+
+All page layout on every zebra page is **CSS grid**. No `display: flex` for
+layout. Reasons we settled on this:
+
+- Grid lets us pin children to explicit columns (`grid-column: 1/2/3`) so
+ a `display:none` on one child never causes siblings to slide into its
+ slot. Flex auto-reorders, grid does not.
+- One mental model for both axes. Flex needs rules per row + per item,
+ grid expresses the same intent in one `grid-template-*` block.
+- A global `.hidden { display: none !important }` utility lives in the
+ page CSS — combined with explicit grid placement it produces a layout
+ that survives any child being toggled in or out.
+
+When refactoring or adding UI, use:
+
+- `display: grid` + `grid-template-columns` for column layout
+- `grid-column: N` on every child of a grid so its position is explicit
+- `grid-template-rows` + `grid-row` for vertical placement when needed
+- `grid-template-areas` for small named-region layouts
+- `gap` for spacing (instead of margins)
+
+Avoid:
+
+- `display: flex` on any container that arranges multiple elements
+ horizontally or vertically as part of the page layout
+- Implicit positioning that relies on DOM order — always set
+ `grid-column` (and `grid-row` if relevant) on every grid child
+- `flex-basis` / `flex-grow` mathematics — `1fr` is the equivalent and
+ reads cleaner
+
+If you need a one-off horizontal alignment of two short inline things
+(e.g. a label + a value), grid still works fine
+(`grid-template-columns: auto 1fr`). Don't reach for flex.
+
### Web page integrity stamping
Each deployed page (`web/chat.html`, `web/zebra-audio.html`, `web/how-it-works.html`,
diff --git a/web/chat.html b/web/chat.html
index dd3666e..1116e7c 100644
--- a/web/chat.html
+++ b/web/chat.html
@@ -53,11 +53,16 @@
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
- .row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
+ .row {
+ display: grid; grid-auto-flow: column;
+ grid-template-columns: max-content minmax(0, 1fr);
+ grid-auto-columns: max-content;
+ align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;
+ }
.dot {
width: 9px; height: 9px; border-radius: 50%;
border: 1px solid #000; background: #fff;
- flex-shrink: 0; transition: background 0.15s;
+ transition: background 0.15s;
}
.dot.on { background: #000; }
.dot.warn { background: #888; }
@@ -71,8 +76,11 @@
}
input[type=text], input[type=password], select { width: 100%; }
textarea { width: 100%; resize: vertical; min-height: 4.5rem; }
- .field-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
- .field-row > input, .field-row > textarea { flex: 1; }
+ .field-row {
+ display: grid; grid-template-columns: minmax(0, 1fr) max-content;
+ gap: 0.5rem; margin-bottom: 0.5rem;
+ }
+ .field-row > input, .field-row > textarea { width: 100%; min-width: 0; }
.key-display {
border: 1px solid #ccc; padding: 0.5rem;
font-family: monospace; font-size: 0.7rem;
@@ -127,7 +135,8 @@
}
details[open] > summary { margin-bottom: 0.5rem; }
.mode-toggle {
- display: inline-flex; border: 1px solid #000;
+ display: inline-grid; grid-auto-flow: column; grid-auto-columns: max-content;
+ border: 1px solid #000;
font-size: 0.78rem; user-select: none;
}
.mode-toggle > label {
@@ -152,11 +161,14 @@
.share-box .qr-canvas {
width: 320px; height: 320px;
border: 1px solid #ccc; background: #fff;
- display: flex; align-items: center; justify-content: center;
+ display: grid; place-items: center;
font-size: 0.65rem; color: #999;
}
.share-box input { width: 100%; font-size: 0.7rem; }
- .share-box .copy-row { display: flex; gap: 0.4rem; margin-top: 0.3rem; }
+ .share-box .copy-row {
+ display: grid; grid-template-columns: minmax(0, 1fr) max-content;
+ gap: 0.4rem; margin-top: 0.3rem;
+ }
.share-box .copy-row button { font-size: 0.75rem; padding: 0.25rem 0.7rem; }
@media (max-width: 600px) {
.share-box { grid-template-columns: 1fr; }
@@ -250,7 +262,7 @@ try {
identity
-
+
@@ -269,7 +281,7 @@ try {
room (passphrase mode)
+ style="width:100%; min-width:0">
@@ -307,12 +319,12 @@ try {
@@ -1720,8 +1732,8 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
diff --git a/web/zebra-audio.html b/web/zebra-audio.html
index f2d64da..a057208 100644
--- a/web/zebra-audio.html
+++ b/web/zebra-audio.html
@@ -39,14 +39,19 @@
button:disabled { opacity: 0.3; cursor: default; }
button.invert { background: #000; color: #fff; }
button.invert:hover:not(:disabled) { background: #333; }
- .row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
+ .row {
+ display: grid; grid-auto-flow: column;
+ grid-template-columns: max-content minmax(0, 1fr);
+ grid-auto-columns: max-content;
+ align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;
+ }
input[type=text] {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
- padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0;
+ padding: 0.45rem; background: #fff; color: #000; min-width: 0; width: 100%;
}
select {
font-family: monospace; font-size: 0.9rem; border: 1px solid #000;
- padding: 0.45rem; background: #fff; color: #000; flex: 1; min-width: 0; cursor: pointer;
+ padding: 0.45rem; background: #fff; color: #000; min-width: 0; width: 100%; cursor: pointer;
}
.dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #000; background: #fff; flex-shrink: 0; }
.dot.ok { background: #060; border-color: #060; }
@@ -703,7 +708,7 @@ else wirePuppet();
-
+
puppet
@@ -727,8 +732,8 @@ else wirePuppet();
diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 5ef5425..067a19c 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -101,23 +101,33 @@
* Both columns get min-width:0 so they shrink without forcing a
* horizontal scrollbar. */
/* three columns: cameras (left) | timeline/screen (middle) | controls (right).
- * The 220px cameras track stays reserved even when no camera is live —
- * .hidden hides the section content but the column itself stays put so
- * the timeline (with the game iframe or a screen share) keeps its
- * middle position instead of sliding leftmost. */
+ * Each child is pinned to its column with grid-column so any one of
+ * them going display:none can't cause the others to slide. Controls
+ * column is also clamped to ≤50vw so a very wide window doesn't let
+ * the side panel eat the screen-share area. */
.page {
display: grid;
- grid-template-columns: 220px minmax(0, 1fr) minmax(0, 360px);
+ grid-template-columns: 220px minmax(0, 1fr) minmax(0, min(360px, 50vw));
gap: 1.25rem;
align-items: start;
}
+ .cameras-col { grid-column: 1; }
+ .timeline { grid-column: 2; }
+ .controls { grid-column: 3; }
.cameras-col {
min-width: 0;
border-right: 1px solid #ddd; padding-right: 1.25rem;
- display: flex; flex-direction: column;
+ display: grid !important; /* beat the global .hidden util */
+ grid-auto-rows: max-content;
+ gap: 0.5rem;
}
+ /* the cameras-col uses the .hidden class as a content-empty signal —
+ * keep the column visible (track reserved) but hide its h2 + grid
+ * and drop the right-border/padding so the empty column reads as
+ * dead space rather than a divider for nothing */
.cameras-col.hidden h2,
.cameras-col.hidden #cameras { display: none; }
+ .cameras-col.hidden { border-right: none; padding-right: 0; }
/* hide-panel = focus mode: both side columns fold away, screen+timeline
* fills the full viewport. Cameras and controls both go to display:none
* so the screen-share / game iframe has nothing competing with it. */
@@ -138,10 +148,14 @@
html.controls-collapsed-pref .page > .timeline { border-right: none; padding-right: 0; }
.timeline {
border-right: 1px solid #ddd; padding-right: 1.25rem; min-height: 60vh;
- min-width: 0; display: flex; flex-direction: column; position: relative;
+ min-width: 0; display: grid; grid-auto-rows: max-content;
+ position: relative;
}
.controls { min-width: 0; }
- .game-tabs { display: flex; gap: 0.4rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
+ .game-tabs {
+ display: grid; grid-auto-flow: column; grid-auto-columns: max-content;
+ gap: 0.4rem; margin-bottom: 0.5rem;
+ }
.game-tabs button {
background: #fff; color: #000; border: 1px solid #000;
padding: 0.3rem 0.8rem; font-family: monospace; font-size: 0.8rem; cursor: pointer;
@@ -149,19 +163,20 @@
.game-tabs button.active { background: #000; color: #fff; }
.game-tabs button:hover:not(.active) { background: #f0f0f0; }
.timeline-frame {
- flex: 1; min-height: 80vh; width: 100%;
+ min-height: 80vh; width: 100%;
border: 1px solid #ddd; background: #fff;
}
@media (max-width: 800px) {
body { padding: 1.25rem; }
- /* stack on mobile: controls first, then any live screen, cameras last */
+ /* mobile: single column, all three sections stack to grid-column:1 */
.page { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
- .controls { order: 1; }
- .timeline { order: 2;
- border-right: none; border-top: 1px solid #ddd;
- padding-right: 0; padding-top: 1rem; min-height: 0; }
- .cameras-col { order: 3;
- width: 100%; border-right: none; border-top: 1px solid #ddd;
+ .controls, .timeline, .cameras-col { grid-column: 1; }
+ .controls { grid-row: 1; }
+ .timeline { grid-row: 2;
+ border-right: none; border-top: 1px solid #ddd;
+ padding-right: 0; padding-top: 1rem; min-height: 0; }
+ .cameras-col { grid-row: 3;
+ border-right: none; border-top: 1px solid #ddd;
padding-right: 0; padding-top: 1rem; }
#cameras { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
}
@@ -176,14 +191,14 @@
#screens { display: grid; grid-template-columns: 1fr; gap: 0.75rem; }
.screen-tile {
border: 1px solid #000; background: #000; padding: 0;
- display: flex; flex-direction: column; position: relative;
+ display: grid; grid-auto-rows: max-content; position: relative;
}
.screen-tile video {
width: 100%; height: auto; max-height: 92vh; display: block; background: #000;
}
.screen-tile .screen-meta {
background: #111; color: #ddd; font-size: 0.7rem; padding: 0.3rem 0.5rem;
- display: flex; justify-content: space-between;
+ display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center;
}
/* Firefox Android rejects MediaStream