openvibers/openvibe.community Commits
New paste: the form as an app — one row of choices, switch rows, a code box (#319)
Changes
@@@ -19,7 +19,7 @@"multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.169.0","pg": "^8.23.0"},"devDependencies": {@@@ -1236,9 +1236,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.168.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",-- "integrity": "sha512-Ahp6kif2XWtGr1DTQfovmDyUuWMDcZN7ygRmkahIc3A1uY6u5QCQxNRD/PbyGUKdT++VW9jmnjMaHlF3hogpuQ==",++ "version": "4.169.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.169.0",++ "integrity": "sha512-qqx69Fy+sOjOf8WLVuxRJ5szwyPFhRgmU+pqGAPqWHYxMtcWSnRlxGtImCCKOxFJ9q5ERYpDumfuzxELKnL1RA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -31,7 +31,7 @@"multer": "^2.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.169.0","pg": "^8.23.0"},"engines": {@@@ -128,7 +128,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.hljs-strong { font-weight: 700; }/* Forms */--.paste-form { display: flex; flex-direction: column; gap: 14px; max-width: 900px; }++.paste-form { display: flex; flex-direction: column; gap: 14px; max-width: 860px; }++.paste-form [hidden] { display: none !important; }.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }.field > span { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }.field input[type="text"], .field select, .field textarea, .field input[type="file"] { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; font: inherit; width: 100%; }@@@ -145,6 +146,61 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }.alert-error { border-color: var(--danger); color: var(--danger-text, var(--danger)); }++/* ── New paste (the owner, 11-09: minimalist, modern, app-like): a titled code box, one row of choices, the++ options as switch rows, and a Create button pinned above the app bar on a phone. ── */++.pf-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin-bottom: 6px; }++.pf-head h1 { margin: 0; }++.pf-who { display: inline-flex; align-items: center; gap: 9px; min-height: 44px; box-sizing: border-box; padding: 4px 14px 4px 4px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); color: var(--text-primary); text-decoration: none; }++.pf-who:hover { border-color: var(--accent); text-decoration: none; }++.pf-who .avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex: none; }++.pf-who-text { display: grid; gap: 0; line-height: 1.2; }++.pf-who-text small { color: var(--text-secondary); font-size: .7rem; font-weight: 600; }++.pf-who-text strong { font-size: .88rem; }++.pf-anon { margin: 0; }++.pf-modes { align-self: flex-start; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); }++.pf-modes .tab { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 6px 14px; border: 0; border-radius: 999px; }++.pf-modes .tab .ovk-i { width: 15px; height: 15px; }++.pf-modes .tab.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }++.pf-title input { font-size: 1.05rem; font-weight: 650; padding: 11px 14px; }++.pf-row { display: flex; align-items: flex-start; gap: 16px; }++.pf-lang { flex: 0 1 260px; }++.pf-vis { flex: 1 1 340px; min-width: 0; }++.pf-vis .pf-label { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }++.pf-vis-mean { margin: 6px 0 0; min-height: 1.2em; color: var(--text-secondary); font-size: .8rem; }++.pf-vis-mean > span { display: none; }++.pf-vis:has(input[value="public"]:checked) [data-vis-for="public"],++.pf-vis:has(input[value="unlisted"]:checked) [data-vis-for="unlisted"],++.pf-vis:has(input[value="private"]:checked) [data-vis-for="private"] { display: block; }++.pf-audience { flex: 1 1 340px; }++.pf-pane { display: grid; gap: 10px; }++.codebox { border: 1px solid var(--border); border-radius: 12px; background: var(--bg-input); overflow: hidden; transition: border-color .15s, box-shadow .15s; }++.codebox:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .16); }++.codebox.is-drop { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .22); }++.codebox-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 38px; padding: 4px 10px; border-bottom: 1px solid var(--border); background: var(--bg-card); }++.codebox-drop { color: var(--text-secondary); font-size: .76rem; }++.pf-file { background: none; border: 0; padding: 0; color: var(--accent-light); font: inherit; text-decoration: underline; cursor: pointer; }++.codebox-lang { flex: none; padding: 3px 9px; border: 1px solid var(--border); border-radius: 999px; color: var(--text-secondary); font-size: .7rem; font-weight: 600; line-height: 1.4; letter-spacing: .02em; }++.codebox-body { display: grid; grid-template-columns: auto minmax(0, 1fr); }++.codebox-gutter { overflow: hidden; padding: 14px 9px 14px 12px; border-right: 1px solid var(--border); background: var(--bg-card); color: var(--text-muted); font: 13px/1.55 var(--mono); text-align: right; user-select: none; }++.codebox-gutter > div { white-space: pre; }++.codebox textarea { display: block; width: 100%; margin: 0; padding: 14px 16px; border: 0; border-radius: 0; background: transparent; color: var(--text-primary); font: 13px/1.55 var(--mono); min-height: 0; resize: none; overflow: auto; tab-size: 4; }++.codebox textarea:focus { outline: 0; box-shadow: none; }++.pf-options { margin: 4px 0 0; }++.pf-options[hidden], .pf-options [hidden] { display: none !important; }++.pf-options .lock-it-body { margin: 0 14px 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-input); }++.pf-actions { display: grid; gap: 8px; }++.pf-create { justify-self: start; }++.pf-actions .form-status:empty { min-height: 0; }++.pf-rules { margin: 0; color: var(--text-secondary); font-size: .76rem; }++.pf-rules a { color: var(--text-secondary); }++@media (max-width: 700px) {++ .pf-row { flex-direction: column; gap: 12px; }++ .pf-lang, .pf-vis, .pf-audience { flex: 1 1 auto; width: 100%; }++ .pf-create { width: 100%; justify-self: stretch; }++ .pf-actions { position: sticky; bottom: calc(var(--ovnav-appbar-h, 0px) + 8px); z-index: 30; margin-top: 2px; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: var(--bg-primary); border-top: 1px solid var(--border); }++ html.ov-kb-open .pf-actions { position: static; padding-bottom: 0; background: none; border-top: 0; }++}++/* Spaces, threads, posts, Pulse */.space-list, .thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }.space-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }@@@ -458,17 +514,10 @@ button.rx:hover { border-color: var(--accent); }.thread-head .ovk-labels { margin: 2px 0 6px; }/* ── Locked pastes (Locker, design 2026-11-06): the create form's Lock it, the locked card, the opened text ── */--.lock-it { margin: 14px 0; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-input); display: grid; gap: 10px; }--.lock-it[hidden], .lock-it [hidden] { display: none !important; } /* the section and its choices are JS-only */.paste-form .field[hidden] { display: none !important; }.lock-audience { margin: 0; padding: 9px 12px; border: 1px dashed var(--border); border-radius: 9px; color: var(--text-primary); background: none; font-size: .95rem; }.lock-audience[hidden] { display: none !important; }--.lock-it-switch { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 4px 8px; font-size: .95rem; }--.lock-it-switch .muted { grid-column: 1 / -1; }--.lock-it-switch strong { white-space: nowrap; }--.lock-it-switch strong { color: var(--text-primary); }--.lock-it-switch i { color: var(--accent-light); }--.lock-it-body { display: grid; gap: 12px; padding-top: 4px; border-top: 1px solid var(--border); }++.lock-it-body { display: grid; gap: 12px; }.lock-it-body p { margin: 0; }.lock-kinds { display: grid; gap: 8px; }.lock-kind { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }@@@ -111,6 +111,7 @@tabs.forEach(function (t) { t.hidden = false; });var lockSection = $('[data-lock-it]', form);var visField = $('[data-vis-field]', form);++ var visibilityInputs = $$('input[name="visibility"]', form);var audienceLine = $('[data-lock-audience]', form);var audienceText = $('[data-lock-audience-text]', form);var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };@@@ -137,9 +138,8 @@if (lockBody) lockBody.hidden = !on;// The audience is the lock's (Specific people, or the link), so the column is replaced by one read-only line// naming it, and comes back when Lock it is off.-- var vis = form.elements.visibility;var kind = (form.querySelector('input[name="lock_kind"]:checked') || {}).value || 'people';-- if (vis) vis.disabled = on;++ visibilityInputs.forEach(function (i) { i.disabled = on; });if (visField) visField.hidden = on;if (audienceLine) audienceLine.hidden = !on;if (audienceText) audienceText.textContent = LOCK_AUDIENCE[kind] || kind;@@@ -150,7 +150,74 @@$$('input[name="lock_kind"]', form).forEach(function (r) { r.addEventListener('change', syncLock); });syncLock();}-- tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }, { signal }); });++ tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); if (t.getAttribute('data-tab') === 'text') growBox(); }, { signal }); });++++ // ── The code box: line numbers, a box that grows from 12 lines, the language chip, and a dropped file ──++ var codebox = $('[data-codebox]', form);++ var contentBox = form.elements.content;++ var gutterBox = $('[data-gutter]', form);++ var langSelect = $('[data-lang-select]', form);++ var langChip = $('[data-lang-chip]', form);++ var fileInput = $('[data-code-file-input]', form);++ var minBox = 0;++ function paintGutter() {++ if (!contentBox || !gutterBox) return;++ var n = Math.max(12, contentBox.value.split('\n').length);++ var out = '';++ for (var i = 1; i <= n; i++) out += i + (i === n ? '' : '\n');++ gutterBox.textContent = out;++ if (gutterBox.parentNode) gutterBox.parentNode.scrollTop = contentBox.scrollTop;++ }++ function growBox() {++ if (!contentBox) return;++ var cs = window.getComputedStyle(contentBox);++ if (!minBox) minBox = contentBox.offsetHeight || (12 * parseFloat(cs.lineHeight) + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom));++ contentBox.style.height = 'auto';++ var max = Math.round(window.innerHeight * 0.68);++ contentBox.style.height = Math.max(minBox, Math.min(contentBox.scrollHeight, max)) + 'px';++ paintGutter();++ }++ function readText(f) {++ var r = new FileReader();++ r.onload = function () {++ contentBox.value = String(r.result || '');++ if (!form.elements.title.value.trim()) form.elements.title.value = f.name.replace(/\.[^.]+$/, '');++ setMode('text');++ growBox();++ contentBox.focus();++ };++ r.readAsText(f);++ }++ function fillFile(f) {++ if (!f) return;++ if (/^image\//.test(f.type)) { takeFile(f); return; }++ if (/^text\//.test(f.type) || /\.(txt|log|md|markdown|json|jsonc|js|mjs|cjs|ts|tsx|jsx|py|sh|bash|zsh|yml|yaml|toml|ini|sql|css|scss|html?|xml|diff|patch|go|rs|php|rb|java|kt|swift|c|cpp|cs|lua|pl|r|conf|nginx)$/i.test(f.name)) readText(f);++ }++ if (contentBox && gutterBox) {++ growBox();++ contentBox.addEventListener('input', growBox, { signal });++ contentBox.addEventListener('scroll', function () { if (gutterBox.parentNode) gutterBox.parentNode.scrollTop = contentBox.scrollTop; }, { signal });++ window.addEventListener('resize', growBox, { signal });++ }++ if (langSelect && langChip) {++ var sayLang = function () { var o = langSelect.options[langSelect.selectedIndex]; langChip.textContent = o ? o.text : 'Auto-detect'; };++ langSelect.addEventListener('change', sayLang, { signal }); sayLang();++ }++ if (fileInput) {++ fileInput.addEventListener('change', function () { fillFile(fileInput.files && fileInput.files[0]); fileInput.value = ''; }, { signal });++ var pickFile = $('[data-code-file]', form);++ if (pickFile) pickFile.addEventListener('click', function () { fileInput.click(); }, { signal });++ }++ if (codebox) {++ ['dragenter', 'dragover'].forEach(function (ev) { codebox.addEventListener(ev, function (e) { e.preventDefault(); codebox.classList.add('is-drop'); }, { signal }); });++ codebox.addEventListener('dragleave', function () { codebox.classList.remove('is-drop'); }, { signal });++ codebox.addEventListener('drop', function (e) {++ codebox.classList.remove('is-drop');++ if (!e.dataTransfer || !e.dataTransfer.files[0]) return;++ e.preventDefault(); e.stopPropagation();++ fillFile(e.dataTransfer.files[0]);++ }, { signal });++ }setMode('text');// Dropping or pasting an image anywhere on the form switches to the screenshot mode.function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }@@@ -601,71 +601,99 @@ window.addEventListener('DOMContentLoaded', function () {function newPage({ user, values = {}, error = null, fork = null }) {const v = { title: '', language: 'auto', content: '', visibility: 'public', ...values };if (fork) { v.title = v.title || `Fork of ${fork.title || 'paste'}`; v.content = v.content || fork.content || ''; v.language = fork.language || v.language; }++ // Signed out, Private and Lock it do not exist; a failed post can still come back asking for one.++ const visibility = !user && v.visibility === 'private' ? 'public' : v.visibility;const langOpts = LANGUAGES.map(([id, label]) => `<option value="${id}"${id === v.language ? ' selected' : ''}>${esc(label)}</option>`).join('');++ const visItems = [++ { value: 'public', label: 'Public', icon: 'globe' },++ { value: 'unlisted', label: 'Unlisted', icon: 'link' },++ ...(user ? [{ value: 'private', label: 'Private', icon: 'lock' }] : []),++ ];++ const visMeanings = [++ ['public', 'Listed on Pastes and in search — anyone can find it.'],++ ['unlisted', 'Only people with the link can open it.'],++ ...(user ? [['private', 'Only you can open it.']] : []),++ ];++ const lines = Math.max(12, String(v.content || '').split('\n').length);++ const gutter = Array.from({ length: lines }, (_, i) => i + 1).join('\n');++ const whoPic = user ? avatarUrl(user, 72) : null;++ const who = user++ ? `<a class="pf-who" href="/my" title="My pastes">${whoPic ? `<img class="avatar" src="${esc(whoPic)}" alt="" width="28" height="28" loading="lazy">` : ''}<span class="pf-who-text"><small>Posting as</small><strong>${esc(user.display_name || user.username)}</strong></span></a>`++ : `<p class="pf-anon muted small">Posting anonymously. <a href="/auth/login?next=%2Fnew">Sign in</a> to keep your pastes together.</p>`;++ const lock = user ? `<div data-lock-it hidden>++ ${kit.switchRow({ name: 'lock_it', icon: 'lock', title: 'Lock it', line: 'OpenVibe can’t read what’s inside' })}++ <div class="lock-it-body" data-lock-body hidden>++ <p class="muted small">Choose how people open it. Your browser seals the content before it leaves this page; the key never reaches OpenVibe.</p>++ <div class="lock-kinds" role="radiogroup" aria-label="How to lock it">++ <label class="lock-kind"><input type="radio" name="lock_kind" value="people" checked>++ <span><strong>For the people on the list</strong><small>Only you and the people you name can open it, each with their own keys. It switches the audience to Specific people.</small></span></label>++ <label class="lock-kind"><input type="radio" name="lock_kind" value="password">++ <span><strong>With a password</strong><small>Anyone you give the password to can open it. If the password is lost, nobody can open it, including OpenVibe.</small></span></label>++ <label class="lock-kind"><input type="radio" name="lock_kind" value="link">++ <span><strong>With the link</strong><small>The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.</small></span></label>++ </div>++ <div data-lock-for="people">++ <label class="field"><span>People</span>++ <input type="text" name="lock_people" placeholder="@maticus, @quinn" autocomplete="off" spellcheck="false">++ </label>++ <p class="muted small">Their OpenVibe names. Each needs a Locker: they make one the first time they open a locked item.</p>++ </div>++ <label class="field" data-lock-for="password" hidden><span>Password</span>++ <input type="password" name="lock_password" autocomplete="new-password" spellcheck="false">++ </label>++ <label class="check"><input type="checkbox" name="show_title" value="1"> Show the title to people who can open it</label>++ <p class="muted small">Lose the password, or your locker passphrase and recovery kit, and nobody can open this, including OpenVibe. There is no reset.</p>++ </div>++ </div>` : '';const body = `--<header class="page-head">++<header class="page-head pf-head"><h1>New paste</h1>-- <p class="muted">${user ? `Posting as <strong>${esc(user.display_name || user.username)}</strong> — your pastes collect under <a href="/my">My pastes</a>.` : `Posting anonymously. <a href="/auth/login?next=%2Fnew">Sign in</a> to keep your pastes together and delete them later.`}</p>++ ${who}</header>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<form class="paste-form" method="post" action="/new" data-new-paste data-lock-network="${esc(config.networkUrl)}"${user ? ` data-subject="${esc(user.subject_id || '')}"` : ''}>-- <div class="tabs" role="tablist">-- <button type="button" class="tab active" data-tab="text" role="tab" aria-selected="true"><i class="fa-solid fa-code" aria-hidden="true"></i> Text or code</button>-- <button type="button" class="tab" data-tab="image" role="tab" aria-selected="false" hidden><i class="fa-solid fa-image" aria-hidden="true"></i> Screenshot</button>++ <div class="tabs pf-modes" role="tablist" aria-label="What to paste">++ <button type="button" class="tab active" data-tab="text" role="tab" aria-selected="true">${kit.icon('code')} Text or code</button>++ <button type="button" class="tab" data-tab="image" role="tab" aria-selected="false" hidden>${kit.icon('image')} Screenshot</button></div>-- <label class="field"><span>Title</span><input type="text" name="title" maxlength="200" value="${esc(v.title)}" placeholder="Untitled"></label>-- <div class="field-row">-- <label class="field"><span>Language</span><select name="language">${langOpts}</select></label>-- <label class="field" data-vis-field><span>Visibility</span><select name="visibility">-- <option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public — listed and searchable</option>-- <option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Unlisted — link only</option>-- ${user ? `<option value="private"${v.visibility === 'private' ? ' selected' : ''}>Private — only you</option>` : ''}-- </select></label>-- ${user ? `<div class="field" data-lock-audience hidden><span>Who can open it</span><p class="lock-audience" data-lock-audience-text></p></div>` : ''}++ <label class="field pf-title"><span>Title</span><input type="text" name="title" maxlength="200" value="${esc(v.title)}" placeholder="Untitled" autocomplete="off"></label>++ <div class="pf-row">++ <label class="field pf-lang"><span>Language</span><select name="language" data-lang-select>${langOpts}</select></label>++ <div class="field pf-vis" data-vis-field>++ <span class="pf-label" id="pf-vis-label">Visibility</span>++ ${kit.segmented({ name: 'visibility', value: visibility, ariaLabel: 'Visibility', items: visItems })}++ <p class="pf-vis-mean" data-vis-meaning>${visMeanings.map(([id, text]) => `<span data-vis-for="${id}">${esc(text)}</span>`).join('')}</p>++ </div>++ ${user ? `<div class="field pf-audience" data-lock-audience hidden><span>Who can open it</span><p class="lock-audience" data-lock-audience-text></p></div>` : ''}</div>-- <div data-pane="text">-- <label class="field"><span>Content</span><textarea name="content" rows="18" spellcheck="false" placeholder="Paste it here…" required>${esc(v.content)}</textarea></label>++ <div data-pane="text" class="pf-pane">++ <div class="codebox" data-codebox>++ <div class="codebox-bar">++ <span class="codebox-drop">Drop a file, or <button type="button" class="pf-file" data-code-file>choose one</button></span>++ <span class="codebox-lang" data-lang-chip>${esc(languageLabel(v.language))}</span>++ </div>++ <div class="codebox-body">++ <div class="codebox-gutter" aria-hidden="true"><div data-gutter>${gutter}</div></div>++ <textarea name="content" rows="12" wrap="off" spellcheck="false" placeholder="Paste it here…" required aria-label="Content">${esc(v.content)}</textarea>++ </div>++ <input type="file" data-code-file-input accept="text/*,image/*,.md,.json,.js,.ts,.jsx,.tsx,.py,.sh,.yml,.yaml,.toml,.ini,.sql,.css,.html,.xml,.diff,.patch,.log,.txt" hidden>++ </div></div><div data-pane="image" hidden><label class="field"><span>Image (PNG, JPEG, WebP or GIF)</span><input type="file" name="screenshot" accept="image/png,image/jpeg,image/webp,image/gif" disabled></label><label class="field"><span>Description</span><textarea name="description" rows="3" maxlength="2000" placeholder="What is this a picture of?"></textarea></label></div>-- <div class="field-row options">-- <label class="check"><input type="checkbox" name="burn_after_read" value="1"${v.burn_after_read ? ' checked' : ''}> Burn after reading</label>-- <label class="check"><input type="checkbox" name="is_nsfw" value="1"${v.is_nsfw ? ' checked' : ''}> Mark as NSFW</label>-- </div>-- ${user ? `<section class="lock-it" data-lock-it hidden>-- <label class="check lock-it-switch"><input type="checkbox" name="lock_it" value="1" data-lock-switch>-- <i class="fa-solid fa-lock" aria-hidden="true"></i> <strong>Lock it</strong>-- <span class="muted small">OpenVibe can’t read what’s inside.</span></label>-- <div class="lock-it-body" data-lock-body hidden>-- <p class="muted small">Choose how people open it. Your browser seals the content before it leaves this page; the key never reaches OpenVibe.</p>-- <div class="lock-kinds" role="radiogroup" aria-label="How to lock it">-- <label class="lock-kind"><input type="radio" name="lock_kind" value="people" checked>-- <span><strong>For the people on the list</strong><small>Only you and the people you name can open it, each with their own keys. It switches the audience to Specific people.</small></span></label>-- <label class="lock-kind"><input type="radio" name="lock_kind" value="password">-- <span><strong>With a password</strong><small>Anyone you give the password to can open it. If the password is lost, nobody can open it, including OpenVibe.</small></span></label>-- <label class="lock-kind"><input type="radio" name="lock_kind" value="link">-- <span><strong>With the link</strong><small>The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.</small></span></label>-- </div>-- <div data-lock-for="people">-- <label class="field"><span>People</span>-- <input type="text" name="lock_people" placeholder="@maticus, @quinn" autocomplete="off" spellcheck="false">-- </label>-- <p class="muted small">Their OpenVibe names. Each needs a Locker: they make one the first time they open a locked item.</p>-- </div>-- <label class="field" data-lock-for="password" hidden><span>Password</span>-- <input type="password" name="lock_password" autocomplete="new-password" spellcheck="false">-- </label>-- <label class="check"><input type="checkbox" name="show_title" value="1"> Show the title to people who can open it</label>-- <p class="muted small">Lose the password, or your locker passphrase and recovery kit, and nobody can open this, including OpenVibe. There is no reset.</p>-- </div>-- </section>` : ''}-- <div class="form-actions">-- <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Create paste</button>-- <span class="muted small">By posting you agree to the <a href="${esc(config.liveUrl)}/tos">rules</a>. Say it, own it.</span>++ <section class="ovk-section pf-options" aria-labelledby="pf-options-h">++ <h2 id="pf-options-h">Options</h2>++ ${kit.switchRow({ name: 'burn_after_read', icon: 'flame', title: 'Burn after reading', line: 'Gone once it is opened', checked: !!v.burn_after_read })}++ ${kit.switchRow({ name: 'is_nsfw', icon: 'eye-off', title: 'Mark as NSFW', line: 'Blurred until it is opened', checked: !!v.is_nsfw })}++ ${lock}++ </section>++ <div class="pf-actions">++ <p class="form-status" data-status aria-live="polite"></p>++ <button class="btn btn-primary pf-create" type="submit">${kit.icon('send')} Create paste</button>++ <p class="pf-rules">By posting you agree to the <a href="${esc(config.liveUrl)}/tos">rules</a>. Say it, own it.</p></div>-- <p class="form-status" data-status aria-live="polite"></p></form>`;return renderPage({title: 'New paste',@@@ -674,6 +702,7 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}robots: 'noindex,follow',active: 'new',footerVariant: 'compact',++ styles: ['kit.css'], // the options card and the switch rows (openvibe-shared/kit)body,});}@@@ -18,8 +18,8 @@ const BUDGETS = {jsRawKB: 409, // 408.2 with openvibe-shared 4.77.0: the navbar loads the glyphs when a page first shows an icon (2026-11-01); 408: 408: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4; Font Awesome's CSS and font were external, uncounted, and heavier); 339.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 338.3 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 337.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 335.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 332.4 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 329.8 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); 322 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 314 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; 306.5 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 300.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 286.6 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 276.8 with shared 4.11.0's seasonal navbar; 267.5 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 251.3 (was 208.8 before the shell's web-runtime.js)jsBrotliKB: 100.5, // 100.5: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4); 83.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.3 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 81.7 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); 80.2 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 78.1 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; 74.4 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 71.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 69.1 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 65.5 with shared 4.11.0's seasonal navbar; 59.9 (was 48.9)cssFiles: 5, // 5 the short home: + kit.css (numbers, lists, chips, the fold) and room.css (cards and rails), the shared kit's look; 3-- cssRawKB: 142.2, // 142.2: measured 137.2 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09-- cssBrotliKB: 30.1, // 30.1: measured 28.9 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09++ cssRawKB: 147.5, // 147.5: measured 146.3 with openvibe-shared 4.169.0 (the kit's switch rows and segmented control) and the paste form, 2026-11-09; 144.2: measured 143.7 with openvibe-shared 4.163.0 and the new paste form's styles (39 lines for the code box, the segments and the sticky action bar, 2026-11-09); 142.2: measured 137.2 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09++ cssBrotliKB: 31.2, // 31.2: measured 30.6 with openvibe-shared 4.169.0, 2026-11-09; 30.1: measured 28.9 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09externalFiles: 2, // 1};@@@ -231,9 +231,30 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(anon.status, 200);has(anon.text, 'name="content"');assert.ok(!anon.text.includes('value="private"'));++ // The form as designed on 11-09: a code box with line numbers, one row of choices, the options as switch rows.++ has(anon.text, '<form class="paste-form"');++ has(anon.text, 'class="tabs pf-modes"');++ has(anon.text, 'data-codebox');++ has(anon.text, 'data-gutter');++ has(anon.text, 'data-lang-chip');++ has(anon.text, 'aria-label="Content"');++ has(anon.text, 'role="radiogroup" aria-label="Visibility"');++ has(anon.text, 'Only people with the link can open it.');++ has(anon.text, 'class="ovk-set-row ovk-set-switch"');++ has(anon.text, 'name="burn_after_read"');++ has(anon.text, 'name="is_nsfw"');++ has(anon.text, 'role="switch"');++ has(anon.text, 'class="btn btn-primary pf-create"');++ assert.ok(!anon.text.includes('name="lock_it"'), 'no Lock it without a sign-in');++ assert.ok(!anon.text.includes('class="pf-who"'), 'no identity chip anonymous');const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });has(signed.text, 'value="private"');++ has(signed.text, 'name="visibility" value="private"');++ has(signed.text, 'class="pf-who"');++ has(signed.text, 'Posting as');++ has(signed.text, 'name="lock_it"');++ has(signed.text, 'OpenVibe can’t read what’s inside');has(signed.text, 'Fork of Hello world in JavaScript');const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.169.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1236,9 +1236,9 @@
@@ -1236,9 +1236,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.168.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
- "integrity": "sha512-Ahp6kif2XWtGr1DTQfovmDyUuWMDcZN7ygRmkahIc3A1uY6u5QCQxNRD/PbyGUKdT++VW9jmnjMaHlF3hogpuQ==",
+ "version": "4.169.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.169.0",
+ "integrity": "sha512-qqx69Fy+sOjOf8WLVuxRJ5szwyPFhRgmU+pqGAPqWHYxMtcWSnRlxGtImCCKOxFJ9q5ERYpDumfuzxELKnL1RA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"multer": "^2.4.0",
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.168.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.169.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -128,7 +128,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -128,7 +128,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.hljs-strong { font-weight: 700; }
.hljs-strong { font-weight: 700; }
/* Forms */
/* Forms */
-.paste-form { display: flex; flex-direction: column; gap: 14px; max-width: 900px; }
+.paste-form { display: flex; flex-direction: column; gap: 14px; max-width: 860px; }
+.paste-form [hidden] { display: none !important; }
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; }
.field > span { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }
.field > span { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }
.field input[type="text"], .field select, .field textarea, .field input[type="file"] { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; font: inherit; width: 100%; }
.field input[type="text"], .field select, .field textarea, .field input[type="file"] { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 9px; padding: 9px 12px; font: inherit; width: 100%; }
@@ -145,6 +146,61 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -145,6 +146,61 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }
.alert { border-radius: 9px; padding: 10px 14px; border: 1px solid var(--border); background: var(--bg-card); }
.alert-error { border-color: var(--danger); color: var(--danger-text, var(--danger)); }
.alert-error { border-color: var(--danger); color: var(--danger-text, var(--danger)); }
+/* ── New paste (the owner, 11-09: minimalist, modern, app-like): a titled code box, one row of choices, the
+ options as switch rows, and a Create button pinned above the app bar on a phone. ── */
+.pf-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; margin-bottom: 6px; }
+.pf-head h1 { margin: 0; }
+.pf-who { display: inline-flex; align-items: center; gap: 9px; min-height: 44px; box-sizing: border-box; padding: 4px 14px 4px 4px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); color: var(--text-primary); text-decoration: none; }
+.pf-who:hover { border-color: var(--accent); text-decoration: none; }
+.pf-who .avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex: none; }
+.pf-who-text { display: grid; gap: 0; line-height: 1.2; }
+.pf-who-text small { color: var(--text-secondary); font-size: .7rem; font-weight: 600; }
+.pf-who-text strong { font-size: .88rem; }
+.pf-anon { margin: 0; }
+.pf-modes { align-self: flex-start; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); }
+.pf-modes .tab { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 6px 14px; border: 0; border-radius: 999px; }
+.pf-modes .tab .ovk-i { width: 15px; height: 15px; }
+.pf-modes .tab.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }
+.pf-title input { font-size: 1.05rem; font-weight: 650; padding: 11px 14px; }
+.pf-row { display: flex; align-items: flex-start; gap: 16px; }
+.pf-lang { flex: 0 1 260px; }
+.pf-vis { flex: 1 1 340px; min-width: 0; }
+.pf-vis .pf-label { font-size: .8rem; color: var(--text-secondary); font-weight: 600; }
+.pf-vis-mean { margin: 6px 0 0; min-height: 1.2em; color: var(--text-secondary); font-size: .8rem; }
+.pf-vis-mean > span { display: none; }
+.pf-vis:has(input[value="public"]:checked) [data-vis-for="public"],
+.pf-vis:has(input[value="unlisted"]:checked) [data-vis-for="unlisted"],
+.pf-vis:has(input[value="private"]:checked) [data-vis-for="private"] { display: block; }
+.pf-audience { flex: 1 1 340px; }
+.pf-pane { display: grid; gap: 10px; }
+.codebox { border: 1px solid var(--border); border-radius: 12px; background: var(--bg-input); overflow: hidden; transition: border-color .15s, box-shadow .15s; }
+.codebox:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .16); }
+.codebox.is-drop { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .22); }
+.codebox-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 38px; padding: 4px 10px; border-bottom: 1px solid var(--border); background: var(--bg-card); }
+.codebox-drop { color: var(--text-secondary); font-size: .76rem; }
+.pf-file { background: none; border: 0; padding: 0; color: var(--accent-light); font: inherit; text-decoration: underline; cursor: pointer; }
+.codebox-lang { flex: none; padding: 3px 9px; border: 1px solid var(--border); border-radius: 999px; color: var(--text-secondary); font-size: .7rem; font-weight: 600; line-height: 1.4; letter-spacing: .02em; }
+.codebox-body { display: grid; grid-template-columns: auto minmax(0, 1fr); }
+.codebox-gutter { overflow: hidden; padding: 14px 9px 14px 12px; border-right: 1px solid var(--border); background: var(--bg-card); color: var(--text-muted); font: 13px/1.55 var(--mono); text-align: right; user-select: none; }
+.codebox-gutter > div { white-space: pre; }
+.codebox textarea { display: block; width: 100%; margin: 0; padding: 14px 16px; border: 0; border-radius: 0; background: transparent; color: var(--text-primary); font: 13px/1.55 var(--mono); min-height: 0; resize: none; overflow: auto; tab-size: 4; }
+.codebox textarea:focus { outline: 0; box-shadow: none; }
+.pf-options { margin: 4px 0 0; }
+.pf-options[hidden], .pf-options [hidden] { display: none !important; }
+.pf-options .lock-it-body { margin: 0 14px 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-input); }
+.pf-actions { display: grid; gap: 8px; }
+.pf-create { justify-self: start; }
+.pf-actions .form-status:empty { min-height: 0; }
+.pf-rules { margin: 0; color: var(--text-secondary); font-size: .76rem; }
+.pf-rules a { color: var(--text-secondary); }
+@media (max-width: 700px) {
+ .pf-row { flex-direction: column; gap: 12px; }
+ .pf-lang, .pf-vis, .pf-audience { flex: 1 1 auto; width: 100%; }
+ .pf-create { width: 100%; justify-self: stretch; }
+ .pf-actions { position: sticky; bottom: calc(var(--ovnav-appbar-h, 0px) + 8px); z-index: 30; margin-top: 2px; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: var(--bg-primary); border-top: 1px solid var(--border); }
+ html.ov-kb-open .pf-actions { position: static; padding-bottom: 0; background: none; border-top: 0; }
+}
+
/* Spaces, threads, posts, Pulse */
/* Spaces, threads, posts, Pulse */
.space-list, .thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.space-list, .thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.space-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
.space-item { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
@@ -458,17 +514,10 @@ button.rx:hover { border-color: var(--accent); }
@@ -458,17 +514,10 @@ button.rx:hover { border-color: var(--accent); }
.thread-head .ovk-labels { margin: 2px 0 6px; }
.thread-head .ovk-labels { margin: 2px 0 6px; }
/* ── Locked pastes (Locker, design 2026-11-06): the create form's Lock it, the locked card, the opened text ── */
/* ── Locked pastes (Locker, design 2026-11-06): the create form's Lock it, the locked card, the opened text ── */
-.lock-it { margin: 14px 0; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-input); display: grid; gap: 10px; }
-.lock-it[hidden], .lock-it [hidden] { display: none !important; } /* the section and its choices are JS-only */
.paste-form .field[hidden] { display: none !important; }
.paste-form .field[hidden] { display: none !important; }
.lock-audience { margin: 0; padding: 9px 12px; border: 1px dashed var(--border); border-radius: 9px; color: var(--text-primary); background: none; font-size: .95rem; }
.lock-audience { margin: 0; padding: 9px 12px; border: 1px dashed var(--border); border-radius: 9px; color: var(--text-primary); background: none; font-size: .95rem; }
.lock-audience[hidden] { display: none !important; }
.lock-audience[hidden] { display: none !important; }
-.lock-it-switch { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 4px 8px; font-size: .95rem; }
-.lock-it-switch .muted { grid-column: 1 / -1; }
-.lock-it-switch strong { white-space: nowrap; }
-.lock-it-switch strong { color: var(--text-primary); }
-.lock-it-switch i { color: var(--accent-light); }
-.lock-it-body { display: grid; gap: 12px; padding-top: 4px; border-top: 1px solid var(--border); }
+.lock-it-body { display: grid; gap: 12px; }
.lock-it-body p { margin: 0; }
.lock-it-body p { margin: 0; }
.lock-kinds { display: grid; gap: 8px; }
.lock-kinds { display: grid; gap: 8px; }
.lock-kind { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
.lock-kind { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
@@ -111,6 +111,7 @@
@@ -111,6 +111,7 @@
tabs.forEach(function (t) { t.hidden = false; });
tabs.forEach(function (t) { t.hidden = false; });
var lockSection = $('[data-lock-it]', form);
var lockSection = $('[data-lock-it]', form);
var visField = $('[data-vis-field]', form);
var visField = $('[data-vis-field]', form);
+ var visibilityInputs = $$('input[name="visibility"]', form);
var audienceLine = $('[data-lock-audience]', form);
var audienceLine = $('[data-lock-audience]', form);
var audienceText = $('[data-lock-audience-text]', form);
var audienceText = $('[data-lock-audience-text]', form);
var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
@@ -137,9 +138,8 @@
@@ -137,9 +138,8 @@
if (lockBody) lockBody.hidden = !on;
if (lockBody) lockBody.hidden = !on;
// The audience is the lock's (Specific people, or the link), so the column is replaced by one read-only line
// The audience is the lock's (Specific people, or the link), so the column is replaced by one read-only line
// naming it, and comes back when Lock it is off.
// naming it, and comes back when Lock it is off.
- var vis = form.elements.visibility;
var kind = (form.querySelector('input[name="lock_kind"]:checked') || {}).value || 'people';
var kind = (form.querySelector('input[name="lock_kind"]:checked') || {}).value || 'people';
- if (vis) vis.disabled = on;
+ visibilityInputs.forEach(function (i) { i.disabled = on; });
if (visField) visField.hidden = on;
if (visField) visField.hidden = on;
if (audienceLine) audienceLine.hidden = !on;
if (audienceLine) audienceLine.hidden = !on;
if (audienceText) audienceText.textContent = LOCK_AUDIENCE[kind] || kind;
if (audienceText) audienceText.textContent = LOCK_AUDIENCE[kind] || kind;
@@ -150,7 +150,74 @@
@@ -150,7 +150,74 @@
$$('input[name="lock_kind"]', form).forEach(function (r) { r.addEventListener('change', syncLock); });
$$('input[name="lock_kind"]', form).forEach(function (r) { r.addEventListener('change', syncLock); });
syncLock();
syncLock();
}
}
- tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }, { signal }); });
+ tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); if (t.getAttribute('data-tab') === 'text') growBox(); }, { signal }); });
+
+ // ── The code box: line numbers, a box that grows from 12 lines, the language chip, and a dropped file ──
+ var codebox = $('[data-codebox]', form);
+ var contentBox = form.elements.content;
+ var gutterBox = $('[data-gutter]', form);
+ var langSelect = $('[data-lang-select]', form);
+ var langChip = $('[data-lang-chip]', form);
+ var fileInput = $('[data-code-file-input]', form);
+ var minBox = 0;
+ function paintGutter() {
+ if (!contentBox || !gutterBox) return;
+ var n = Math.max(12, contentBox.value.split('\n').length);
+ var out = '';
+ for (var i = 1; i <= n; i++) out += i + (i === n ? '' : '\n');
+ gutterBox.textContent = out;
+ if (gutterBox.parentNode) gutterBox.parentNode.scrollTop = contentBox.scrollTop;
+ }
+ function growBox() {
+ if (!contentBox) return;
+ var cs = window.getComputedStyle(contentBox);
+ if (!minBox) minBox = contentBox.offsetHeight || (12 * parseFloat(cs.lineHeight) + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom));
+ contentBox.style.height = 'auto';
+ var max = Math.round(window.innerHeight * 0.68);
+ contentBox.style.height = Math.max(minBox, Math.min(contentBox.scrollHeight, max)) + 'px';
+ paintGutter();
+ }
+ function readText(f) {
+ var r = new FileReader();
+ r.onload = function () {
+ contentBox.value = String(r.result || '');
+ if (!form.elements.title.value.trim()) form.elements.title.value = f.name.replace(/\.[^.]+$/, '');
+ setMode('text');
+ growBox();
+ contentBox.focus();
+ };
+ r.readAsText(f);
+ }
+ function fillFile(f) {
+ if (!f) return;
+ if (/^image\//.test(f.type)) { takeFile(f); return; }
+ if (/^text\//.test(f.type) || /\.(txt|log|md|markdown|json|jsonc|js|mjs|cjs|ts|tsx|jsx|py|sh|bash|zsh|yml|yaml|toml|ini|sql|css|scss|html?|xml|diff|patch|go|rs|php|rb|java|kt|swift|c|cpp|cs|lua|pl|r|conf|nginx)$/i.test(f.name)) readText(f);
+ }
+ if (contentBox && gutterBox) {
+ growBox();
+ contentBox.addEventListener('input', growBox, { signal });
+ contentBox.addEventListener('scroll', function () { if (gutterBox.parentNode) gutterBox.parentNode.scrollTop = contentBox.scrollTop; }, { signal });
+ window.addEventListener('resize', growBox, { signal });
+ }
+ if (langSelect && langChip) {
+ var sayLang = function () { var o = langSelect.options[langSelect.selectedIndex]; langChip.textContent = o ? o.text : 'Auto-detect'; };
+ langSelect.addEventListener('change', sayLang, { signal }); sayLang();
+ }
+ if (fileInput) {
+ fileInput.addEventListener('change', function () { fillFile(fileInput.files && fileInput.files[0]); fileInput.value = ''; }, { signal });
+ var pickFile = $('[data-code-file]', form);
+ if (pickFile) pickFile.addEventListener('click', function () { fileInput.click(); }, { signal });
+ }
+ if (codebox) {
+ ['dragenter', 'dragover'].forEach(function (ev) { codebox.addEventListener(ev, function (e) { e.preventDefault(); codebox.classList.add('is-drop'); }, { signal }); });
+ codebox.addEventListener('dragleave', function () { codebox.classList.remove('is-drop'); }, { signal });
+ codebox.addEventListener('drop', function (e) {
+ codebox.classList.remove('is-drop');
+ if (!e.dataTransfer || !e.dataTransfer.files[0]) return;
+ e.preventDefault(); e.stopPropagation();
+ fillFile(e.dataTransfer.files[0]);
+ }, { signal });
+ }
setMode('text');
setMode('text');
// Dropping or pasting an image anywhere on the form switches to the screenshot mode.
// Dropping or pasting an image anywhere on the form switches to the screenshot mode.
function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }
function takeFile(f) { if (!f || !/^image\//.test(f.type)) return; var dt = new DataTransfer(); dt.items.add(f); setMode('image'); form.elements.screenshot.files = dt.files; if (!form.elements.title.value) form.elements.title.value = f.name.replace(/\.[^.]+$/, ''); }
@@ -601,71 +601,99 @@ window.addEventListener('DOMContentLoaded', function () {
@@ -601,71 +601,99 @@ window.addEventListener('DOMContentLoaded', function () {
function newPage({ user, values = {}, error = null, fork = null }) {
function newPage({ user, values = {}, error = null, fork = null }) {
const v = { title: '', language: 'auto', content: '', visibility: 'public', ...values };
const v = { title: '', language: 'auto', content: '', visibility: 'public', ...values };
if (fork) { v.title = v.title || `Fork of ${fork.title || 'paste'}`; v.content = v.content || fork.content || ''; v.language = fork.language || v.language; }
if (fork) { v.title = v.title || `Fork of ${fork.title || 'paste'}`; v.content = v.content || fork.content || ''; v.language = fork.language || v.language; }
+ // Signed out, Private and Lock it do not exist; a failed post can still come back asking for one.
+ const visibility = !user && v.visibility === 'private' ? 'public' : v.visibility;
const langOpts = LANGUAGES.map(([id, label]) => `<option value="${id}"${id === v.language ? ' selected' : ''}>${esc(label)}</option>`).join('');
const langOpts = LANGUAGES.map(([id, label]) => `<option value="${id}"${id === v.language ? ' selected' : ''}>${esc(label)}</option>`).join('');
+ const visItems = [
+ { value: 'public', label: 'Public', icon: 'globe' },
+ { value: 'unlisted', label: 'Unlisted', icon: 'link' },
+ ...(user ? [{ value: 'private', label: 'Private', icon: 'lock' }] : []),
+ ];
+ const visMeanings = [
+ ['public', 'Listed on Pastes and in search — anyone can find it.'],
+ ['unlisted', 'Only people with the link can open it.'],
+ ...(user ? [['private', 'Only you can open it.']] : []),
+ ];
+ const lines = Math.max(12, String(v.content || '').split('\n').length);
+ const gutter = Array.from({ length: lines }, (_, i) => i + 1).join('\n');
+ const whoPic = user ? avatarUrl(user, 72) : null;
+ const who = user
+ ? `<a class="pf-who" href="/my" title="My pastes">${whoPic ? `<img class="avatar" src="${esc(whoPic)}" alt="" width="28" height="28" loading="lazy">` : ''}<span class="pf-who-text"><small>Posting as</small><strong>${esc(user.display_name || user.username)}</strong></span></a>`
+ : `<p class="pf-anon muted small">Posting anonymously. <a href="/auth/login?next=%2Fnew">Sign in</a> to keep your pastes together.</p>`;
+ const lock = user ? `<div data-lock-it hidden>
+ ${kit.switchRow({ name: 'lock_it', icon: 'lock', title: 'Lock it', line: 'OpenVibe can’t read what’s inside' })}
+ <div class="lock-it-body" data-lock-body hidden>
+ <p class="muted small">Choose how people open it. Your browser seals the content before it leaves this page; the key never reaches OpenVibe.</p>
+ <div class="lock-kinds" role="radiogroup" aria-label="How to lock it">
+ <label class="lock-kind"><input type="radio" name="lock_kind" value="people" checked>
+ <span><strong>For the people on the list</strong><small>Only you and the people you name can open it, each with their own keys. It switches the audience to Specific people.</small></span></label>
+ <label class="lock-kind"><input type="radio" name="lock_kind" value="password">
+ <span><strong>With a password</strong><small>Anyone you give the password to can open it. If the password is lost, nobody can open it, including OpenVibe.</small></span></label>
+ <label class="lock-kind"><input type="radio" name="lock_kind" value="link">
+ <span><strong>With the link</strong><small>The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.</small></span></label>
+ </div>
+ <div data-lock-for="people">
+ <label class="field"><span>People</span>
+ <input type="text" name="lock_people" placeholder="@maticus, @quinn" autocomplete="off" spellcheck="false">
+ </label>
+ <p class="muted small">Their OpenVibe names. Each needs a Locker: they make one the first time they open a locked item.</p>
+ </div>
+ <label class="field" data-lock-for="password" hidden><span>Password</span>
+ <input type="password" name="lock_password" autocomplete="new-password" spellcheck="false">
+ </label>
+ <label class="check"><input type="checkbox" name="show_title" value="1"> Show the title to people who can open it</label>
+ <p class="muted small">Lose the password, or your locker passphrase and recovery kit, and nobody can open this, including OpenVibe. There is no reset.</p>
+ </div>
+ </div>` : '';
const body = `
const body = `
-<header class="page-head">
+<header class="page-head pf-head">
<h1>New paste</h1>
<h1>New paste</h1>
- <p class="muted">${user ? `Posting as <strong>${esc(user.display_name || user.username)}</strong> — your pastes collect under <a href="/my">My pastes</a>.` : `Posting anonymously. <a href="/auth/login?next=%2Fnew">Sign in</a> to keep your pastes together and delete them later.`}</p>
+ ${who}
</header>
</header>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<form class="paste-form" method="post" action="/new" data-new-paste data-lock-network="${esc(config.networkUrl)}"${user ? ` data-subject="${esc(user.subject_id || '')}"` : ''}>
<form class="paste-form" method="post" action="/new" data-new-paste data-lock-network="${esc(config.networkUrl)}"${user ? ` data-subject="${esc(user.subject_id || '')}"` : ''}>
- <div class="tabs" role="tablist">
- <button type="button" class="tab active" data-tab="text" role="tab" aria-selected="true"><i class="fa-solid fa-code" aria-hidden="true"></i> Text or code</button>
- <button type="button" class="tab" data-tab="image" role="tab" aria-selected="false" hidden><i class="fa-solid fa-image" aria-hidden="true"></i> Screenshot</button>
+ <div class="tabs pf-modes" role="tablist" aria-label="What to paste">
+ <button type="button" class="tab active" data-tab="text" role="tab" aria-selected="true">${kit.icon('code')} Text or code</button>
+ <button type="button" class="tab" data-tab="image" role="tab" aria-selected="false" hidden>${kit.icon('image')} Screenshot</button>
</div>
</div>
- <label class="field"><span>Title</span><input type="text" name="title" maxlength="200" value="${esc(v.title)}" placeholder="Untitled"></label>
- <div class="field-row">
- <label class="field"><span>Language</span><select name="language">${langOpts}</select></label>
- <label class="field" data-vis-field><span>Visibility</span><select name="visibility">
- <option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public — listed and searchable</option>
- <option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Unlisted — link only</option>
- ${user ? `<option value="private"${v.visibility === 'private' ? ' selected' : ''}>Private — only you</option>` : ''}
- </select></label>
- ${user ? `<div class="field" data-lock-audience hidden><span>Who can open it</span><p class="lock-audience" data-lock-audience-text></p></div>` : ''}
+ <label class="field pf-title"><span>Title</span><input type="text" name="title" maxlength="200" value="${esc(v.title)}" placeholder="Untitled" autocomplete="off"></label>
+ <div class="pf-row">
+ <label class="field pf-lang"><span>Language</span><select name="language" data-lang-select>${langOpts}</select></label>
+ <div class="field pf-vis" data-vis-field>
+ <span class="pf-label" id="pf-vis-label">Visibility</span>
+ ${kit.segmented({ name: 'visibility', value: visibility, ariaLabel: 'Visibility', items: visItems })}
+ <p class="pf-vis-mean" data-vis-meaning>${visMeanings.map(([id, text]) => `<span data-vis-for="${id}">${esc(text)}</span>`).join('')}</p>
+ </div>
+ ${user ? `<div class="field pf-audience" data-lock-audience hidden><span>Who can open it</span><p class="lock-audience" data-lock-audience-text></p></div>` : ''}
</div>
</div>
- <div data-pane="text">
- <label class="field"><span>Content</span><textarea name="content" rows="18" spellcheck="false" placeholder="Paste it here…" required>${esc(v.content)}</textarea></label>
+ <div data-pane="text" class="pf-pane">
+ <div class="codebox" data-codebox>
+ <div class="codebox-bar">
+ <span class="codebox-drop">Drop a file, or <button type="button" class="pf-file" data-code-file>choose one</button></span>
+ <span class="codebox-lang" data-lang-chip>${esc(languageLabel(v.language))}</span>
+ </div>
+ <div class="codebox-body">
+ <div class="codebox-gutter" aria-hidden="true"><div data-gutter>${gutter}</div></div>
+ <textarea name="content" rows="12" wrap="off" spellcheck="false" placeholder="Paste it here…" required aria-label="Content">${esc(v.content)}</textarea>
+ </div>
+ <input type="file" data-code-file-input accept="text/*,image/*,.md,.json,.js,.ts,.jsx,.tsx,.py,.sh,.yml,.yaml,.toml,.ini,.sql,.css,.html,.xml,.diff,.patch,.log,.txt" hidden>
+ </div>
</div>
</div>
<div data-pane="image" hidden>
<div data-pane="image" hidden>
<label class="field"><span>Image (PNG, JPEG, WebP or GIF)</span><input type="file" name="screenshot" accept="image/png,image/jpeg,image/webp,image/gif" disabled></label>
<label class="field"><span>Image (PNG, JPEG, WebP or GIF)</span><input type="file" name="screenshot" accept="image/png,image/jpeg,image/webp,image/gif" disabled></label>
<label class="field"><span>Description</span><textarea name="description" rows="3" maxlength="2000" placeholder="What is this a picture of?"></textarea></label>
<label class="field"><span>Description</span><textarea name="description" rows="3" maxlength="2000" placeholder="What is this a picture of?"></textarea></label>
</div>
</div>
- <div class="field-row options">
- <label class="check"><input type="checkbox" name="burn_after_read" value="1"${v.burn_after_read ? ' checked' : ''}> Burn after reading</label>
- <label class="check"><input type="checkbox" name="is_nsfw" value="1"${v.is_nsfw ? ' checked' : ''}> Mark as NSFW</label>
- </div>
- ${user ? `<section class="lock-it" data-lock-it hidden>
- <label class="check lock-it-switch"><input type="checkbox" name="lock_it" value="1" data-lock-switch>
- <i class="fa-solid fa-lock" aria-hidden="true"></i> <strong>Lock it</strong>
- <span class="muted small">OpenVibe can’t read what’s inside.</span></label>
- <div class="lock-it-body" data-lock-body hidden>
- <p class="muted small">Choose how people open it. Your browser seals the content before it leaves this page; the key never reaches OpenVibe.</p>
- <div class="lock-kinds" role="radiogroup" aria-label="How to lock it">
- <label class="lock-kind"><input type="radio" name="lock_kind" value="people" checked>
- <span><strong>For the people on the list</strong><small>Only you and the people you name can open it, each with their own keys. It switches the audience to Specific people.</small></span></label>
- <label class="lock-kind"><input type="radio" name="lock_kind" value="password">
- <span><strong>With a password</strong><small>Anyone you give the password to can open it. If the password is lost, nobody can open it, including OpenVibe.</small></span></label>
- <label class="lock-kind"><input type="radio" name="lock_kind" value="link">
- <span><strong>With the link</strong><small>The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.</small></span></label>
- </div>
- <div data-lock-for="people">
- <label class="field"><span>People</span>
- <input type="text" name="lock_people" placeholder="@maticus, @quinn" autocomplete="off" spellcheck="false">
- </label>
- <p class="muted small">Their OpenVibe names. Each needs a Locker: they make one the first time they open a locked item.</p>
- </div>
- <label class="field" data-lock-for="password" hidden><span>Password</span>
- <input type="password" name="lock_password" autocomplete="new-password" spellcheck="false">
- </label>
- <label class="check"><input type="checkbox" name="show_title" value="1"> Show the title to people who can open it</label>
- <p class="muted small">Lose the password, or your locker passphrase and recovery kit, and nobody can open this, including OpenVibe. There is no reset.</p>
- </div>
- </section>` : ''}
- <div class="form-actions">
- <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Create paste</button>
- <span class="muted small">By posting you agree to the <a href="${esc(config.liveUrl)}/tos">rules</a>. Say it, own it.</span>
+ <section class="ovk-section pf-options" aria-labelledby="pf-options-h">
+ <h2 id="pf-options-h">Options</h2>
+ ${kit.switchRow({ name: 'burn_after_read', icon: 'flame', title: 'Burn after reading', line: 'Gone once it is opened', checked: !!v.burn_after_read })}
+ ${kit.switchRow({ name: 'is_nsfw', icon: 'eye-off', title: 'Mark as NSFW', line: 'Blurred until it is opened', checked: !!v.is_nsfw })}
+ ${lock}
+ </section>
+ <div class="pf-actions">
+ <p class="form-status" data-status aria-live="polite"></p>
+ <button class="btn btn-primary pf-create" type="submit">${kit.icon('send')} Create paste</button>
+ <p class="pf-rules">By posting you agree to the <a href="${esc(config.liveUrl)}/tos">rules</a>. Say it, own it.</p>
</div>
</div>
- <p class="form-status" data-status aria-live="polite"></p>
</form>`;
</form>`;
return renderPage({
return renderPage({
title: 'New paste',
title: 'New paste',
@@ -674,6 +702,7 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
@@ -674,6 +702,7 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
robots: 'noindex,follow',
robots: 'noindex,follow',
active: 'new',
active: 'new',
footerVariant: 'compact',
footerVariant: 'compact',
+ styles: ['kit.css'], // the options card and the switch rows (openvibe-shared/kit)
body,
body,
});
});
}
}
@@ -18,8 +18,8 @@ const BUDGETS = {
@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 409, // 408.2 with openvibe-shared 4.77.0: the navbar loads the glyphs when a page first shows an icon (2026-11-01); 408: 408: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4; Font Awesome's CSS and font were external, uncounted, and heavier); 339.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 338.3 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 337.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 335.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 332.4 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 329.8 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); 322 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 314 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; 306.5 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 300.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 286.6 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 276.8 with shared 4.11.0's seasonal navbar; 267.5 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 251.3 (was 208.8 before the shell's web-runtime.js)
jsRawKB: 409, // 408.2 with openvibe-shared 4.77.0: the navbar loads the glyphs when a page first shows an icon (2026-11-01); 408: 408: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4; Font Awesome's CSS and font were external, uncounted, and heavier); 339.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 338.3 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 337.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 335.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 332.4 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 329.8 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); 322 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 314 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; 306.5 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 300.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 286.6 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 276.8 with shared 4.11.0's seasonal navbar; 267.5 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 251.3 (was 208.8 before the shell's web-runtime.js)
jsBrotliKB: 100.5, // 100.5: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4); 83.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.3 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 81.7 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); 80.2 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 78.1 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; 74.4 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 71.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 69.1 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 65.5 with shared 4.11.0's seasonal navbar; 59.9 (was 48.9)
jsBrotliKB: 100.5, // 100.5: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4); 83.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.3 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 81.7 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); 80.2 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 78.1 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; 74.4 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 71.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 69.1 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 65.5 with shared 4.11.0's seasonal navbar; 59.9 (was 48.9)
cssFiles: 5, // 5 the short home: + kit.css (numbers, lists, chips, the fold) and room.css (cards and rails), the shared kit's look; 3
cssFiles: 5, // 5 the short home: + kit.css (numbers, lists, chips, the fold) and room.css (cards and rails), the shared kit's look; 3
- cssRawKB: 142.2, // 142.2: measured 137.2 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
- cssBrotliKB: 30.1, // 30.1: measured 28.9 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
+ cssRawKB: 147.5, // 147.5: measured 146.3 with openvibe-shared 4.169.0 (the kit's switch rows and segmented control) and the paste form, 2026-11-09; 144.2: measured 143.7 with openvibe-shared 4.163.0 and the new paste form's styles (39 lines for the code box, the segments and the sticky action bar, 2026-11-09); 142.2: measured 137.2 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
+ cssBrotliKB: 31.2, // 31.2: measured 30.6 with openvibe-shared 4.169.0, 2026-11-09; 30.1: measured 28.9 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
externalFiles: 2, // 1
externalFiles: 2, // 1
};
};
@@ -231,9 +231,30 @@ const { boot, check, done } = require('./helpers/app');
@@ -231,9 +231,30 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(anon.status, 200);
assert.strictEqual(anon.status, 200);
has(anon.text, 'name="content"');
has(anon.text, 'name="content"');
assert.ok(!anon.text.includes('value="private"'));
assert.ok(!anon.text.includes('value="private"'));
+ // The form as designed on 11-09: a code box with line numbers, one row of choices, the options as switch rows.
+ has(anon.text, '<form class="paste-form"');
+ has(anon.text, 'class="tabs pf-modes"');
+ has(anon.text, 'data-codebox');
+ has(anon.text, 'data-gutter');
+ has(anon.text, 'data-lang-chip');
+ has(anon.text, 'aria-label="Content"');
+ has(anon.text, 'role="radiogroup" aria-label="Visibility"');
+ has(anon.text, 'Only people with the link can open it.');
+ has(anon.text, 'class="ovk-set-row ovk-set-switch"');
+ has(anon.text, 'name="burn_after_read"');
+ has(anon.text, 'name="is_nsfw"');
+ has(anon.text, 'role="switch"');
+ has(anon.text, 'class="btn btn-primary pf-create"');
+ assert.ok(!anon.text.includes('name="lock_it"'), 'no Lock it without a sign-in');
+ assert.ok(!anon.text.includes('class="pf-who"'), 'no identity chip anonymous');
const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });
const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });
has(signed.text, 'value="private"');
has(signed.text, 'value="private"');
+ has(signed.text, 'name="visibility" value="private"');
+ has(signed.text, 'class="pf-who"');
+ has(signed.text, 'Posting as');
+ has(signed.text, 'name="lock_it"');
+ has(signed.text, 'OpenVibe can’t read what’s inside');
has(signed.text, 'Fork of Hello world in JavaScript');
has(signed.text, 'Fork of Hello world in JavaScript');
const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();
const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();
const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });