Add checklist switching and contextual memo ticker
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s

This commit is contained in:
Shinuwa 2026-09-28 09:40:29 +02:00
parent 273bc5d528
commit bd4059c9c1
16 changed files with 231 additions and 28 deletions

View file

@ -370,7 +370,8 @@ Regles UI :
- palette de couleurs compacte reprenant les swatches du Bloc Note ; - palette de couleurs compacte reprenant les swatches du Bloc Note ;
- la creation de mémo est masquee par defaut et s'ouvre via le bouton `+` du header d'outil ; - la creation de mémo est masquee par defaut et s'ouvre via le bouton `+` du header d'outil ;
- réorganisation des cartes par poignée de drag & drop compacte ; - réorganisation des cartes par poignée de drag & drop compacte ;
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos. - bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos dans l'outil et reste fixée en bas des pages complètes de cette toolbox ainsi que des pages du jeu qui lui est associé ;
- la bande fixe laisse le contenu verticalement accessible, suit la largeur disponible et remonte au-dessus de la navigation mobile.
### Calque De Dessin ### Calque De Dessin
@ -859,10 +860,13 @@ reste dorée lorsque la liste est associée. Le bouton NDS
dissocie la précédente. Le switch « Masquer les cochés » est désactivé sur les listes dissocie la précédente. Le switch « Masquer les cochés » est désactivé sur les listes
non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ; non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ;
le nombre de correspondances figure dessous. Une liste active devenue incompatible le nombre de correspondances figure dessous. Une liste active devenue incompatible
garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché. garde ses commandes avec un message explicite.
Quand une liste est associée, un encart compact placé en haut à droite du header Un encart compact placé en haut à droite du header du guide expose un sélecteur dès
du guide expose directement le switch « Masquer les cochés » et l'action qu'au moins une checklist compatible existe. Sans association, lui seul est affiché.
« Dissocier la checklist ». La colonne d'actions occupe la hauteur existante du Une fois une liste choisie, l'encart ajoute le switch « Masquer les cochés » et
l'action « Dissocier la checklist ». Changer la
valeur du sélecteur remplace immédiatement l'association sans perdre le réglage de
masquage. La colonne d'actions occupe la hauteur existante du
header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette
association est conservée dans `localStorage` entre les sessions ; elle est retirée association est conservée dans `localStorage` entre les sessions ; elle est retirée
si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît. si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît.

View file

@ -237,6 +237,7 @@ Type : `memos`
Stockage compact : Stockage compact :
- `tickerEnabled` vaut `true` par défaut et est omis dans ce cas ; - `tickerEnabled` vaut `true` par défaut et est omis dans ce cas ;
- lorsqu'il est actif, la bande est aussi affichée en position fixe sur la page complète de la toolbox et sur les pages du jeu lié ;
- `updatedAt` est omis si vide ou invalide ; - `updatedAt` est omis si vide ou invalide ;
- les mémos sans texte sont ignorés ; - les mémos sans texte sont ignorés ;
- si aucun mémo n'est valide et que la bande est active par défaut, l'entrée de module peut être supprimée. - si aucun mémo n'est valide et que la bande est active par défaut, l'entrée de module peut être supprimée.
@ -251,7 +252,9 @@ affiche un toggle de lien à côté du « + » de son en-tête dans le panneau l
Il ouvre une section masquée par défaut en haut du contenu : Il ouvre une section masquée par défaut en haut du contenu :
bouton toggle d'association/dissociation et switch de masquage, actif seulement pour bouton toggle d'association/dissociation et switch de masquage, actif seulement pour
la liste associée. Associer une autre liste remplace la précédente et conserve le la liste associée. Associer une autre liste remplace la précédente et conserve le
réglage de masquage ; dissocier réaffiche tous les éléments. Replier les commandes réglage de masquage ; le sélecteur affiché dans l'en-tête du guide dès qu'une liste
compatible existe permet le même changement ; sans association, les autres actions
de l'encart restent masquées. Dissocier réaffiche tous les éléments. Replier les commandes
ne modifie pas l'association ; cet état d'affichage reste local au composant. ne modifie pas l'association ; cet état d'affichage reste local au composant.
Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox. Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox.

View file

@ -24,6 +24,7 @@ not bugs, missing scenarios or a percentage of work remaining.
| Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` | | Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` |
| Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` | | Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` |
| Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` | | Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` |
| Mémos : bande fixe, portée par route et persistance du toggle | `e2e/memos-ticker.spec.js` |
| Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` | | Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` |
| Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` | | Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` |
| Production onboarding | `e2e/production/onboarding.spec.js` | | Production onboarding | `e2e/production/onboarding.spec.js` |

