openvibers/openvibe.design Commits

Elements gallery: a pack shows its deck, one action card, the sample run as turns (#288)

OP OpenVibers committed 1 days ago · 4c81aee7b7 · 7 files, +312 −110

* Elements gallery: a pack shows its deck, one action card, the sample run as turns

The design pass on Design #283's gallery, listing and Try it pages.

- Gallery: the kit search pill; kind chips with an icon and their count
  (rule 134) and the order as plain links; a pack's card shows its first four
  controls and "+N more" instead of its first button alone.
- Listing: a pack's whole deck, every element a tile that opens Try it on that
  element (?item=N); one action card (Install full width, then Try it and
  Remix side by side, the rating under them); the empty "Report" heading is a
  quiet "Report this pack" at the foot, for signed-in people only.
- Try it: a pack's elements as chips to switch between; the sample run reads
  as turns (who pressed, then what happened, with an icon and tint per kind,
  "(simulated)" said once above); the price is one plain sentence; the arena
  shows only when the element moves the robot; the maker's "button · tint"
  note stays on the canvas only.
- Rules 136 groups-show-their-members and 137 logs-keep-cause-and-effect.

* Gallery CSS: no stop under the device's reduced-motion setting (Calm reduces, only Off removes)

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex b6b8e4d..8d1a6c4 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -985,23 +985,50 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}
.dv-elx-ticker { letter-spacing: .3em; }
.dv-elx-alert { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: #fff; color: #b42318; font-weight: 800; }
++/* Elements pages (2026-11-12 design pass): the look on a lit stage, the sample run as turns (who did it, then what
++ happened), and the price as one plain sentence. 390 px first. */
.el-page { max-width: 1080px; margin: 0 auto; padding: 16px; }
--.el-head h1 { margin: 8px 0 4px; }
++.el-page .dv-fine { font-size: .875rem; line-height: 1.45; color: var(--text-muted); }
.el-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 20px; margin: 16px 0; align-items: start; }
--.el-look-panel { display: grid; gap: 8px; justify-items: center; border: 1px solid var(--border, #1f2d47); border-radius: 16px; padding: 16px; background: var(--bg-secondary, #101828); }
--.el-look-panel canvas { width: 100%; max-width: 320px; height: auto; border-radius: 12px; border: 1px solid var(--border, #1f2d47); }
--.el-run h2 { margin-top: 0; }
--.el-events { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; max-height: 420px; overflow: auto; }
--.el-event { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px; align-items: start; padding: 8px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-secondary, #101828); font-size: .92rem; }
--.el-event-icon { text-align: center; }
--.el-event small { color: var(--text-muted, #7386a3); }
--.el-event--refused { border-color: #6b2b2b; }
--.el-event--refused .el-event-icon { color: #f97066; }
--.el-event--robot { border-color: #2d4d6b; }
--.el-event--ai { border-color: #5b4b6b; }
--.el-price { margin-top: 12px; padding: 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); }
++.el-look-panel { display: grid; gap: 12px; justify-items: center; border: 1px solid var(--border); border-radius: 18px; padding: 16px; background: var(--bg-card, var(--bg-secondary)); }
++.el-look-stage { display: grid; place-items: center; width: 100%; min-height: 168px; border-radius: 14px;
++ background: radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 68%), var(--bg-primary); }
++/* The component's name under a look is the maker's note (the canvas prints it on its own line). */
++.el-look-stage .dv-elx small { display: none; }
++.el-look-panel canvas { width: 100%; max-width: 320px; height: auto; border-radius: 12px; border: 1px solid var(--border); }
++.el-look-panel .dv-fine { margin: 0; text-align: center; }
++.el-run h2 { margin: 0 0 4px; font-size: 1.05rem; }
++.el-run > .dv-fine { margin: 0 0 12px; }
++.el-turns { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.el-turn { padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card, var(--bg-secondary)); }
++.el-turn.is-refused { border-color: color-mix(in srgb, #f97066 38%, var(--border)); }
++.el-turn-who { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0; font-size: .95rem; }
++.el-turn-who small { padding: 1px 8px; border-radius: 999px; background: var(--ovk-panel-2, rgba(255,255,255,.07)); color: var(--text-secondary); font-size: .75rem; font-weight: 600; }
++.el-turn-who > span { color: var(--text-muted); }
++.el-turn-who > .el-turn-chat { color: var(--text-primary); }
++.el-turn-who .ovk-i { width: 16px; height: 16px; color: var(--text-muted); }
++.el-turn-out { list-style: none; margin: 8px 0 2px 7px; padding: 0 0 0 12px; border-left: 2px solid var(--border); display: grid; gap: 6px; }
++.el-out { display: flex; gap: 8px; align-items: flex-start; font-size: .9rem; color: var(--text-secondary); }
++.el-out .ovk-i { flex: none; width: 16px; height: 16px; margin-top: 2px; color: var(--el-out, var(--accent-light)); }
++.el-out--robot { --el-out: #60a5fa; }
++.el-out--screen { --el-out: #a78bfa; }
++.el-out--chat { --el-out: #34d399; }
++.el-out--cost, .el-out--money { --el-out: #fbbf24; }
++.el-out--ai { --el-out: #c084fc; }
++.el-out--state { --el-out: #2dd4bf; }
++.el-out--chance { --el-out: #94a3b8; }
++.el-out--refused { --el-out: #f97066; color: var(--text-primary); }
++.el-price { display: flex; gap: 10px; align-items: flex-start; margin: 12px 0 0; padding: 12px 14px; border-radius: 14px;
++ border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); background: color-mix(in srgb, var(--accent) 8%, transparent); }
++.el-price > .ovk-i { flex: none; width: 18px; height: 18px; margin-top: 2px; color: var(--accent-light); }
++.el-price b { display: block; font-weight: 650; }
++.el-price small { display: block; margin-top: 2px; color: var(--text-muted); font-size: .8125rem; }
++.el-pack-pick { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 4px -16px 0; padding: 0 16px 4px; }
++.el-pack-pick::-webkit-scrollbar { display: none; }
++.el-pack-pick .el-g-chip { padding: 0 14px; }
++@media (min-width: 761px) { .el-pack-pick { flex-wrap: wrap; margin: 4px 0 0; padding: 0; overflow: visible; } }
.el-tweaks { margin: 16px 0; }
--.el-tweaks fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px; display: grid; gap: 10px; }
++.el-tweaks fieldset { border: 1px solid var(--border); border-radius: 14px; padding: 12px; display: grid; gap: 10px; }
.el-tweak { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto; gap: 8px; align-items: center; }
.el-tweak input[type=range] { width: 100%; min-height: 44px; }
.el-tweak input[type=text] { min-height: 44px; width: 100%; box-sizing: border-box; }
@@@ -1011,31 +1038,86 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}
.el-codes textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: .82rem; }
@media (max-width: 760px) { .el-grid { grid-template-columns: minmax(0, 1fr); } .el-tweak { grid-template-columns: minmax(0, 1fr); } }
--/* Elements and packs gallery (2026-11-11): the list, one listing and the staff queue. Plain, 390 px first. */
--.el-gallery { max-width: 1080px; }
--.el-g-search { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0 14px; }
--.el-g-search input, .el-g-search select { min-height: 44px; min-width: 0; }
--.el-g-search input { flex: 1 1 220px; }
--.el-g-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 12px; margin: 0; padding: 0; list-style: none; }
--.el-g-card { border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-secondary, #101828); overflow: hidden; }
++/* Elements and packs gallery (2026-11-12 design pass): the search pill, kind chips with their counts (rule 134) and
++ the order as links; cards whose picture is the element, or a pack's first four; a listing that shows a pack's
++ whole deck beside one action card. */
++.el-gallery .ovk-find { margin: 4px 0 10px; }
++.el-g-filters { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; margin: 0 0 16px; }
++.el-g-chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 -16px; padding: 0 16px 2px; }
++.el-g-chips::-webkit-scrollbar { display: none; }
++.el-g-chip { flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px 0 7px; box-sizing: border-box;
++ border-radius: 999px; border: 1px solid var(--ovk-line, rgba(255,255,255,.1)); background: var(--ovk-panel, rgba(255,255,255,.04)); color: var(--text-primary);
++ font-size: .9375rem; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background-color .15s, border-color .15s, transform .12s; }
++.el-g-chip:hover, .el-g-chip:focus-visible { background: var(--ovk-panel-2, rgba(255,255,255,.07)); }
++.el-g-chip[aria-current] { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
++.el-g-chip:active { transform: scale(.97); }
++.el-g-chip-ic { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; flex: none; color: var(--accent-light);
++ background: color-mix(in srgb, var(--accent) 16%, transparent); }
++.el-g-chip-ic .ovk-i { width: 17px; height: 17px; }
++.el-g-n { color: var(--text-secondary); font-size: .8125rem; font-weight: 600; font-variant-numeric: tabular-nums; }
++.el-g-sort { display: flex; gap: 2px; font-size: .875rem; }
++.el-g-filters .el-g-sort a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 10px; border-radius: 10px; color: var(--text-muted); text-decoration: none; }
++.el-g-filters .el-g-sort a:hover { color: var(--text-primary); }
++.el-g-filters .el-g-sort a[aria-current] { color: var(--text-primary); background: var(--ovk-panel, rgba(255,255,255,.04)); font-weight: 600; }
++@media (min-width: 600px) { .el-g-chips { flex-wrap: wrap; margin: 0; padding: 0; overflow: visible; } }
++
++.el-g-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 14px; margin: 0; padding: 0; list-style: none; }
++.el-g-card { border: 1px solid var(--border); border-radius: 18px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; transition: border-color .15s; }
++.el-g-card:hover, .el-g-card:focus-within { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.el-g-card a { display: grid; grid-template-rows: auto 1fr; color: inherit; text-decoration: none; height: 100%; }
--.el-g-look { display: grid; place-items: center; min-height: 150px; padding: 12px; background: var(--bg-primary, #0b0f17); }
--.el-g-text { display: grid; gap: 4px; padding: 12px; }
--.el-g-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
--.el-g-text small { color: var(--text-muted, #7386a3); }
--.el-g-summary { color: var(--text-muted, #7386a3); font-size: .9rem; }
--.el-g-detail { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); gap: 20px; align-items: start; }
--.el-g-preview { display: grid; place-items: center; min-height: 280px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-primary, #0b0f17); padding: 16px; }
--.el-g-side { min-width: 0; display: grid; gap: 8px; }
--.el-g-side h2 { margin: 14px 0 2px; font-size: 1.05rem; }
--.el-g-side input, .el-g-side select, .el-g-side textarea { width: 100%; min-width: 0; min-height: 44px; box-sizing: border-box; }
--.el-g-side fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 12px; display: grid; gap: 8px; padding: 12px; }
--.el-g-items { margin: 0; padding-left: 18px; }
++.el-g-look { display: grid; place-items: center; min-height: 168px; padding: 14px; box-sizing: border-box;
++ background: radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%), var(--bg-primary); }
++.el-g-look--pack { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; align-content: center; }
++.el-g-mini { display: grid; place-items: center; min-width: 0; }
++.el-g-mini .dv-elx { min-width: 0; width: 100%; padding: 10px 12px; }
++.el-g-mini .dv-elx-label { font-size: .875rem; }
++.el-g-more { grid-column: 1 / -1; font-size: .8rem; color: var(--text-muted); }
++/* The component's name under its label is the maker's note; a gallery tile shows the control itself. */
++.el-g-look .dv-elx small, .el-deck-look .dv-elx small { display: none; }
++.el-g-text { display: grid; gap: 4px; padding: 12px 14px 14px; align-content: start; }
++.el-g-kind { display: flex; align-items: center; gap: 6px; font-size: .75rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); }
++.el-g-title { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); }
++.el-g-summary { color: var(--text-secondary); font-size: .9rem; line-height: 1.45; }
++.el-g-by { color: var(--text-muted); font-size: .8rem; margin-top: 4px; }
++
++.el-g-lede { font-size: 1.05rem; line-height: 1.55; color: var(--text-secondary); margin: 4px 0 16px; max-width: 62ch; }
++.el-g-detail { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); gap: 24px; align-items: start; }
++.el-g-main { min-width: 0; }
++.el-g-h { font-size: 1rem; margin: 0 0 2px; }
++.el-g-main > .dv-fine { margin: 0; }
++.el-g-stage { display: grid; place-items: center; min-height: 260px; border: 1px solid var(--border); border-radius: 18px; padding: 16px;
++ background: radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%), var(--bg-primary); }
++.el-deck { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); gap: 10px; }
++.el-deck-tile { display: grid; gap: 6px; justify-items: center; align-content: start; text-align: center; height: 100%; box-sizing: border-box; padding: 14px 10px 12px;
++ border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); color: inherit; text-decoration: none; transition: border-color .15s, transform .15s; }
++.el-deck-tile:hover, .el-deck-tile:focus-visible { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); transform: translateY(-1px); }
++.el-deck-look { display: grid; place-items: center; min-height: 64px; }
++.el-deck-look .dv-elx { min-width: 0; padding: 12px 18px; }
++.el-deck-look .dv-elx-label { font-size: .9375rem; }
++.el-deck-name { font-weight: 650; font-size: .95rem; }
++.el-deck-line { font-size: .8125rem; color: var(--text-muted); line-height: 1.35; }
++.el-g-act { position: sticky; top: 76px; display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border); border-radius: 18px; background: var(--bg-card, var(--bg-secondary)); }
++.el-g-btns { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
++.el-g-btns > form { display: contents; }
++/* The one main action fills the card; the quieter two share the row under it. */
++.el-g-btns--main > .ovk-btn { flex: 1 1 100%; justify-content: center; }
++.el-g-btns > .ovk-btn, .el-g-btns > form > .ovk-btn { flex: 1 1 0; justify-content: center; white-space: nowrap; }
++.el-g-act .dv-fine { margin: 0; }
++.el-g-installed { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 650; color: #34d399; }
++.el-g-installed .ovk-i { width: 18px; height: 18px; }
++.el-g-rating { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; padding-top: 12px; border-top: 1px solid var(--border); color: var(--text-secondary); font-size: .9rem; }
++.el-g-stars { color: #fbbf24; letter-spacing: 1px; }
++.el-g-act input, .el-g-act select, .el-g-act textarea, .el-g-report input, .el-g-report select, .el-g-report textarea { width: 100%; min-width: 0; min-height: 44px; box-sizing: border-box; }
++.el-g-act fieldset { border: 1px solid var(--border); border-radius: 12px; display: grid; gap: 8px; padding: 12px; margin: 0; }
++.el-g-report { margin: 28px 0 0; color: var(--text-muted); font-size: .9rem; }
++.el-g-report summary { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; cursor: pointer; }
++.el-g-report summary .ovk-i { width: 16px; height: 16px; }
++.el-g-report form { display: grid; gap: 8px; max-width: 480px; }
.el-g-reports { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
--.el-g-report-row { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px; }
++.el-g-report-row { border: 1px solid var(--border); border-radius: 12px; padding: 12px; }
.el-g-report-row form { display: flex; flex-wrap: wrap; gap: 8px; }
.dv-prose { line-height: 1.5; }
--@media (max-width: 760px) { .el-g-detail { grid-template-columns: minmax(0, 1fr); } .el-g-preview { min-height: 200px; } }
++@media (max-width: 760px) { .el-g-detail { grid-template-columns: minmax(0, 1fr); gap: 16px; } .el-g-act { position: static; order: -1; } .el-g-stage { min-height: 200px; } }
/* An element take is a look, not a document frame: one compact card, and no device/theme toolbar over it. */
.dv-canvas[data-kind="element"] .dv-rail { justify-content: center; }
ddiff --git a/server/create/element.js b/server/create/element.js
iindex 629b4c4..fb57999 100644
---- a/server/create/element.js
++++ b/server/create/element.js
@@@ -419,13 +419,13 @@ function simulateElement(doc, { price = null } = {}) {
for (const [i, line] of lines.entries()) {
at += 5;
const person = SAMPLE_PEOPLE[(i + 2) % SAMPLE_PEOPLE.length];
-- events.push({ at, kind: 'chat', person: person.name, text: `Chat: ${person.name}: ${line}` });
++ events.push({ at, kind: 'chat', person: person.name, role: person.role, text: `Chat: ${person.name}: ${line}` });
if (line === command.command) fire(person, 'chat_command');
}
} else {
for (const person of SAMPLE_PEOPLE) {
at += 2;
-- events.push({ at, kind: 'press', person: person.name, text: `${person.name} (${person.role}) presses.` });
++ events.push({ at, kind: 'press', person: person.name, role: person.role, text: `${person.name} (${person.role}) presses.` });
fire(person, (recipe.triggers || [])[0] ? recipe.triggers[0].kind : 'press');
}
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b616313..df3e68e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -606,8 +606,11 @@ function createPageRoutes(ctx) {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
const kind = req.query.kind === 'element' || req.query.kind === 'pack' ? req.query.kind : '';
const sort = ['new', 'installs', 'rating'].includes(String(req.query.sort)) ? String(req.query.sort) : 'new';
-- const [listings, count] = await Promise.all([ctx.gallery.list({ q, kind, sort, limit: 60 }), ctx.gallery.count({ q, kind })]);
-- return page(res, 200, galleryView.galleryPage({ listings, count, q, kind, sort, viewer: req.viewer || {},
++ // The kind chips carry their counts (rule 134), so all three are read whichever one is open.
++ const [listings, all, element, pack] = await Promise.all([ctx.gallery.list({ q, kind, sort, limit: 60 }),
++ ctx.gallery.count({ q }), ctx.gallery.count({ q, kind: 'element' }), ctx.gallery.count({ q, kind: 'pack' })]);
++ const counts = { all, element, pack };
++ return page(res, 200, galleryView.galleryPage({ listings, count: kind ? counts[kind] : all, counts, q, kind, sort,
notice: req.query.published === '1' ? 'Published to the gallery.' : null }), req.viewer, { cacheControl: 'public, max-age=60' });
});
r.get('/gallery/moderation', async (req, res) => {
@@@ -633,13 +636,18 @@ function createPageRoutes(ctx) {
r.get('/gallery/:slug/try', async (req, res) => {
const listing = await ctx.gallery.get(req.params.slug);
if (!listing || listing.status !== 'published') return page(res, 404, views.notFound(), req.viewer);
-- const doc = listing.kind === 'pack' ? (listing.document.items || [])[0] : listing.document;
++ // A pack tries one of its elements at a time: ?item=N (0 first), with the others one tap away.
++ const packItems = listing.kind === 'pack' && Array.isArray(listing.document.items) ? listing.document.items : null;
++ const item = packItems ? Math.max(0, Math.min(packItems.length - 1, parseInt(req.query.item, 10) || 0)) : 0;
++ const doc = packItems ? packItems[item] : listing.document;
if (!doc) return page(res, 404, views.notFound(), req.viewer);
const sim = elementLib.simulateElement(doc);
let price = null;
try { price = await elementLib.priceOf(doc, ctx.ai); } catch { /* the price line says unknown */ }
++ const tryPath = `/gallery/${encodeURIComponent(listing.slug)}/try`;
return page(res, 200, elementPreviewView.elementPreview({ design: null, element: doc, sim, price, viewer: req.viewer, mode: 'try',
-- listingId: listing.id, path: `/gallery/${encodeURIComponent(listing.slug)}/try`, listingUrl: galleryNext(listing.slug) }), req.viewer,
++ listingId: listing.id, path: item ? `${tryPath}?item=${item}` : tryPath, listingUrl: galleryNext(listing.slug), listingName: listing.name,
++ pack: packItems ? { items: packItems.map((it) => it.name || 'Element'), index: item, href: tryPath } : null }), req.viewer,
{ cacheControl: 'public, max-age=60' });
});
r.get('/gallery/:slug', (req, res) => galleryListing(req, res, {
ddiff --git a/server/rules/register.js b/server/rules/register.js
iindex 10492e9..d6c2213 100644
---- a/server/rules/register.js
++++ b/server/rules/register.js
@@@ -499,6 +499,12 @@ const RULES = Object.freeze([
R("people-make-their-own-controls", "agents", "Every set of controls can be remade by the person using it.",
"Controls and panels are built from parts a person can move, resize, restyle, hide and add, by hand or by asking OpenVibe's AI (\"put Stop on the left, make the stick bigger\"). The result is theirs, kept with their account, shareable as a layout others can use, and one tap brings back the default. A layout only arranges and styles the controls that exist; it never adds a power the person does not have.",
'2026-11-11', 'The owner: "All controls and other interfaces should use a system where the user can make their own completely how they want it using our AI systems."'),
++ R("groups-show-their-members", "honest", "A pack, a set or a playlist shows what is in it.",
++ "A group's card shows its first few members and how many more there are (\"+4 more\"); its page shows every member, each one opening on its own. A group is never drawn as its first member alone: that says one thing where there are eight. When a member's name is already written on it (a \"Look up\" button), the tile does not print the name again.",
++ '2026-11-12', 'The Elements gallery drew the eight-control LOOI pack as one "Look up" button, on its card and on its page.'),
++ R("logs-keep-cause-and-effect", "data", "A log keeps each cause with what it caused.",
++ "Who did it (or what was said) leads a turn, and what happened sits indented under it, each outcome with its own icon and tint; a refused turn is marked as refused. A log is never a run of equal boxes where the reader pairs \"Ada presses\" with the line after it, and words every row already implies (\"(simulated)\" on a page that says it is a sample) are said once, above the log.",
++ '2026-11-12', 'The element preview\'s sample run showed six presses and their six results as twelve equal boxes, cut off by a scroll box.'),
]);
// The register's order is the rule's number: a person can cite "rule 7" and a link can carry it.
ddiff --git a/server/views/element-preview.js b/server/views/element-preview.js
iindex e8e0241..8f43035 100644
---- a/server/views/element-preview.js
++++ b/server/views/element-preview.js
@@@ -12,23 +12,42 @@ const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
const { renderLook } = require('../create/element');
--const ICON = { press: '●', chat: '💬', refused: '✕', cost: '◆', chance: '?', robot: '▸', screen: '▣', state: '▤', stream: '▶', ai: '✦', money: '¤', unknown: '·' };
++// Each kind of outcome has a kit icon; the row's tint says the same thing in colour (design.css .el-out--*).
++const ICON = { refused: 'ban', cost: 'coin', chance: 'help', robot: 'robot', screen: 'grid', state: 'chart', stream: 'live', ai: 'spark', money: 'coin', chat: 'chat', unknown: 'info' };
const money = (n) => (n == null ? '–' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);
function priceLine({ design = null, price = null }) {
const making = design && design.estimate && Number.isFinite(Number(design.estimate.total))
-- ? `Making it: ${money(design.estimate.total)}${design.estimate.priced === false ? ' (some models unpriced)' : ''}.`
++ ? `Making it cost ${money(design.estimate.total)}${design.estimate.priced === false ? ' (some models unpriced)' : ''}. `
: '';
const calls = price && price.calls && price.calls.length
? (price.calls.map((c) => `${c.available ? `${c.model}: ${money(c.price_in)} in / ${money(c.price_out)} out per million${c.max_cost_usd != null ? `, at most ${money(c.max_cost_usd)} a press` : ''}` : `${c.model || 'The model'}: not on the live catalogue, price unknown`}`).join(' · '))
-- : 'No AI it calls: it runs from the recipe alone.';
-- return `<p class="el-price"><b>Price</b> ${esc(making)} ${esc(calls)} Elements are free: an AI action is paid for by the person whose channel runs it, through their own Balance.</p>`;
++ : null;
++ const lead = calls ? `Each press asks an AI. ${calls}.` : 'Free to run: no AI in it, only its recipe.';
++ return `<p class="el-price">${kit.icon(calls ? 'spark' : 'check')}<span><b>${esc(lead)}</b> ${esc(making)}<small>An AI action is paid by the person whose channel runs it, from their own Balance; the element itself is free.</small></span></p>`;
}
++/**
++ * The sample run as turns: who pressed (or what was said in chat), then what happened, indented under it. Six
++ * people pressing read as twelve equal boxes before; a turn keeps the cause and its result together.
++ */
function timeline(sim) {
if (!sim || !Array.isArray(sim.events) || !sim.events.length) return '<p class="dv-fine">No sample events.</p>';
-- return `<ol class="el-events">${sim.events.map((e) => `<li class="el-event el-event--${esc(e.kind)}"><span class="el-event-icon" aria-hidden="true">${ICON[e.kind] || '·'}</span><span class="el-event-text">${esc(e.text)}</span>${e.person ? `<small>${esc(e.person)}</small>` : ''}</li>`).join('')}</ol>`;
++ const turns = [];
++ for (const e of sim.events) {
++ if (e.kind === 'press' || e.kind === 'chat' || !turns.length) turns.push({ start: e, out: [] });
++ else turns[turns.length - 1].out.push(e);
++ }
++ const plain = (t) => String(t || '').replace(/\s*\(simulated\)/g, '');
++ const who = (e) => {
++ if (e.kind === 'press') return `<b>${esc(e.person || 'Someone')}</b>${e.role ? ` <small>${esc(e.role)}</small>` : ''} <span>presses</span>`;
++ if (e.kind === 'chat') return `${kit.icon('chat')}<span class="el-turn-chat">${esc(plain(e.text).replace(/^Chat:\s*/, ''))}</span>`;
++ return `<span>${esc(plain(e.text))}</span>`;
++ };
++ const out = (e) => `<li class="el-out el-out--${esc(e.kind)}">${kit.icon(ICON[e.kind] || 'info')}<span>${esc(plain(e.text))}</span></li>`;
++ return `<ol class="el-turns">${turns.map((t) => `<li class="el-turn${t.out.some((e) => e.kind === 'refused') ? ' is-refused' : ''}">
++<p class="el-turn-who">${who(t.start)}</p>${t.out.length ? `<ul class="el-turn-out">${t.out.map(out).join('')}</ul>` : ''}</li>`).join('')}</ol>`;
}
/** The robot actions only, in the shape public/js/element-sim.js plays. */
@@@ -87,7 +106,13 @@ function elementPreview(o) {
const listing = design && design.elementListing;
const variant = design && o.variant ? o.variant : null;
const prompts = design ? `<p class="dv-fine">Made from: “${esc(design.prompt)}”</p>${design.elementRemixOf ? `<p class="dv-fine">Remixed from <a href="/gallery/${encodeURIComponent(design.elementRemixOf)}">the original</a>.</p>` : ''}` : '';
-- const back = design ? `<a href="/d/${encodeURIComponent(design.id)}${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}">← Back to the design</a>` : '<a href="/gallery">Elements and packs</a>';
++ const back = design
++ ? { href: `/d/${encodeURIComponent(design.id)}${editKey ? `?key=${encodeURIComponent(editKey)}` : ''}`, label: 'Back to the design' }
++ : { href: o.listingUrl || '/gallery', label: o.listingName || 'Elements and packs' };
++ // A pack's Try it: its elements as chips, the open one marked; each is its own page (?item=N), no script.
++ const pack = o.pack && o.pack.items.length > 1
++ ? `<nav class="el-pack-pick" aria-label="${esc(o.listingName || 'The pack')}: its elements">${o.pack.items.map((name, i) => `<a class="el-g-chip" href="${esc(o.pack.href)}${i ? `?item=${i}` : ''}"${i === o.pack.index ? ' aria-current="page"' : ''}>${esc(name)}</a>`).join('')}</nav>`
++ : '';
const handoffs = mine ? `<div class="el-handoffs">
${channel && !channel.error ? `<p class="dv-ok" role="status">On your channel${channel.url ? `: <a href="${esc(channel.url)}">${esc(channel.url.replace(/^https?:\/\//, ''))}</a>` : '.'}</p>` : ''}
${channel && channel.error ? `<p class="dv-err" role="alert">${esc(channel.error)}</p>` : ''}
@@@ -97,23 +122,25 @@ ${listing && listing.error ? `<p class="dv-err" role="alert">${esc(listing.error
<form method="post" action="/d/${encodeURIComponent(design.id)}/element/gallery"><input type="hidden" name="key" value="${esc(editKey)}">${kit.button({ label: listing && !listing.error ? 'Publish again to the gallery' : 'Publish to the gallery', type: 'submit', kind: 'quiet' })}</form>
<p class="dv-fine">Add to my channel puts it in your Live element library. Publish puts it in the Elements and packs gallery here on Design as a free listing under your name.${signedIn ? '' : ' Sign in to use either.'}</p>
</div>` : (mode === 'canvas' ? '<p class="dv-fine">Sign in at OpenVibe to add this element to your channel or publish it.</p>' : '');
-- const share = o.listingUrl ? `<p><a href="${esc(o.listingUrl)}">See it in the Elements and packs gallery →</a></p>` : '';
++ // The listing's own page is the back link on Try it, so no second "see it in the gallery" link.
++ const share = o.listingUrl && mode !== 'try' ? `<p><a href="${esc(o.listingUrl)}">See it in the Elements and packs gallery →</a></p>` : '';
const tryIt = o.tryUrl ? `<p><a class="ovk-btn ovk-btn--quiet" href="${esc(o.tryUrl)}">Try it again</a></p>` : '';
-- const body = `<section class="el-page">
--<header class="el-head">${back}<h1>${esc(element.name)}</h1>
--<p>${esc(element.description || 'An element made with OpenVibe.Design.')}</p>${prompts}
--${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}${error ? `<p class="dv-err" role="alert">${esc(error)}</p>` : ''}</header>
++ const hasRobot = robotActions(sim).length > 0;
++ const body = `<section class="el-page el-preview">
++${kit.head({ title: element.name, sub: element.description || 'An element made with OpenVibe.Design.', back })}
++${prompts}${pack}
++${notice ? kit.notice({ kind: 'ok', text: notice }) : ''}${error ? kit.notice({ kind: 'error', text: error }) : ''}
<div class="el-grid">
<div class="el-look-panel">
--${renderLook(element)}
--<p class="dv-fine">${esc(element.look.component)} · ${esc(element.look.label || '')}${element.look.style && element.look.style.tint ? ` · tint ${esc(element.look.style.tint)}` : ''}</p>
--<canvas id="el-sim" width="320" height="240" aria-label="OpenVibe.Bot's simulated arena with the element's robot actions played on a local rover"></canvas>
--<p class="dv-fine" id="el-sim-state" role="status">Bot's simulated arena. This is a picture; no robot is connected.</p>
++<div class="el-look-stage">${renderLook(element)}</div>
++${mode === 'canvas' ? `<p class="dv-fine">${esc(element.look.component)} · ${esc(element.look.label || '')}${element.look.style && element.look.style.tint ? ` · tint ${esc(element.look.style.tint)}` : ''}</p>` : ''}
++${hasRobot ? `<canvas id="el-sim" width="320" height="240" aria-label="OpenVibe.Bot's simulated arena with the element's robot actions played on a local rover"></canvas>
++<p class="dv-fine" id="el-sim-state" role="status">Bot's simulated arena: a picture, no robot connected.</p>` : ''}
<script type="application/json" id="el-sim-data">${JSON.stringify({ actions: robotActions(sim) }).replace(/</g, '\\u003c')}</script>
</div>
<div class="el-run">
<h2>On sample data</h2>
--<p class="dv-fine">${esc(sim && sim.note || 'The recipe is run against sample people and a fake chat.')}</p>
++<p class="dv-fine">${sim && Array.isArray(sim.events) && sim.events.some((e) => e.kind === 'chat') ? 'Sample people type in a sample chat.' : 'Six sample people, one of each role, press it.'} No robot, Live channel, chat or AI is touched.</p>
${timeline(sim)}
${priceLine({ design, price })}
</div></div>
ddiff --git a/server/views/gallery.js b/server/views/gallery.js
iindex 18262d5..474695e 100644
---- a/server/views/gallery.js
++++ b/server/views/gallery.js
@@@ -4,6 +4,9 @@
* The Elements and packs gallery on OpenVibe.Design: the list beside /create, one listing with Install, Remix,
* Try it, a rating and a report, and the staff report queue. The look is drawn from the element document with
* Design's own control preview; the real one is the Try it page.
++ *
++ * A pack shows its whole deck, on its card (the first four, smaller) and on its page (every element, each one a door
++ * to Try it): a pack drawn as its first button said one control where there are eight.
*/
const kit = require('openvibe-shared/kit');
const { layout, esc } = require('../render/layout');
@@@ -11,81 +14,142 @@ const { renderLook } = require('../create/element');
const stars = (n) => '★★★★★'.slice(0, n) + '☆☆☆☆☆'.slice(0, 5 - n);
const ratingText = (r) => (r && r.count ? `${r.average} / 5 from ${r.count} rating${r.count === 1 ? '' : 's'}` : 'No ratings yet');
--const lookOf = (l) => renderLook({ look: l.look || (l.document && l.document.items && l.document.items[0] && l.document.items[0].look) || { component: 'button', label: l.name } });
++const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
++const itemsOf = (l) => (l.kind === 'pack' && l.document && Array.isArray(l.document.items) ? l.document.items : []);
++const firstLook = (l) => renderLook({ look: l.look || (itemsOf(l)[0] && itemsOf(l)[0].look) || (l.document && l.document.look) || { component: 'button', label: l.name } });
++const tryHref = (l, i = null) => `/gallery/${encodeURIComponent(l.slug)}/try${i ? `?item=${i}` : ''}`;
++
++/** The card's picture: an element's look, or a pack's first four looks in a small deck with "+N more". */
++function cardLook(l) {
++ const items = itemsOf(l);
++ if (!items.length) return `<span class="el-g-look">${firstLook(l)}</span>`;
++ const shown = items.slice(0, 4).map((it) => `<span class="el-g-mini">${renderLook(it)}</span>`).join('');
++ const more = items.length > 4 ? `<span class="el-g-more">+${items.length - 4} more</span>` : '';
++ return `<span class="el-g-look el-g-look--pack" data-count="${Math.min(items.length, 4)}">${shown}${more}</span>`;
++}
function card(l) {
++ const items = itemsOf(l);
++ const what = l.kind === 'pack' ? `Pack · ${plural(items.length, 'element')}` : 'Element';
return `<li class="el-g-card"><a href="/gallery/${encodeURIComponent(l.slug)}">
--<span class="el-g-look">${lookOf(l)}</span>
--<span class="el-g-text"><span class="el-g-title"><b>${esc(l.name)}</b> ${kit.badge(l.kind === 'pack' ? 'Pack' : 'Element', l.official ? 'accent' : undefined)}</span>
--<small>${esc(l.author && l.author.name || 'A maker')} · ${l.installs} install${l.installs === 1 ? '' : 's'} · ${esc(ratingText(l.rating))}</small>
--<span class="el-g-summary">${esc(l.summary || '')}</span></span></a></li>`;
++${cardLook(l)}
++<span class="el-g-text"><span class="el-g-kind">${esc(what)}${l.official ? ` ${kit.badge('Official', 'accent')}` : ''}</span>
++<span class="el-g-title">${esc(l.name)}</span>
++<span class="el-g-summary">${esc(l.summary || '')}</span>
++<small class="el-g-by">${esc(l.author && l.author.name || 'A maker')} · ${plural(l.installs, 'install')} · ${esc(ratingText(l.rating))}</small></span></a></li>`;
}
--function galleryPage({ listings = [], count = 0, q = '', kind = '', sort = 'new', viewer = {}, notice = null } = {}) {
-- const signedIn = viewer.kind === 'user';
++const KINDS = [['', 'All', 'grid'], ['element', 'Elements', 'spark'], ['pack', 'Packs', 'folder']];
++const SORTS = [['new', 'Newest'], ['installs', 'Most installed'], ['rating', 'Best rated']];
++
++/** The kind filters (icon, name and count: rule 134) and the order, as links: crawlable, no script. */
++function filters({ q, kind, sort, counts }) {
++ const href = (k, s) => {
++ const p = new URLSearchParams();
++ if (q) p.set('q', q);
++ if (k) p.set('kind', k);
++ if (s && s !== 'new') p.set('sort', s);
++ const qs = p.toString();
++ return `/gallery${qs ? `?${qs}` : ''}`;
++ };
++ const n = (k) => (k ? counts[k] : counts.all);
++ const chips = KINDS.map(([k, label, ic]) => `<a class="el-g-chip" href="${esc(href(k, sort))}"${k === kind ? ' aria-current="page"' : ''}><span class="el-g-chip-ic">${kit.icon(ic)}</span><span>${label}</span><span class="el-g-n">${Number(n(k)) || 0}</span></a>`).join('');
++ const order = SORTS.map(([s, label]) => `<a href="${esc(href(kind, s))}"${s === sort ? ' aria-current="true"' : ''}>${label}</a>`).join('');
++ return `<div class="el-g-filters"><nav class="el-g-chips" aria-label="Kind">${chips}</nav><nav class="el-g-sort" aria-label="Order">${order}</nav></div>`;
++}
++
++function galleryPage({ listings = [], count = 0, counts = null, q = '', kind = '', sort = 'new', notice = null } = {}) {
++ const n = counts || { all: count, element: kind === 'element' ? count : 0, pack: kind === 'pack' ? count : 0 };
++ const empty = q
++ ? kit.empty({ icon: 'search', title: `Nothing matches “${q}”`, text: 'Try another word, or make the control you were looking for.', action: { href: '/create', label: 'Make one' } })
++ : kit.empty({ icon: 'spark', title: 'Nothing here yet', text: 'Make a control with AI and publish it from its preview.', action: { href: '/create', label: 'Make one' } });
const body = `<section class="el-page el-gallery">
--${kit.head({ title: 'Elements and packs', sub: 'Free controls, buttons and stream widgets people made with AI. Install one on your channel, remix it, or try it first.', actions: [kit.button({ href: '/create', label: 'Make one' })] })}
--${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}
--<div class="el-g-sec">
--<form class="el-g-search" method="get" action="/gallery" role="search">
--<input type="search" name="q" value="${esc(q)}" placeholder="Search elements and packs" aria-label="Search elements and packs">
--<select name="kind" aria-label="Kind"><option value="">All kinds</option><option value="element"${kind === 'element' ? ' selected' : ''}>Elements</option><option value="pack"${kind === 'pack' ? ' selected' : ''}>Packs</option></select>
--<select name="sort" aria-label="Sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="installs"${sort === 'installs' ? ' selected' : ''}>Most installed</option><option value="rating"${sort === 'rating' ? ' selected' : ''}>Best rated</option></select>
--${kit.button({ label: 'Search', kind: 'quiet', type: 'submit' })}
--</form>
--<p class="dv-fine">${count} listing${count === 1 ? '' : 's'}, free to install.${signedIn ? ' Make one in <a href="/create">/create</a>, choose Element, then publish it from its preview.' : ''}</p>
--${listings.length ? `<ul class="el-g-list">${listings.map(card).join('')}</ul>` : kit.empty({ icon: 'spark', title: 'Nothing here yet', text: 'No listing matches that search.' })}
--</div></section>`;
-- return layout({ title: 'Elements and packs', description: 'Free buttons, controls and stream widgets people made with AI: install one on your channel, remix it, or try it first.', path: '/gallery', body, active: '/gallery' });
++${kit.head({ title: 'Elements and packs', sub: 'Free controls, buttons and stream widgets made with AI. Try one, install it, remix it.', actions: [kit.button({ href: '/create', label: 'Make one', icon: 'spark' })] })}
++${notice ? kit.notice({ kind: 'ok', text: notice }) : ''}
++${kit.find({ action: '/gallery', value: q, placeholder: 'Search elements and packs', hidden: { ...(kind ? { kind } : {}), ...(sort !== 'new' ? { sort } : {}) } })}
++${filters({ q, kind, sort, counts: n })}
++${listings.length ? `<ul class="el-g-list">${listings.map(card).join('')}</ul>` : empty}
++</section>`;
++ return layout({ title: 'Elements and packs', description: 'Free buttons, controls and stream widgets people made with AI: try one, install it on your channel, or remix it.', path: '/gallery', body, active: '/gallery' });
}
--function itemList(l) {
-- if (l.kind !== 'pack' || !Array.isArray(l.document.items)) return '';
-- return `<ul class="el-g-items">${l.document.items.map((it) => `<li><b>${esc(it.name || 'Element')}</b>${it.description ? ` <small>${esc(it.description)}</small>` : ''}</li>`).join('')}</ul>`;
++// A tile names its element only when the control does not already say it ("Look up" on a "Look up" button).
++const sameWords = (it) => String(it.name || '').trim().toLowerCase() === String(it.look && it.look.label || '').trim().toLowerCase()
++ && !(it.look && it.look.style && it.look.style.show_label === false);
++
++/** A pack's deck: every element as a tile (its look, name and line), each one opening Try it on that element. */
++function deck(l) {
++ const items = itemsOf(l);
++ if (!items.length) {
++ return `<div class="el-g-stage">${firstLook(l)}</div>`;
++ }
++ return `<ul class="el-deck">${items.map((it, i) => `<li><a class="el-deck-tile" href="${esc(tryHref(l, i))}" aria-label="Try ${esc(it.name || 'this element')}">
++<span class="el-deck-look">${renderLook(it)}</span>
++${sameWords(it) ? '' : `<span class="el-deck-name">${esc(it.name || 'Element')}</span>`}${it.description ? `<span class="el-deck-line">${esc(it.description)}</span>` : ''}
++</a></li>`).join('')}</ul>`;
}
--function listingPage({ listing: l, viewer = {}, installed = null, myRating = null, canRemix = false, notice = null, error = null } = {}) {
-- const signedIn = viewer.kind === 'user';
-- const next = `/gallery/${encodeURIComponent(l.slug)}`;
-- const installForm = !signedIn
-- ? kit.notice({ kind: 'info', text: `Sign in to install or rate this ${l.kind}.` }) + `<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe' })}</p>`
-- : installed
-- ? `<p class="dv-ok" role="status">${kit.badge('Installed', 'ok')} On your channel${installed.channel ? ` (${esc(installed.channel)})` : ''}${installed.library && installed.library.error ? `; Live said: ${esc(installed.library.error)}` : ''}.</p>`
-- : `<form method="post" action="${next}/install"><p>${kit.button({ label: 'Install', type: 'submit' })}</p><p class="dv-fine">Free. It goes to your Live channel and the maker is credited.</p></form>`;
-- const rateForm = signedIn && installed
++/** The one place to act: install (or sign in), try, remix, and the rating. */
++function actions({ l, signedIn, installed, myRating, canRemix, next }) {
++ const kindWord = l.kind === 'pack' ? 'pack' : 'element';
++ const tryBtn = kit.button({ href: tryHref(l), label: 'Try it', kind: 'quiet', icon: 'play' });
++ let main;
++ if (!signedIn) {
++ main = `<div class="el-g-btns el-g-btns--main">${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in to install' })}</div><div class="el-g-btns">${tryBtn}</div>
++<p class="dv-fine">Try it needs no account. Installing, remixing and rating do.</p>`;
++ } else if (installed) {
++ const err = installed.library && installed.library.error;
++ main = `<p class="el-g-installed">${kit.icon('check')}<span>Installed on your channel${installed.channel ? ` (${esc(installed.channel)})` : ''}</span></p>
++${err ? kit.notice({ kind: 'warn', text: `Live said: ${err}` }) : ''}<div class="el-g-btns">${tryBtn}${canRemix ? remixButton(next) : ''}</div>`;
++ } else {
++ main = `<form method="post" action="${next}/install" class="el-g-btns el-g-btns--main">${kit.button({ label: `Install ${kindWord}`, type: 'submit', icon: 'download' })}</form>
++<div class="el-g-btns">${tryBtn}${canRemix ? remixButton(next) : ''}</div>
++<p class="dv-fine">Free. It goes to your Live channel and ${esc(l.author && l.author.name || 'the maker')} is credited${canRemix ? '; a remix is your own copy that credits them too' : ''}.</p>`;
++ }
++ const rate = signedIn && installed
? `<form method="post" action="${next}/rating" class="el-g-rate"><fieldset><legend>Your rating</legend>
<label>Stars <select name="stars" required>${[5, 4, 3, 2, 1].map((n) => `<option value="${n}"${myRating && myRating.stars === n ? ' selected' : ''}>${n} — ${stars(n)}</option>`).join('')}</select></label>
<label>Note (optional) <input type="text" name="note" maxlength="400" value="${esc(myRating && myRating.note || '')}"></label>
${kit.button({ label: myRating ? 'Update rating' : 'Rate it', type: 'submit', kind: 'quiet' })}</fieldset></form>`
-- : (signedIn ? '<p class="dv-fine">Install it to rate it.</p>' : '');
-- const remixForm = signedIn && canRemix
-- ? `<form method="post" action="${next}/remix" class="el-g-remix"><p>${kit.button({ label: 'Remix', type: 'submit', kind: 'quiet' })}</p><p class="dv-fine">A copy of your own that credits ${esc(l.author && l.author.name || 'the original')}.</p></form>`
-- : (signedIn ? '<p class="dv-fine">Your own element; other people can remix it and credit you.</p>' : `<p class="dv-fine"><a href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> to remix it.</p>`);
-- const reportForm = signedIn
-- ? `<details class="el-g-report"><summary>Report this ${l.kind}</summary><form method="post" action="${next}/report">
++ : '';
++ const r = l.rating;
++ const ratingLine = `<p class="el-g-rating">${r && r.count ? `<span class="el-g-stars" aria-hidden="true">${stars(Math.round(r.average))}</span>` : ''}<span>${esc(ratingText(r))}${signedIn && !installed && canRemix ? ' · install it to rate it' : ''}</span></p>`;
++ return `<aside class="el-g-act" aria-label="Install and try">${main}${ratingLine}${rate}</aside>`;
++}
++
++const remixButton = (next) => `<form method="post" action="${next}/remix">${kit.button({ label: 'Remix a copy', type: 'submit', kind: 'quiet', icon: 'copy' })}</form>`;
++
++function listingPage({ listing: l, viewer = {}, installed = null, myRating = null, canRemix = false, notice = null, error = null } = {}) {
++ const signedIn = viewer.kind === 'user';
++ const next = `/gallery/${encodeURIComponent(l.slug)}`;
++ const items = itemsOf(l);
++ const sub = [l.kind === 'pack' ? `Pack of ${plural(items.length, 'element')}` : 'Element', `by ${l.author && l.author.name || 'a maker'}`, plural(l.installs, 'install')].join(' · ');
++ const report = signedIn
++ ? `<details class="el-g-report"><summary>${kit.icon('flag')}Report this ${l.kind}</summary><form method="post" action="${next}/report">
<label>Reason <select name="reason"><option value="unsafe">Unsafe</option><option value="broken">Broken</option><option value="spam">Spam</option><option value="other">Other</option></select></label>
<label>What is wrong? <textarea name="detail" maxlength="1000" rows="3"></textarea></label>
--${kit.button({ label: 'Send report', type: 'submit', kind: 'quiet' })}</form><p class="dv-fine">Staff read reports; nothing is hidden by a report itself.</p></details>`
++${kit.button({ label: 'Send report', type: 'submit', kind: 'quiet' })}</form><p class="dv-fine">Staff read every report; a report alone hides nothing.</p></details>`
: '';
++ const own = signedIn && !canRemix && l.author && viewer.subject === l.author.subject
++ ? '<p class="dv-fine">Yours. Other people can remix it, and every copy credits you.</p>' : '';
return layout({
title: l.name, description: l.summary || `${l.name}, a free ${l.kind} for OpenVibe.`, path: next,
robots: l.status === 'published' ? 'index, follow' : 'noindex, nofollow',
active: '/gallery',
body: `<section class="el-page el-listing">
--${kit.head({ title: l.name, sub: `${l.kind === 'pack' ? 'Pack' : 'Element'} by ${l.author && l.author.name || 'a maker'} · ${l.installs} install${l.installs === 1 ? '' : 's'}`, back: { href: '/gallery', label: 'Elements and packs' }, actions: [kit.badge('Free', 'ok')] })}
--${notice ? `<p class="dv-ok" role="status">${esc(notice)}</p>` : ''}${error ? `<p class="dv-err" role="alert">${esc(error)}</p>` : ''}
--<div class="el-g-detail">
--<div class="el-g-preview">${lookOf(l)}</div>
--<div class="el-g-side">
--${l.summary ? `<p class="dv-prose">${esc(l.summary)}</p>` : ''}
++${kit.head({ title: l.name, sub, back: { href: '/gallery', label: 'Elements and packs' }, actions: [kit.badge('Free', 'ok')] })}
++${notice ? kit.notice({ kind: 'ok', text: notice }) : ''}${error ? kit.notice({ kind: 'error', text: error }) : ''}
++${l.summary ? `<p class="el-g-lede">${esc(l.summary)}</p>` : ''}
${l.remix_of ? `<p class="dv-fine">Remixed from <a href="/gallery/${encodeURIComponent(l.remix_of)}">an earlier element</a>.</p>` : ''}
--<h2>Install</h2>${installForm}
--<h2>Try it</h2><p><a class="ovk-btn ovk-btn--quiet" href="${next}/try">Try it</a></p>
--<h2>Remix</h2>${remixForm}
--<h2>Rating</h2><p>${esc(ratingText(l.rating))}</p>${rateForm}
--${itemList(l)}
--<h2>Report</h2>${reportForm}
--</div></div></section>`,
++<div class="el-g-detail">
++<div class="el-g-main">
++${items.length ? `<h2 class="el-g-h">In this pack</h2><p class="dv-fine">Tap one to try it on sample data.</p>` : ''}
++${deck(l)}
++</div>
++${actions({ l, signedIn, installed, myRating, canRemix, next })}
++</div>
++${own}${report}
++</section>`,
});
}
ddiff --git a/test/element.test.js b/test/element.test.js
iindex 02cfe9d..d906899 100644
---- a/test/element.test.js
++++ b/test/element.test.js
@@@ -186,7 +186,8 @@ const fakeAi = (state, doc = ELEMENT) => ({
const tryPage = await t.get('/gallery/big-red-button/try');
assert.strictEqual(tryPage.status, 200, tryPage.text);
assert.ok(tryPage.text.includes('On sample data'));
-- assert.ok(tryPage.text.includes('See it in the Elements and packs gallery'));
++ assert.ok(tryPage.text.includes('href="/gallery/big-red-button"'), 'Try it leads back to its listing');
++ assert.ok(tryPage.text.includes('class="el-turns"'), 'the sample run reads as turns: who pressed, then what happened');
const report = await t.get('/gallery/big-red-button/report', { headers: { ...token(bob), 'content-type': 'application/x-www-form-urlencoded' }, body: 'reason=broken&detail=The+button+does+nothing' });
assert.strictEqual(report.status, 303, report.text);
const queue = await t.get('/gallery/moderation', { as: mod });
@@@ -236,6 +237,20 @@ const fakeAi = (state, doc = ELEMENT) => ({
assert.strictEqual(page.status, 200, page.text);
assert.ok(page.text.includes('LOOI pack'));
assert.ok(page.text.includes('Headlights on'));
++ // The pack's page shows its whole deck, each element a door to Try it on that element.
++ const items = first.document.items;
++ assert.strictEqual((page.text.match(/class="el-deck-tile"/g) || []).length, items.length, 'every element of the pack is on its page');
++ assert.ok(page.text.includes('href="/gallery/looi-pack/try?item=2"'));
++ const third = await t.get('/gallery/looi-pack/try?item=2');
++ assert.strictEqual(third.status, 200, third.text);
++ assert.ok(third.text.includes(`<h1`) && third.text.includes(items[2].name), 'Try it opens the element asked for');
++ assert.ok(third.text.includes('aria-current="page"') && third.text.includes('class="el-pack-pick"'), 'the pack\'s other elements are one tap away');
++ const far = await t.get('/gallery/looi-pack/try?item=99');
++ assert.ok(far.text.includes(items[items.length - 1].name), 'an item past the end opens the last one');
++ // The gallery's kind chips carry their counts.
++ const gallery = await t.get('/gallery');
++ assert.match(gallery.text, /Packs<\/span><span class="el-g-n">\d+<\/span>/);
++ assert.ok(gallery.text.includes('class="el-g-mini"'), 'a pack card shows its first elements, not one button');
const remix = await t.get('/gallery/looi-pack/remix', { headers: token(bob), body: 'key=' });
assert.strictEqual(remix.status, 303, remix.text);
const copySlug = /\/gallery\/([^?]+)/.exec(remix.headers.get('location') || '')?.[1];