Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecc39efe2 | ||
|
|
10ac7d1e09 | ||
|
|
706cab2362 | ||
|
|
35ef54e248 |
@@ -140,8 +140,8 @@ below. Nothing is installed without someone clicking.
|
||||
sit flush — when two share a vertical edge, hover the seam and the same
|
||||
kind of pill appears; drag it to trade width (macOS split-view style).
|
||||
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
||||
green tab brings it back, minus the Admin button), the occupied columns
|
||||
stretch to the full width, and every column runs to the
|
||||
green lip at the top-left brings it back, minus the Admin button), the
|
||||
occupied columns stretch to the full width, and every column runs to the
|
||||
bottom of the screen — the last tile in each column is stretched to end
|
||||
there. A short tile like the clock is never stretched into a ribbon: under
|
||||
another tile it slides to the bottom edge and the tile above grows into the
|
||||
@@ -150,9 +150,11 @@ below. Nothing is installed without someone clicking.
|
||||
its normal row height and scrolls.
|
||||
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||
for a clean wall-display look. The menu bar has the same kind of tab —
|
||||
the green one under the ProdDash name, kept at the left so it never sits
|
||||
on top of a tile's own tab.
|
||||
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||
name itself**, wrapped in a translucent green pill at the left — click it
|
||||
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||
the bar back. It stays at the left so it never sits on top of a tile's
|
||||
own tab.
|
||||
- The **gear** on a tile holds per-tile settings (filters, text size,
|
||||
display options) — these belong to this browser's layout, not the server.
|
||||
- The **grid icon** — save the current arrangement to the server under a
|
||||
@@ -166,8 +168,10 @@ below. Nothing is installed without someone clicking.
|
||||
|
||||
- **ProdCom** — live transcript stream with in-place updates of in-progress
|
||||
speech, channel colors, per-tile channel visibility toggles and group
|
||||
presets, timestamps, text size and jump-to-latest in the title bar.
|
||||
presets, timestamps, text size, a top-down / bottom-up direction toggle
|
||||
and jump-to-latest in the title bar.
|
||||
Admin config: ProdCom server (host : port), API key.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
Admin config: ProPresenter endpoint (host : port), password.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Ported from prodcom-listener/public/app.js into the ProdDash module
|
||||
contract: everything lives inside the tile's root, per-tile state
|
||||
(hidden channels, timestamps, text size) persists in instance settings,
|
||||
and all network traffic goes through moduleApi (the module's server
|
||||
proxy at /prodcom/*). */
|
||||
(hidden channels, channel icons, timestamps, text size, flow direction)
|
||||
persists in instance settings, and all network traffic goes through
|
||||
moduleApi (the module's server proxy at /prodcom/*). */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
/* ── per-instance state ─────────────────────────────────────────── */
|
||||
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
|
||||
const channels = new Map(); // channelId -> {name, color}
|
||||
const groups = new Map(); // groupId -> {name, channelIds}
|
||||
const entryEls = new Map(); // entryId -> element
|
||||
let pinnedToBottom = true;
|
||||
let pinnedToLatest = true;
|
||||
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
||||
let stream = null; // moduleApi.sse handle
|
||||
let bootTimer = null;
|
||||
@@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) {
|
||||
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
|
||||
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
|
||||
const CHANNELS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
|
||||
// arrow shows where new messages land: down = oldest first, up = newest first
|
||||
const FLOW_DOWN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
||||
const FLOW_UP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
||||
|
||||
let channelMenuEl = null; // the open menu's element (filled on each open)
|
||||
const channelsMenu = moduleApi.header.addMenu({
|
||||
label: 'Channels ▾',
|
||||
icon: CHANNELS_SVG,
|
||||
title: 'Choose visible channels',
|
||||
build(menu) {
|
||||
menu.classList.add('pt-menu');
|
||||
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
|
||||
},
|
||||
});
|
||||
channelsMenu.button.classList.add('pt-channels-btn');
|
||||
// the icon carries the label; a count rides alongside it while filtering
|
||||
const channelCountEl = document.createElement('span');
|
||||
channelCountEl.className = 'pt-count';
|
||||
channelCountEl.hidden = true;
|
||||
channelsMenu.button.appendChild(channelCountEl);
|
||||
|
||||
let timeBtn = null;
|
||||
timeBtn = moduleApi.header.addButton({
|
||||
@@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||
|
||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||
const flowBtn = moduleApi.header.addButton({
|
||||
icon: FLOW_DOWN_SVG,
|
||||
onClick() {
|
||||
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
|
||||
applyInstanceSettings();
|
||||
// the two directions have opposite "latest" ends — follow the flip
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
},
|
||||
});
|
||||
|
||||
moduleApi.header.addButton({
|
||||
icon: JUMP_SVG,
|
||||
title: 'Jump to latest',
|
||||
onClick() {
|
||||
pinnedToBottom = true;
|
||||
pinnedToLatest = true;
|
||||
jumpBtn.hidden = true;
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) {
|
||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||
wrap.style.setProperty('--pt-size', size + 'px');
|
||||
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
||||
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
|
||||
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
||||
|
||||
const up = newestFirst();
|
||||
wrap.classList.toggle('newest-first', up);
|
||||
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
||||
flowBtn.classList.toggle('active', up);
|
||||
flowBtn.title = up
|
||||
? 'Newest at top — click for newest at bottom'
|
||||
: 'Newest at bottom — click for newest at top';
|
||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||
}
|
||||
applyInstanceSettings();
|
||||
|
||||
/* ── channel filter (instance setting) ──────────────────────────── */
|
||||
|
||||
function channelFilter() {
|
||||
return String(moduleApi.instanceSettings.channel || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Entry hidden by the hard per-tile filter (settings.channel)? */
|
||||
function filteredOut(channelId, channelName) {
|
||||
const want = channelFilter();
|
||||
if (!want) return false;
|
||||
const known = channels.get(channelId);
|
||||
const name = ((known && known.name) || channelName || '').toLowerCase();
|
||||
return name !== want && String(channelId).toLowerCase() !== want;
|
||||
}
|
||||
|
||||
/* ── channel visibility toggles ─────────────────────────────────── */
|
||||
|
||||
function persistHidden() {
|
||||
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function applyFilter(el) {
|
||||
const hidden = hiddenChannels.has(el.dataset.channelId)
|
||||
|| filteredOut(el.dataset.channelId, el.dataset.channelName);
|
||||
el.classList.toggle('hidden-by-filter', hidden);
|
||||
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|
||||
}
|
||||
|
||||
function toggleChannel(id) {
|
||||
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
|
||||
persistHidden();
|
||||
rebuildChannelMenu();
|
||||
entryEls.forEach((el) => applyFilter(el));
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
}
|
||||
|
||||
/** Show exactly this group's channels, hide the rest */
|
||||
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
|
||||
persistHidden();
|
||||
rebuildChannelMenu();
|
||||
entryEls.forEach((el) => applyFilter(el));
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
}
|
||||
|
||||
function groupIsActive(g) {
|
||||
@@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) {
|
||||
);
|
||||
}
|
||||
|
||||
function updateChannelsLabel() {
|
||||
/* The button is an icon, so the "some channels are hidden" state shows as
|
||||
an accent tint plus a compact count beside it. */
|
||||
function updateChannelsButton() {
|
||||
let visibleCount = 0;
|
||||
for (const id of channels.keys()) {
|
||||
if (!hiddenChannels.has(id)) visibleCount++;
|
||||
}
|
||||
channelsMenu.setLabel(visibleCount === channels.size
|
||||
? 'Channels ▾'
|
||||
: `Channels (${visibleCount}/${channels.size}) ▾`);
|
||||
const filtering = channels.size > 0 && visibleCount !== channels.size;
|
||||
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
||||
channelCountEl.hidden = !filtering;
|
||||
channelsMenu.button.classList.toggle('active', filtering);
|
||||
channelsMenu.button.title = filtering
|
||||
? `Channels — showing ${visibleCount} of ${channels.size}`
|
||||
: 'Choose visible channels';
|
||||
}
|
||||
|
||||
function rebuildChannelMenu() {
|
||||
updateChannelsLabel();
|
||||
updateChannelsButton();
|
||||
const channelMenu = channelMenuEl;
|
||||
if (!channelMenu) return; // menu not opened yet — the label is enough
|
||||
channelMenu.innerHTML = '';
|
||||
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* ── scrolling ──────────────────────────────────────────────────── */
|
||||
|
||||
/** Newest entry at the top (bottom-up) instead of at the bottom? */
|
||||
function newestFirst() {
|
||||
return Boolean(moduleApi.instanceSettings.newestFirst);
|
||||
}
|
||||
|
||||
streamEl.addEventListener('scroll', () => {
|
||||
const nearBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||
pinnedToBottom = nearBottom;
|
||||
if (nearBottom) jumpBtn.hidden = true;
|
||||
const near = newestFirst()
|
||||
? streamEl.scrollTop < 60
|
||||
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
||||
pinnedToLatest = near;
|
||||
if (near) jumpBtn.hidden = true;
|
||||
});
|
||||
|
||||
jumpBtn.addEventListener('click', () => {
|
||||
pinnedToBottom = true;
|
||||
pinnedToLatest = true;
|
||||
jumpBtn.hidden = true;
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
streamEl.scrollTop = streamEl.scrollHeight;
|
||||
/** The latest entry is at the top when flipped, at the bottom otherwise. */
|
||||
function scrollToLatest() {
|
||||
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
|
||||
}
|
||||
|
||||
function afterInsert(el) {
|
||||
if (el.classList.contains('hidden-by-filter')) return;
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
else jumpBtn.hidden = false;
|
||||
}
|
||||
|
||||
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'pt-entry';
|
||||
el.dataset.channelId = entry.channelId || '';
|
||||
el.dataset.channelName = entry.channelName || '';
|
||||
const info = channelInfo(entry);
|
||||
el.style.setProperty('--ch', info.color);
|
||||
|
||||
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
|
||||
afterInsert(el);
|
||||
} else {
|
||||
updateEntryEl(el, entry);
|
||||
if (pinnedToBottom && !el.classList.contains('hidden-by-filter')) scrollToBottom();
|
||||
if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
|
||||
bootTimer = setTimeout(boot, 5000);
|
||||
return;
|
||||
}
|
||||
scrollToBottom();
|
||||
scrollToLatest();
|
||||
connect();
|
||||
}
|
||||
|
||||
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
|
||||
root.innerHTML = ''; // header controls are removed by the shell
|
||||
},
|
||||
onResize() {
|
||||
if (pinnedToBottom) scrollToBottom();
|
||||
if (pinnedToLatest) scrollToLatest();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"proddash": ">=1.1.0",
|
||||
"description": "Live ProdCom transcript stream",
|
||||
"client": "client.js",
|
||||
@@ -22,15 +22,15 @@
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"label": "Channel filter (channel name; empty = all)",
|
||||
"default": ""
|
||||
"showAvatars": {
|
||||
"type": "boolean",
|
||||
"label": "Show channel icons",
|
||||
"default": true
|
||||
},
|
||||
"textSize": {
|
||||
"type": "number",
|
||||
"label": "Text size (px)",
|
||||
"default": 15
|
||||
"newestFirst": {
|
||||
"type": "boolean",
|
||||
"label": "Newest first (transcript reads bottom-up)",
|
||||
"default": false
|
||||
},
|
||||
"showTimes": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── channels button ──
|
||||
Icon-only; the count appears next to it while some channels are hidden. */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-channels-btn { gap: 3px; }
|
||||
[data-module="prodcom-transcript"] .pt-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||
|
||||
/* ── channels menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it lives inside the tile, so [data-module] scoping still applies. */
|
||||
@@ -113,6 +124,7 @@
|
||||
background: color-mix(in srgb, var(--ch, var(--muted)) 17%, transparent);
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-entry .avatar svg { width: 66%; height: 66%; display: block; }
|
||||
[data-module="prodcom-transcript"] .hide-avatars .pt-entry .avatar { display: none; }
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-entry .body { flex: 1; min-width: 0; }
|
||||
|
||||
@@ -143,6 +155,12 @@
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-entry.hidden-by-filter { display: none; }
|
||||
|
||||
/* bottom-up: newest entry at the top, the stream reading upwards */
|
||||
[data-module="prodcom-transcript"] .newest-first .pt-entries {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* ── jump-to-latest ── */
|
||||
|
||||
[data-module="prodcom-transcript"] .pt-jump {
|
||||
@@ -163,3 +181,9 @@
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
[data-module="prodcom-transcript"] .pt-jump[hidden] { display: none; }
|
||||
|
||||
/* flipped, "latest" is the top of the stream — so is the pill */
|
||||
[data-module="prodcom-transcript"] .newest-first .pt-jump {
|
||||
top: 12px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
+3
-2
@@ -15,7 +15,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<header id="topbar">
|
||||
<span id="brand">Prod<span class="accent">Dash</span></span>
|
||||
<!-- invisible twin of the logo: holds its place in the bar; the real logo is the handle below -->
|
||||
<span id="brand" class="brand-spacer" aria-hidden="true">Prod<span class="accent">Dash</span></span>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar"></button>
|
||||
<button id="topbar-notch" title="Hide / show the menu bar" aria-label="Hide or show the menu bar">Prod<span class="accent">Dash</span></button>
|
||||
|
||||
<main id="gridwrap">
|
||||
<div id="grid">
|
||||
|
||||
+38
-20
@@ -54,36 +54,54 @@ body.menu-hidden #topbar {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The menubar's own show/hide tab. It sits under the ProdDash brand at the
|
||||
left — not screen-centre, where a tile's own (centred) tab would land
|
||||
right under it once both bars are hidden — and wears the accent colour
|
||||
so it never reads as a tile tab. */
|
||||
/* The menubar's show/hide handle IS the logo: a translucent accent pill
|
||||
wrapped around "ProdDash" at the left of the bar, "Dash" cut out in the
|
||||
background colour. It sits at the left — never screen-centre, where a
|
||||
tile's own (centred) tab lands once both bars are hidden. When the bar
|
||||
hides, the pill shrinks to a slim lip at the very top edge, clear of a
|
||||
top-row tile's status dot and title. */
|
||||
.brand-spacer { visibility: hidden; }
|
||||
|
||||
#topbar-notch {
|
||||
position: fixed;
|
||||
/* straddles the bar's bottom edge — mostly on the bar, a short lip below
|
||||
it — so it never covers a top-row tile's status dot or title */
|
||||
top: calc(var(--topbar-h, 46px) - 8px);
|
||||
left: 12px;
|
||||
width: 54px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: var(--accent);
|
||||
/* a tab hung from the top of the bar: square top, rounded bottom, hanging
|
||||
8px past the bar's bottom edge; the logo sits centred in the bar part */
|
||||
top: 0;
|
||||
left: 2px; /* the logo text starts at the bar's 12px padding; the tab adds 10px each side */
|
||||
height: calc(var(--topbar-h, 46px) + 8px);
|
||||
min-width: 60px;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: var(--topbar-h, 46px);
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
border: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
cursor: pointer;
|
||||
z-index: 60;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease, top 0.22s ease, height 0.22s ease;
|
||||
transition: height 0.22s ease, line-height 0.22s ease, font-size 0.22s ease, background 0.15s ease;
|
||||
}
|
||||
#topbar-notch:hover { opacity: 1; }
|
||||
/* bar hidden: a slim tab at the very top edge, clear of a top-row tile's dot and title */
|
||||
body.menu-hidden #topbar-notch { top: 0; height: 10px; }
|
||||
#topbar-notch::after {
|
||||
#topbar-notch .accent { color: var(--bg); } /* the cutout */
|
||||
#topbar-notch:hover { background: color-mix(in srgb, var(--accent) 68%, transparent); }
|
||||
/* bar hidden: the text folds away and the tab becomes a lip at the top edge */
|
||||
body.menu-hidden #topbar-notch {
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
font-size: 0;
|
||||
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
body.menu-hidden #topbar-notch:hover { background: var(--accent); }
|
||||
body.menu-hidden #topbar-notch::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
margin: 4px auto 0;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user