openvibers/openvibe.inventory Commits

Inventory on a phone: tiles, what you wear, and the tile is the wear button (#212)

OP OpenVibers committed 5 days ago · 983d7d2e1a · 3 files, +86 −18

The owner, 11-08: improve the UI, more user friendly, better on mobile.

- Every item and your inventory show items as tiles, three to a row on a phone (the picture on its rarity's glow,
  the name, the rarity in a word), not one tall card each: the catalog went from 16,700 px to 3,100 px at 390.
- Your inventory opens with what you wear, one slot per kind (a swipe on a phone), each jumping to its kind; in a
  kind, the tile itself is the wear button (a plain form post), the worn one ringed and ticked, a small i for the item.
- A big kind shows its first twelve, then a counted fold ("More voice items (78)"), only when more than six would
  be behind it; one kind's own page shows all. The kind chips leave out empty kinds and scroll sideways on a phone.
- CSS budgets: 10 KB brotli, 47 KB raw (dated notes).

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex fd2f64c..6a5b51f 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -139,3 +139,51 @@ button.sc-btn.sc-primary { border: 0; }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
++
++/* ── Tiles (owner, 11-08: "improve the UI … more user friendly and better on mobile"): three to a row on a phone, the
++ picture on its rarity's glow, the name, the rarity in a word. In your inventory the tile is the wear button itself. ── */
++.inv-tiles { list-style: none; padding: 0; margin: 10px 0 4px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(104px, 30%), 1fr)); }
++@media (min-width: 760px) { .inv-tiles { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 12px; } }
++.inv-tile { position: relative; min-width: 0; }
++.inv-tile-form { margin: 0; height: 100%; }
++.inv-tile-main { all: unset; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 6px; width: 100%; height: 100%; padding: 12px 8px 10px; text-align: center; cursor: pointer;
++ border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: linear-gradient(180deg, color-mix(in srgb, var(--rar) 9%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e) 70%);
++ color: var(--text-primary, #e6edf7); -webkit-tap-highlight-color: transparent; transition: border-color .15s, transform .15s, box-shadow .2s; }
++.inv-tile-main:hover { border-color: color-mix(in srgb, var(--rar) 60%, var(--border, #1f2d47)); box-shadow: 0 10px 26px -16px color-mix(in srgb, var(--rar) 80%, transparent); }
++.inv-tile-main:active { transform: scale(.97); }
++.inv-tile-main:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.inv-tile .inv-art-md { width: 52px; height: 52px; font-size: 27px; }
++.inv-tile-name { font-weight: 650; font-size: .86rem; line-height: 1.25; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.inv-tile-sub { font-size: .7rem; line-height: 1.2; color: var(--text-muted, #7386a3); }
++.inv-tile-rar { font-weight: 750; letter-spacing: .04em; text-transform: uppercase; color: color-mix(in srgb, var(--rar) 55%, var(--text-primary, #e6edf7)); }
++.inv-tile-act { margin-top: 2px; padding: 3px 10px; border-radius: 999px; font-size: .72rem; font-weight: 750; color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++.inv-tile.is-worn .inv-tile-main { border-color: var(--success, #22c55e); box-shadow: 0 0 0 1px var(--success, #22c55e), 0 12px 28px -18px var(--success, #22c55e); }
++.inv-tile.is-worn .inv-tile-act { color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
++.inv-tile.is-worn .inv-tile-act::before { content: "✓ "; }
++.inv-tile-info { position: absolute; top: 6px; right: 6px; z-index: 1; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 999px; font: 700 12px/1 Georgia, serif; font-style: italic;
++ color: var(--text-secondary, #96a7c2); text-decoration: none; background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 60%, transparent); border: 1px solid var(--border, #1f2d47); }
++.inv-tile-info:hover { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); }
++.inv-more { margin: 4px 0 0; }
++.inv-more > summary { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); font-weight: 650; font-size: .9rem; list-style: none; }
++.inv-more > summary::-webkit-details-marker { display: none; }
++.inv-more > summary::after { content: "⌄"; font-size: 1rem; line-height: 1; transform: translateY(-2px); }
++.inv-more[open] > summary::after { transform: rotate(180deg) translateY(-2px); }
++
++/* ── What you wear: one slot per kind, a swipe on a phone ── */
++.inv-wearing { margin: 14px 0 6px; }
++.inv-slots { list-style: none; margin: 0; padding: 2px 2px 8px; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(176px, 1fr); gap: 8px; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin; }
++.inv-slots li { scroll-snap-align: start; min-width: 0; }
++.inv-slot { display: flex; align-items: center; gap: 10px; height: 100%; padding: 8px 12px 8px 8px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: inherit; text-decoration: none; }
++.inv-slot:hover { border-color: var(--accent, #3b82f6); }
++.inv-slot.is-empty { border-style: dashed; }
++.inv-slot-t { display: grid; min-width: 0; line-height: 1.25; }
++.inv-slot-t small { font-size: .7rem; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.inv-slot-t b { font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.inv-slot.is-empty .inv-slot-t b { font-weight: 500; color: var(--text-secondary, #96a7c2); }
++.inv-art-sm { width: 40px; height: 40px; font-size: 21px; border-radius: 11px; }
++.inv-art-none { background: repeating-linear-gradient(135deg, transparent 0 6px, color-mix(in srgb, var(--text-muted, #7386a3) 16%, transparent) 6px 7px); border: 1px dashed var(--border-light, #2c3d5c); }
++.inv-links { margin: 8px 0 0; font-size: .92rem; }
++.inv-kind { scroll-margin-top: 72px; }
++.inv-kind h2 { margin-top: 1.4rem; }
++@media (max-width: 520px) { .inv-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-left: -16px; margin-right: -16px; padding: 0 16px 4px; } .inv-tabs::-webkit-scrollbar { display: none; } .inv-tabs a { flex: none; } }
++@media (prefers-reduced-motion: reduce) { .inv-tile-main { transition: none; } .inv-tile-main:active { transform: none; } }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex e375f87..0e4c54a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -56,6 +56,24 @@ function card(d, { href = `/items/${d.id}`, extra = '', owned = null, worn = fal
</li>`;
}
++/**
++ * An item as a compact tile (the catalog and your inventory on a phone: three to a row, not one card a screen):
++ * its picture on its rarity's glow, its name, its rarity in a word. A link to the item, or (in your inventory) the
++ * wear / take-off button itself, with a small link to the item beside it.
++ */
++function tile(d, { href = `/items/${d.id}`, form = null, worn = false, note = '' } = {}) {
++ const label = html`${art(d)}<span class="inv-tile-name">${d.name}</span><span class="inv-tile-sub"><span class="inv-tile-rar">${RARITY_LABEL[d.rarity] || d.rarity}</span>${note ? html` · ${note}` : ''}</span>`;
++ if (!form) return html`<li class="inv-tile r-${d.rarity}"><a class="inv-tile-main" href="${href}">${label}</a></li>`;
++ return html`<li class="inv-tile r-${d.rarity}${worn ? ' is-worn' : ''}">${form(label)}<a class="inv-tile-info" href="${href}" aria-label="About ${d.name}" title="About ${d.name}">i</a></li>`;
++}
++/** A long list of tiles: the first `first` shown, the rest behind a counted fold ("More voice items (78)") when more than six would be behind it. */
++function tiles(list, render, { first = 12, more = 'More' } = {}) {
++ // A fold that would hide only a few is no fold: show them all.
++ const cut = list.length - first > 6 ? first : list.length;
++ const head = list.slice(0, cut), rest = list.slice(cut);
++ return html`<ul class="inv-tiles">${head.map(render)}</ul>${rest.length ? html`<details class="inv-more"><summary>${more} <span class="muted">(${rest.length})</span></summary><ul class="inv-tiles">${rest.map(render)}</ul></details>` : ''}`;
++}
++
/** An item page's meta description (≤ 160 characters): its name, rarity and kind, its own words, how many own it, how it is got. */
function itemDescription(d, k) {
const what = `${d.name}, a ${RARITY_LABEL[d.rarity].toLowerCase()} ${k.name.toLowerCase()} on OpenVibe`;
@@@ -145,10 +163,10 @@ ${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something
description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,
body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>
<p class="lede">${only ? shown[0].description : `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')}. Rarity is shown as it is.`}</p>
--<nav class="inv-tabs" aria-label="Kinds"><a href="/items"${only ? '' : raw(' aria-current="page"')}>All</a>${kinds.map((k) => html`<a href="/items?kind=${encodeURIComponent(k.id)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>
++<nav class="inv-tabs" aria-label="Kinds"><a href="/items"${only ? '' : raw(' aria-current="page"')}>All <span class="muted">${defs.length}</span></a>${kinds.filter((k) => (byKind.get(k.id) || []).length || only === k.id).map((k) => html`<a href="/items?kind=${encodeURIComponent(k.id)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>
${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.replace(/\W/g, '-')}">
-- <h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
-- ${(byKind.get(k.id) || []).length ? html`<ul class="inv-grid">${byKind.get(k.id).map((d) => card(d))}</ul>` : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
++ <h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">${plural((byKind.get(k.id) || []).length, 'item', 'items')} · shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
++ ${(byKind.get(k.id) || []).length ? tiles(byKind.get(k.id), (d) => tile(d), { first: only ? Infinity : 12, more: `More ${k.name.toLowerCase()} items` }) : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
</section>`)}`,
};
}
@@@ -219,22 +237,24 @@ ${(d.grantors || []).includes('service:games') && d.status === 'published' ? htm
page(req, res, {
title: 'Your inventory',
body: html`<h1>Your inventory</h1>
--<p class="lede">${owned.length ? `${plural(owned.length, 'item', 'items')}. Choose one per slot to wear it on every site that shows it.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>
++<p class="lede">${owned.length ? `${plural(owned.length, 'item', 'items')}. What you wear shows in chat, on stream overlays and on your profile, on every OpenVibe site.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>
${done ? html`<p class="notice ok" role="status">${done === 'equipped' ? 'Equipped. It shows everywhere that kind is shown.' : 'Taken off.'}</p>` : ''}
${error ? html`<p class="notice bad" role="alert">${error}</p>` : ''}
--<p><a href="/u/${me}">See your public page</a> · <a href="/items">Browse every item</a></p>
--${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind">
-- <h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}</span></h2>
-- <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, {
-- owned: `Since ${String(i.acquired_at).slice(0, 10)}${i.serial ? ` · #${i.serial}` : ''}`,
++${owned.length ? html`<section class="inv-wearing" aria-labelledby="inv-wearing-h"><h2 id="inv-wearing-h" class="sr-only">What you wear</h2>
++<ul class="inv-slots">${kinds.filter((k) => k.slots.length && byKind.get(k.id).length).map((k) => {
++ const on = byKind.get(k.id).find(({ i }) => worn.has(i.id));
++ return html`<li><a class="inv-slot${on ? '' : ' is-empty'}" href="#k-${k.id.replace(/\W/g, '-')}">${on ? art(on.d, { size: 'sm' }) : html`<span class="inv-art inv-art-sm inv-art-none" aria-hidden="true"></span>`}<span class="inv-slot-t"><small>${k.name}</small><b>${on ? on.d.name : 'Nothing worn'}</b></span></a></li>`;
++ })}</ul></section>` : ''}
++<p class="inv-links"><a href="/u/${me}">Your public page</a> · <a href="/items">Every item</a></p>
++${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind" id="k-${k.id.replace(/\W/g, '-')}">
++ <h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}${k.slots.length ? ' · tap one to wear it' : ''}</span></h2>
++ ${tiles(byKind.get(k.id), ({ i, d }) => tile(d, {
worn: worn.has(i.id),
-- extra: k.slots.length ? html`<form method="post" action="/me/equip" class="inv-equip">
-- <input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}">
-- ${worn.has(i.id)
-- ? html`<input type="hidden" name="instance_id" value=""><span class="inv-worn">Wearing</span><button type="submit" class="inv-btn">Take off</button>`
-- : html`<input type="hidden" name="instance_id" value="${i.id}"><button type="submit" class="inv-btn primary" aria-label="Wear ${d.name}">Wear</button>`}
-- </form>` : '',
-- }))}</ul>
++ note: i.serial ? `#${i.serial}` : '',
++ form: k.slots.length ? (label) => html`<form method="post" action="/me/equip" class="inv-tile-form">
++ <input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}"><input type="hidden" name="instance_id" value="${worn.has(i.id) ? '' : i.id}">
++ <button type="submit" class="inv-tile-main" aria-label="${worn.has(i.id) ? `Take off ${d.name}` : `Wear ${d.name}`}">${label}<span class="inv-tile-act">${worn.has(i.id) ? 'Wearing' : 'Wear'}</span></button></form>` : null,
++ }), { first: 24, more: `More ${k.name.toLowerCase()} items` })}
</section>`)}
${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="/api/v1/me/items">/api/v1/me/items</a>.</p>` : ''}`,
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex d73313e..a03b6bc 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
jsRawKB: 353.9, // 353.9 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 352.9 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 350.2 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 349.2 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 348.2 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 346.1 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 345.4 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 343 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 342 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 340.4 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 338.4 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 337.1 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 335.8 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 333.1 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 332.5 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 331.3 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 328.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 324.8 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 323.2 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 84.4, // 84.4 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 84.2 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 83.7 with openvibe-shared 4.142.0 (2026-11-07: the menu has a close button on a phone); 83.5 with openvibe-shared 4.139.0 (2026-11-07: the menu's feed rows lead with their picture, the footer and glyphs defer 38 KB); 83.1 with openvibe-shared 4.137.0 (2026-11-07: every script by its content hash, one menu button on a phone, the menu warms at idle); 82.6 with openvibe-shared 4.135.0 (2026-11-07: Ask your agent from any page (kit.askAgent), the Dock's Agent panel reads the timeline again, and the menu's feeds follow you); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 82.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 81.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 81.7 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 81.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 81.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 80.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 80.2 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 79.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
-- cssRawKB: 41.1, // 41.1 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 40.1 with 2026-11-01: phone table cards (2026-11-01); 34.2 openvibe-shared 4.26.1 (hero visual); 28.7 with Shared 4.13.0 (2026-10-26: account links); 20.5 with the inventory's cards, rarity tiles and hero panel (2026-10-09)
-- cssBrotliKB: 9, // 8.5 with 2026-11-01: phone table cards (2026-11-01); 8.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 6.4 with Shared 4.13.0 (2026-10-26: account links); 4.9
++ cssRawKB: 47, // 46.5 with the tiles, the slots and the counted folds (2026-11-08); 41.1 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 40.1 with 2026-11-01: phone table cards (2026-11-01); 34.2 openvibe-shared 4.26.1 (hero visual); 28.7 with Shared 4.13.0 (2026-10-26: account links); 20.5 with the inventory's cards, rarity tiles and hero panel (2026-10-09)
++ cssBrotliKB: 10, // 9.7 with the tiles, the slots and the counted folds (2026-11-08, the owner: better on a phone); 8.5 with 2026-11-01: phone table cards (2026-11-01); 8.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 6.4 with Shared 4.13.0 (2026-10-26: account links); 4.9
externalFiles: 0, // 0
};