View file

@ -132,6 +132,12 @@ for (const width of [1280, 390]) {
await page.goto(route); await page.goto(route);
await page.getByRole("searchbox").fill("emberpup"); await page.getByRole("searchbox").fill("emberpup");
const card = page.locator(".aniimo-result"); const card = page.locator(".aniimo-result");
const pageControls = page.locator(".guide-checklist-page-controls");
const unlinkedSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
await expect(pageControls).toBeVisible();
await expect(unlinkedSelect).toHaveValue("");
await expect(unlinkedSelect).toContainText("Choisir une checklist");
await expect(pageControls.getByRole("button")).toHaveCount(0);
await openDrawer(page); await openDrawer(page);
await expect(activeButtons(page)).toHaveCount(0); await expect(activeButtons(page)).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(3); await expect(page.locator(".guide-checklist-controls")).toHaveCount(3);
@ -164,7 +170,6 @@ for (const width of [1280, 390]) {
expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0); expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") }); await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page); await closeDrawer(page);
const pageControls = page.locator(".guide-checklist-page-controls");
await expect(pageControls).toBeVisible(); await expect(pageControls).toBeVisible();
const pageControlsBounds = await pageControls.boundingBox(); const pageControlsBounds = await pageControls.boundingBox();
const pageHeadingBounds = await page.locator(".page-heading").boundingBox(); const pageHeadingBounds = await page.locator(".page-heading").boundingBox();
@ -175,6 +180,14 @@ for (const width of [1280, 390]) {
expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0); expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0);
expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0); expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0);
} }
const checklistSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
await expect(checklistSelect).toHaveValue("forms");
await expect(checklistSelect.locator("option")).toHaveCount(3);
await expect(checklistSelect.locator('option[value="unrelated"]')).toHaveCount(0);
await checklistSelect.selectOption("capture");
await expect(checklistSelect).toHaveValue("capture");
await checklistSelect.selectOption("forms");
await expect(checklistSelect).toHaveValue("forms");
await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false"); await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false");
await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible(); await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible();
await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card).toHaveAttribute("data-checklist-complete", "true");

56
e2e/memos-ticker.spec.js Normal file
View file

@ -0,0 +1,56 @@
// Rôle : vérifie la bande de mémos fixe sur les pages de toolbox et de jeu associées.
import { test, expect } from "@playwright/test";
import { resetSokkoStorage } from "./helpers/storage.js";
async function seedMemosToolbox(page) {
await resetSokkoStorage(page);
await page.evaluate(async () => {
await new Promise((resolve, reject) => {
const request = globalThis.indexedDB.open("sokkog", 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["kv", "modules"], "readwrite");
tx.objectStore("kv").put([
{ id: "memo-toolbox", name: "Toolbox mémos", modules: [{ id: "memos", type: "memos", title: "Mémos" }], moduleColumns: 1 }
], "toolboxes");
tx.objectStore("kv").put({ mhwilds: "memo-toolbox" }, "links");
tx.objectStore("modules").put({
key: "memo-toolbox:memos",
data: { items: [{ id: "memo-1", text: "Préparer les potions", color: "#f6c453" }] }
});
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
});
});
await page.reload();
}
test("shows and persists the memo ticker on linked game and full toolbox pages", async ({ page }) => {
await seedMemosToolbox(page);
await page.goto("/#/games/mhwilds/monsters");
const globalTicker = page.locator(".global-memos-ticker");
await expect(globalTicker).toBeVisible();
await expect(globalTicker).toContainText("Préparer les potions");
await expect(globalTicker).toHaveCSS("position", "fixed");
await page.goto("/#/games/aniimo/aniimos");
await expect(globalTicker).toHaveCount(0);
await page.goto("/#/toolbox/memo-toolbox");
await expect(globalTicker).toBeVisible();
const toggle = page.locator(".memos-ticker-toggle");
await expect(toggle).toHaveAccessibleName("Masquer la bande");
await toggle.click();
await expect(globalTicker).toHaveCount(0);
await page.reload();
await expect(globalTicker).toHaveCount(0);
await expect(toggle).toHaveAccessibleName("Afficher la bande");
await toggle.click();
await expect(globalTicker).toBeVisible();
await page.goto("/#/toolboxes");
await expect(globalTicker).toHaveCount(0);
});

View file

