feat(editor): a DHCP server's two pages as a segmented control
"Grundeinstellungen" and "Statische Adresszuweisung" (Java's words) become the dashboard's segmented control: one frame, equal shares, the chosen one in the accent. Measured: "Grundeinstellungen" is 138 px at 14 px and a half of the 340 px sheet holds 139 (157 bold). So the words wrap at their spaces inside a segment, the chosen one is not bold (its width would jump), and where a segment's longest word with its padding no longer fits its share — a phone, another system font — the segments stack, still one control. Decided by measuring the words in the segments' font, not by a breakpoint that would hold for one font only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -271,6 +271,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `test/remoteBlobs.test.ts` | — | neu (F5.5): Hochladen, Wiederholen, Scheitern (auch bei falschem Namen), Holen je Blob einmal, nachfragen bei 404, Netzverlust |
|
| `test/remoteBlobs.test.ts` | — | neu (F5.5): Hochladen, Wiederholen, Scheitern (auch bei falschem Namen), Holen je Blob einmal, nachfragen bei 404, Netzverlust |
|
||||||
| `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b), erweitert (F5.5: ein großes Bild, von Mia importiert, bei Ben im Bildbetrachter, in F's Speicher unter seinem SHA-256): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt |
|
| `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b), erweitert (F5.5: ein großes Bild, von Mia importiert, bei Ben im Bildbetrachter, in F's Speicher unter seinem SHA-256): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt |
|
||||||
| `src/canvas/Canvas.tsx` (`hasMenu`), `src/Editor.tsx` (Aktionsmodus), `src/simulation/SimulationSheet.tsx`, `src/desktop/DesktopSheet.tsx` (vorher `DesktopWindow.tsx`), `src/simulation/ViewerSheet.tsx` (vorher `ViewerWindow.tsx`), `src/simulation/CaptureSheet.tsx` (vorher `CaptureWindow.tsx`), `src/desktop/desktop.css`, `src/i18n/*.json` (`menu.showSat`, `menu.showNat`, `sheet.seeThrough`), `browser/*.spec.ts` (`showFromMenu`, `showDesktop`), `tools/live/acceptance.ts` | Java `GUIEvents.kontextMenueAktionsmodus`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIDesktopWindow`, `SatViewer`, `NatViewer`, `AggregatedExchangeDialog` | geändert (F6.5, auf Wunsch des Nutzers): im Aktionsmodus wählt ein Tipp das Gerät nur aus, sein Dreipunkt-Knopf erscheint wie im Entwurf (nur wo das Menü etwas anbietet); geöffnet wird über das Menü — Desktop, „SAT Tabelle anzeigen“ am Switch, „NAT-Tabelle anzeigen“ am Heimrouter, Datenaustausch je Karte. Desktop, Tabellen und Datenaustausch sind keine Fenster im Fenster mehr, sondern das Blatt des Aktionsmodus nach dem Muster des Dashboard-Blatts (`AppSheet`): unter der Werkzeugleiste bis zum unteren Rand, Kopf mit Symbol, Titel, Anwesenden, „Durchsichtig“ und ×; eins zur Zeit (es deckt die Fläche, ein anderes öffnet man nach dem Schließen); ein Blatt, dessen Gerät entfernt wurde, schließt; es übersteht die Rückkehr in den Entwurf, ein Desktop behält sein offenes Programm. „Durchsichtig“ (Deckkraft 0,55, im Browser gemerkt) lässt das Netz durchscheinen. Das Geräteblatt bleibt im Aktionsmodus zu, die anderen sehen keinen Rahmen um ein dort gewähltes Gerät. Abweichend von Java: Java öffnet Desktop und Tabellen mit einem Klick aufs Gerät, als verschiebbare Fenster, mehrere zugleich, und sein Menü nur mit Rechtsklick |
|
| `src/canvas/Canvas.tsx` (`hasMenu`), `src/Editor.tsx` (Aktionsmodus), `src/simulation/SimulationSheet.tsx`, `src/desktop/DesktopSheet.tsx` (vorher `DesktopWindow.tsx`), `src/simulation/ViewerSheet.tsx` (vorher `ViewerWindow.tsx`), `src/simulation/CaptureSheet.tsx` (vorher `CaptureWindow.tsx`), `src/desktop/desktop.css`, `src/i18n/*.json` (`menu.showSat`, `menu.showNat`, `sheet.seeThrough`), `browser/*.spec.ts` (`showFromMenu`, `showDesktop`), `tools/live/acceptance.ts` | Java `GUIEvents.kontextMenueAktionsmodus`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIDesktopWindow`, `SatViewer`, `NatViewer`, `AggregatedExchangeDialog` | geändert (F6.5, auf Wunsch des Nutzers): im Aktionsmodus wählt ein Tipp das Gerät nur aus, sein Dreipunkt-Knopf erscheint wie im Entwurf (nur wo das Menü etwas anbietet); geöffnet wird über das Menü — Desktop, „SAT Tabelle anzeigen“ am Switch, „NAT-Tabelle anzeigen“ am Heimrouter, Datenaustausch je Karte. Desktop, Tabellen und Datenaustausch sind keine Fenster im Fenster mehr, sondern das Blatt des Aktionsmodus nach dem Muster des Dashboard-Blatts (`AppSheet`): unter der Werkzeugleiste bis zum unteren Rand, Kopf mit Symbol, Titel, Anwesenden, „Durchsichtig“ und ×; eins zur Zeit (es deckt die Fläche, ein anderes öffnet man nach dem Schließen); ein Blatt, dessen Gerät entfernt wurde, schließt; es übersteht die Rückkehr in den Entwurf, ein Desktop behält sein offenes Programm. „Durchsichtig“ (Deckkraft 0,55, im Browser gemerkt) lässt das Netz durchscheinen. Das Geräteblatt bleibt im Aktionsmodus zu, die anderen sehen keinen Rahmen um ein dort gewähltes Gerät. Abweichend von Java: Java öffnet Desktop und Tabellen mit einem Klick aufs Gerät, als verschiebbare Fenster, mehrere zugleich, und sein Menü nur mit Rechtsklick |
|
||||||
|
| `src/sheet/SheetTabs.tsx` (`variant="segmented"`), `src/sheet/sheet.css` (`.sheet-segments`), `src/sheet/ServicePanels.tsx` (DHCP-Server), `browser/sheet.spec.ts` | Java `JDHCPKonfiguration` (Reiter `jdhcpkonfiguration_msg10/11`) | geändert (10.10.2026, auf Wunsch des Nutzers): die beiden Seiten des DHCP-Servers („Grundeinstellungen“, „Statische Adresszuweisung“) als Segment-Control nach dem Muster des Dashboards — ein Rahmen, gleiche Anteile, das gewählte in der Akzentfarbe. Javas Wörter bleiben; gemessen: „Grundeinstellungen“ ist bei 14 px 138 px breit, eine Hälfte des 340-px-Blatts hält 139 (halbfett 157 px). Darum: Wörter brechen in ihrem Segment an Leerzeichen um, das gewählte ist nicht fett (die Breite spränge), und wo das längste Wort eines Segments samt Innenabstand nicht mehr in seinen Anteil passt (Handy, andere Systemschrift), stehen die Segmente untereinander, als ein Control — entschieden durch Messen der Wörter in der Schrift der Segmente (Canvas), nicht durch eine feste Breite. Abweichend von Java: dort Reiter (JTabbedPane) |
|
||||||
|
|
||||||
## packages/server
|
## packages/server
|
||||||
|
|
||||||
|
|||||||
@@ -504,9 +504,76 @@ test("opens a home router's firewall from its general tab", async ({ page }) =>
|
|||||||
await expect(sheet(page).getByLabel('Firewall aktivieren')).not.toBeChecked();
|
await expect(sheet(page).getByLabel('Firewall aktivieren')).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The DHCP page's segmented control: its frame, its two segments' boxes, and how they stand. */
|
||||||
|
async function dhcpSegments(page: Page) {
|
||||||
|
await page.locator('[data-palette="pc"]').click();
|
||||||
|
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
|
||||||
|
const control = sheet(page).getByRole('tablist');
|
||||||
|
await expect(control).toHaveClass('sheet-segments');
|
||||||
|
const boxes = await Promise.all([0, 1].map(async (index) => (await control.getByRole('tab').nth(index).boundingBox())!));
|
||||||
|
return { control, boxes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shows a DHCP server's two pages as a segmented control: side by side in the sheet, the long word wrapped, nothing cut off", async ({ page }) => {
|
||||||
|
await openEditor(page, server.base, 'new');
|
||||||
|
const { control, boxes } = await dhcpSegments(page);
|
||||||
|
await expect(control).not.toHaveAttribute('data-stacked', 'true');
|
||||||
|
const [basic, assignments] = boxes as [{ x: number; y: number; width: number; height: number }, { x: number; y: number; width: number; height: number }];
|
||||||
|
// One row, equal shares, equal heights — „Statische Adresszuweisung" on two lines inside its 44 px.
|
||||||
|
expect(Math.round(basic.y)).toBe(Math.round(assignments.y));
|
||||||
|
expect(Math.abs(basic.width - assignments.width)).toBeLessThanOrEqual(1);
|
||||||
|
expect(basic.height).toBe(assignments.height);
|
||||||
|
expect(basic.height).toBeGreaterThanOrEqual(44);
|
||||||
|
// No word runs over its segment's edge.
|
||||||
|
for (const tab of await control.getByRole('tab').all()) {
|
||||||
|
expect(await tab.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
}
|
||||||
|
// The chosen one in the accent, the other quiet; a choice moves it.
|
||||||
|
const tabs = control.getByRole('tab');
|
||||||
|
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await tabs.nth(1).click();
|
||||||
|
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'false');
|
||||||
|
// Not bold when chosen: the width does not jump.
|
||||||
|
expect(Math.round((await tabs.nth(1).boundingBox())!.width)).toBe(Math.round(assignments.width));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stacks the DHCP server's segments exactly where a segment's longest word, with its padding, no longer fits its share of the row", async ({ page }) => {
|
||||||
|
await openEditor(page, server.base, 'new');
|
||||||
|
const { control } = await dhcpSegments(page);
|
||||||
|
// What one segment needs: its longest word in the segments' font, and its padding.
|
||||||
|
const need = await control.evaluate((list) => {
|
||||||
|
const segments = [...list.querySelectorAll<HTMLElement>('[role="tab"]')];
|
||||||
|
const style = getComputedStyle(segments[0]!);
|
||||||
|
const measure = document.createElement('canvas').getContext('2d')!;
|
||||||
|
measure.font = style.font;
|
||||||
|
const words = segments.flatMap((segment) => (segment.textContent ?? '').split(/\s+/));
|
||||||
|
return Math.max(...words.map((word) => measure.measureText(word).width)) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||||
|
});
|
||||||
|
// Two shares and the line between them, inside the frame; a little under and a little over.
|
||||||
|
const inside = async (width: number) => control.evaluate((list, value) => { list.style.boxSizing = 'content-box'; list.style.width = `${value}px`; }, width);
|
||||||
|
await inside(2 * need + 1 - 0.6);
|
||||||
|
await expect(control).toHaveAttribute('data-stacked', 'true');
|
||||||
|
await inside(2 * need + 1 + 0.6);
|
||||||
|
await expect(control).not.toHaveAttribute('data-stacked', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('on a phone', () => {
|
test.describe('on a phone', () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||||
|
|
||||||
|
test("stacks the DHCP server's segments where a word no longer fits its share — still one control", async ({ page }) => {
|
||||||
|
await openEditor(page, server.base, 'new');
|
||||||
|
const { control, boxes } = await dhcpSegments(page);
|
||||||
|
await expect(control).toHaveAttribute('data-stacked', 'true');
|
||||||
|
await expect(control).toHaveAttribute('aria-orientation', 'vertical');
|
||||||
|
const [basic, assignments] = boxes as [{ x: number; y: number; width: number; height: number }, { x: number; y: number; width: number; height: number }];
|
||||||
|
expect(Math.round(basic.x)).toBe(Math.round(assignments.x));
|
||||||
|
expect(assignments.y).toBeGreaterThanOrEqual(basic.y + basic.height - 1);
|
||||||
|
for (const tab of await control.getByRole('tab').all()) {
|
||||||
|
expect(await tab.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('opens below the surface with a tap, the device still in sight', async ({ page }) => {
|
test('opens below the surface with a tap, the device still in sight', async ({ page }) => {
|
||||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||||
const id = await deviceNamed(page, 'Notebook 1.10');
|
const id = await deviceNamed(page, 'Notebook 1.10');
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
|
|||||||
]}
|
]}
|
||||||
selected={tab}
|
selected={tab}
|
||||||
onSelect={setTab}
|
onSelect={setTab}
|
||||||
|
variant="segmented"
|
||||||
/>
|
/>
|
||||||
<SheetPanel key={tab}>
|
<SheetPanel key={tab}>
|
||||||
{tab === 'basic' ? (
|
{tab === 'basic' ? (
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
// Java's tabs (JTabbedPane) in the device sheet: chips that wrap in a narrow sheet.
|
// Java's tabs (JTabbedPane) in the device sheet: chips that wrap in a narrow
|
||||||
|
// sheet — or, where a page has two or three tabs of equal rank (a DHCP
|
||||||
|
// server's base settings and static assignments), the dashboard's segmented
|
||||||
|
// control: one frame, equal segments, the chosen one in the accent.
|
||||||
|
//
|
||||||
|
// The width is the hard part. Java's words stay (M11), and "Grundeinstellungen"
|
||||||
|
// is one word of 138 px at 14 px — measured on 10.10.2026, while a half of the
|
||||||
|
// 340 px sheet holds 139. So a segment's words may wrap at their spaces
|
||||||
|
// ("Statische / Adresszuweisung"), the chosen one is not set bold (157 px; the
|
||||||
|
// width would jump on every choice), and where even the longest word of a
|
||||||
|
// segment no longer fits its share — a phone, another system font, English —
|
||||||
|
// the segments stand under one another, still one control. That is decided by
|
||||||
|
// measuring the words in the segments' own font, not by a breakpoint: a
|
||||||
|
// breakpoint would hold for one font only.
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
@@ -6,9 +21,60 @@ export interface Tab {
|
|||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
|
/** Whether every segment's longest word fits its equal share of the row (a 1 px line between two). */
|
||||||
|
function wordsFit(list: HTMLElement, measure: CanvasRenderingContext2D): boolean {
|
||||||
|
const segments = [...list.querySelectorAll<HTMLElement>('[role="tab"]')];
|
||||||
|
if (segments.length === 0) return true;
|
||||||
|
const style = getComputedStyle(segments[0]!);
|
||||||
|
measure.font = style.font;
|
||||||
|
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||||
|
// The width inside the frame, to the fraction (clientWidth would round it).
|
||||||
|
const frame = getComputedStyle(list);
|
||||||
|
const inner = list.getBoundingClientRect().width - parseFloat(frame.borderLeftWidth) - parseFloat(frame.borderRightWidth);
|
||||||
|
const share = (inner - (segments.length - 1)) / segments.length;
|
||||||
|
return segments.every((segment) => Math.max(...(segment.textContent ?? '').split(/\s+/).map((word) => measure.measureText(word).width)) + padding <= share);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Side by side while the words fit, else stacked; measured again when the sheet's width or the words change. */
|
||||||
|
function useStacked(labels: string) {
|
||||||
|
const list = useRef<HTMLDivElement>(null);
|
||||||
|
const [stacked, setStacked] = useState(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = list.current;
|
||||||
|
const measure = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
|
||||||
|
// Without a canvas (a test's document) the row stays as it is.
|
||||||
|
if (element === null || measure === null || typeof ResizeObserver === 'undefined') return undefined;
|
||||||
|
const decide = () => setStacked(!wordsFit(element, measure));
|
||||||
|
decide();
|
||||||
|
const observer = new ResizeObserver(decide);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [labels]);
|
||||||
|
return { list, stacked };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SheetTabs({
|
||||||
|
tabs,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
variant = 'chips',
|
||||||
|
}: {
|
||||||
|
readonly tabs: readonly Tab[];
|
||||||
|
readonly selected: string;
|
||||||
|
readonly onSelect: (key: string) => void;
|
||||||
|
/** `segmented`: the dashboard's segmented control, for a few tabs of equal rank. */
|
||||||
|
readonly variant?: 'chips' | 'segmented';
|
||||||
|
}) {
|
||||||
|
const { list, stacked } = useStacked(tabs.map((tab) => tab.label).join('\n'));
|
||||||
|
const segmented = variant === 'segmented';
|
||||||
return (
|
return (
|
||||||
<div role="tablist" className="sheet-tabs">
|
<div
|
||||||
|
ref={segmented ? list : undefined}
|
||||||
|
role="tablist"
|
||||||
|
className={segmented ? 'sheet-segments' : 'sheet-tabs'}
|
||||||
|
data-stacked={segmented && stacked ? 'true' : undefined}
|
||||||
|
aria-orientation={segmented && stacked ? 'vertical' : undefined}
|
||||||
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
|
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@@ -215,6 +215,78 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The segmented control (SheetTabs `segmented`), after the dashboard's
|
||||||
|
* SegmentedControl: one frame, lines between the segments, the chosen one in
|
||||||
|
* the accent — not bold, so that its width does not jump (see SheetTabs).
|
||||||
|
* Equal shares; a segment's words wrap at their spaces, and where a word no
|
||||||
|
* longer fits its share the segments stand under one another. */
|
||||||
|
.sheet-segments {
|
||||||
|
display: flex;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments[data-stacked='true'] {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border-default);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments button:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments[data-stacked='true'] button {
|
||||||
|
flex: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments[data-stacked='true'] button:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments button:hover:not(:disabled):not([aria-selected='true']) {
|
||||||
|
background: var(--surface-hover, var(--surface-sunken));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments button[aria-selected='true'] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inside: outside, the frame's overflow would cut the ring off. */
|
||||||
|
.sheet-segments button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-segments button:disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.sheet-buttons {
|
.sheet-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user