@ -90,6 +90,8 @@ export function validateSiteContent(site) {
"gamesPage.emptyTitle", "gamesPage.emptyTitle",
"gamesPage.emptyText", "gamesPage.emptyText",
"guideChecklist.label", "guideChecklist.label",
"guideChecklist.selectLabel",
"guideChecklist.selectPlaceholder",
"guideChecklist.associate", "guideChecklist.associate",
"guideChecklist.noMatches", "guideChecklist.noMatches",
"guideChecklist.matches", "guideChecklist.matches",

View file

@ -2771,6 +2771,8 @@
}, },
"guideChecklist": { "guideChecklist": {
"label": "Lien avec le guide", "label": "Lien avec le guide",
"selectLabel": "Checklist associée",
"selectPlaceholder": "Choisir une checklist",
"associate": "Associer à la page", "associate": "Associer à la page",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.", "noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances", "matches": "correspondances",

View file

@ -31,16 +31,27 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
} }
export function GuideChecklistPageControls({ checklist }) { export function GuideChecklistPageControls({ checklist }) {
if (!checklist.selected) return null; if (!checklist.lists.length) return null;
const { content, hideCompleted } = checklist; const { content, hideCompleted } = checklist;
return ( return (
<aside className="guide-checklist-page-controls nds-tool-panel" aria-label={content.label}> <aside className="guide-checklist-page-controls nds-tool-panel" aria-label={content.label}>
<label className="guide-checklist-page-select">
<span>{content.selectLabel}</span>
<select className="nds-select" data-size="sm" value={checklist.selected?.id || ""} onChange={(event) => checklist.associate(event.target.value)}>
{!checklist.selected && <option value="" disabled>{content.selectPlaceholder}</option>}
{checklist.lists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
</select>
</label>
{checklist.selected && (
<div className="guide-checklist-page-actions">
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}> <button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
<Icon name="hide" /> <Icon name="hide" />
<span>{content.hideCompleted}</span> <span>{content.hideCompleted}</span>
<i aria-hidden="true" /> <i aria-hidden="true" />
</button> </button>
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button> <button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
</div>
)}
</aside> </aside>
); );
} }

View file

@ -21,6 +21,7 @@ export function useGuideChecklist(requestedGuideId) {
const association = guideChecklistStorage.associations[guideId]; const association = guideChecklistStorage.associations[guideId];
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({ const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
id: module.id, id: module.id,
title: module.title || "Checklist",
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`]) ...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
})), [entries, toolbox, moduleData]); })), [entries, toolbox, moduleData]);
const selected = lists.find((list) => list.id === association?.moduleId); const selected = lists.find((list) => list.id === association?.moduleId);

View file

@ -0,0 +1,33 @@
// Rôle : affiche en bas de page les mémos actifs de la toolbox courante ou liée au jeu.
import { useMemo } from "react";
import { moduleStorageKey, normalizeMemosData } from "./storage/toolboxStorage.js";
import { MemosTicker } from "./modules/MemosTicker.jsx";
function getRouteToolboxId(route, links) {
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
if (toolboxMatch) return decodeURIComponent(toolboxMatch[1]);
const gameMatch = route.match(/^\/games\/([^/]+)/);
return gameMatch ? links[gameMatch[1]] || "" : "";
}
export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textContent }) {
const items = useMemo(() => {
const toolboxId = getRouteToolboxId(route, links);
const toolbox = toolboxes.find((item) => item.id === toolboxId);
if (!toolbox) return [];
return toolbox.modules
.filter((module) => module.type === "memos")
.flatMap((module) => {
const data = normalizeMemosData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
if (!data.tickerEnabled) return [];
return data.items.map((memo) => ({ ...memo, id: `${module.id}:${memo.id}` }));
});
}, [route, toolboxes, links, moduleData]);
if (!items.length) return null;
return (
<aside className="global-memos-ticker" aria-label={textContent?.tickerLabel || "Mémos"}>
<MemosTicker items={items} label={textContent?.tickerLabel || "Mémos"} />
</aside>
);
}

View file

@ -4,6 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useMemo } from "react"; import { useMemo } from "react";
import { MemoTextEditor } from "./MemoTextEditor.jsx"; import { MemoTextEditor } from "./MemoTextEditor.jsx";
import { MemosTicker } from "./MemosTicker.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { TEXT_COLORS } from "./notepadUtils.js"; import { TEXT_COLORS } from "./notepadUtils.js";
@ -92,21 +93,12 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
type="button" type="button"
onClick={() => save({ ...data, tickerEnabled: !data.tickerEnabled })} onClick={() => save({ ...data, tickerEnabled: !data.tickerEnabled })}
aria-pressed={data.tickerEnabled} aria-pressed={data.tickerEnabled}
aria-label={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"}
title={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"} title={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"}
> >
<Icon name={data.tickerEnabled ? "eye-open" : "eye-closed"} /> <Icon name={data.tickerEnabled ? "eye-open" : "eye-closed"} />
</button> </button>
{tickerItems.length > 0 && ( <MemosTicker items={tickerItems} label={textContent.tickerLabel || "Mémos"} />
<div className="memos-ticker" aria-label={textContent.tickerLabel || "Mémos"}>
<div className="memos-ticker-track">
{[...tickerItems, ...tickerItems].map((memo, index) => (
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
{memo.text}
</span>
))}
</div>
</div>
)}
</div> </div>
<div className="memos-list"> <div className="memos-list">

View file

@ -0,0 +1,15 @@
// Rôle : affiche la bande déroulante partagée par l'outil Mémos et les pages associées.
export function MemosTicker({ items, label = "Mémos", className = "" }) {
if (!items?.length) return null;
return (
<div className={["memos-ticker", className].filter(Boolean).join(" ")} aria-label={label}>
<div className="memos-ticker-track">
{[...items, ...items].map((memo, index) => (
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
{memo.text}
</span>
))}
</div>
</div>
);
}

View file

@ -18,6 +18,7 @@ import { RouteContent } from "./router/RouteContent.jsx";
import { useHashRoute } from "./router/hashRouter.js"; import { useHashRoute } from "./router/hashRouter.js";
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js"; import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js"; import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer }))); const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
const DEFAULT_NOTIFICATION_DURATION_MS = 120000; const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
@ -211,6 +212,13 @@ function App() {
/> />
</Suspense> </Suspense>
)} )}
<GlobalMemosTicker
route={route}
toolboxes={store.toolboxes}
links={store.links}
moduleData={store.moduleData}
textContent={siteContent.toolboxes.modules.memos}
/>
<AppOverlays <AppOverlays
storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle} storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle}
actions={actions} actions={actions}

View file

@ -101,6 +101,11 @@
justify-content: space-between; justify-content: space-between;
} }
.guide-checklist-page-actions {
flex-wrap: wrap;
justify-content: space-between;
}
.home-onboarding-steps-action { .home-onboarding-steps-action {
padding-left: 0; padding-left: 0;
} }

View file

@ -18,13 +18,31 @@
justify-content: space-between; justify-content: space-between;
} }
.guide-checklist-page-controls { .guide-checklist-page-controls {
display: flex; display: grid;
align-self: flex-end; align-self: flex-end;
gap: var(--space-2);
width: min(100%, 360px);
padding: 8px;
}
.guide-checklist-page-select {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
}
.guide-checklist-page-select > span { white-space: nowrap; }
.guide-checklist-page-select .nds-select {
width: auto;
min-width: 0;
flex: 1;
}
.guide-checklist-page-actions {
display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: var(--space-2); gap: var(--space-2);
width: fit-content;
padding: 8px;
} }
.guide-checklist-page-controls .nds-switch { max-width: 100%; } .guide-checklist-page-controls .nds-switch { max-width: 100%; }
.game-heading-navigation { .game-heading-navigation {

View file

@ -37,6 +37,45 @@
background: rgba(5, 7, 17, 0.56); background: rgba(5, 7, 17, 0.56);
} }
.global-memos-ticker {
position: fixed;
right: 12px;
bottom: 12px;
left: 260px;
z-index: 45;
padding: 6px;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-lg);
background: rgba(7, 9, 19, 0.9);
box-shadow: var(--shadow-md);
backdrop-filter: blur(10px);
}
.global-memos-ticker .memos-ticker {
width: 100%;
background: rgba(5, 7, 17, 0.72);
}
.app-shell.sidebar-compact .global-memos-ticker {
left: 84px;
}
.app-shell:has(.global-memos-ticker) main {
padding-bottom: calc(var(--space-12) + 60px);
}
@media (max-width: 1024px) {
.global-memos-ticker { left: 84px; }
}
@media (max-width: 760px) {
.global-memos-ticker {
right: 12px;
bottom: 84px;
left: 12px;
}
}
.memos-ticker-track { .memos-ticker-track {
display: flex; display: flex;
width: max-content; width: max-content;