ouroboros/web/modules/project_reference.js
Ouroboros 9d87d3dbcc Review round 2: an owner's lowercase Project name is a name
The door read `name === id` as "never named". The New Project dialog derives
the id from the name, so `blog`, `my-app` or a repo folder name gave the same
string for both, and the reference said `Project` where the base showed the
name. Only the minted shape `proj_<hex>` is a non-name now; the test pins both
directions.

Also: the question flag on the reference is `data-opens-question`, so an
unqualified `[data-quiz-id]` selector still matches the quiz card alone; the
single-raiser scan reads code, not comments, and says what it cannot see; the
inventory scans the first-party shell scripts too (`theme.js` raises
`ouro:theme-changed`); DESIGN no longer claims the reference names the Project
on the routed message, nor that the inventory lists every control.
2026-09-21 02:11:26 +03:00

71 lines
3.5 KiB
JavaScript

/**
* The one way the UI points at a Project (docs/DESIGN.md "References and actions").
*
* Every place that says "this belongs to that Project — take me there" gets its
* control here, and only this module raises `ouro:open-project`. Callers choose a
* LAYOUT and, where the surface needs it, a STATE; they never write a label, a
* glyph or a class name, because that freedom is how one intent came to be drawn
* several ways while each call still "reused the shared chip".
*
* inline the pill under a message or a System row, and in a quiz card's head
* bar the whole converted task card
* footer the strip that closes a task card bound to a Project
*/
import { PAGE_ICONS } from './page_icons.js';
const LAYOUT_CLASS = { inline: 'chat-quiz-project', bar: '', footer: 'chat-live-bound-pointer' };
// The arrow alone says "opens"; words appear only when the surface is itself the news.
const STATE_WORDS = { '': '', background: 'running in background' };
// What a press opens, per mounted reference: a name that arrives later reaches the payload too.
const targets = new WeakMap();
// A row that was never named carries its minted id as the name. Only that shape is a non-name:
// an owner's own `blog` has the id `blog` too, and it IS the name.
const MINTED_ID = /^proj_[0-9a-f]+$/;
function displayName(project) {
const name = String(project?.name || '').trim();
return name && !(name === String(project?.id || '') && MINTED_ID.test(name)) ? name : 'Project';
}
/** One name for the visible text, the tooltip and the accessible name, so they cannot disagree. */
export function nameProjectReference(node, project) {
const name = displayName(project);
const target = targets.get(node);
if (target && project?.name) target.name = project.name;
const label = node.querySelector('.chat-live-project-name');
if (label && label.textContent !== name) label.textContent = name;
const words = STATE_WORDS[node.dataset.state || ''] || '';
const base = node.dataset.opensQuestion ? `Open this question in ${name}` : `Open project ${name}`;
const spoken = words ? `${base}, ${words}` : base;
node.title = spoken;
node.setAttribute('aria-label', spoken);
return node;
}
export function projectReference(project, { layout = 'inline', state = '', taskId = '', quizId = '' } = {}) {
const target = { ...(project || {}) };
const btn = document.createElement('button');
btn.type = 'button';
btn.className = ['chat-live-project-card-btn', LAYOUT_CLASS[layout] || ''].filter(Boolean).join(' ');
btn.dataset.intent = 'open-project';
if (STATE_WORDS[state]) btn.dataset.state = state;
if (quizId) btn.dataset.opensQuestion = '1';
const icon = document.createElement('span');
icon.className = 'chat-live-project-icon';
icon.setAttribute('aria-hidden', 'true');
icon.innerHTML = PAGE_ICONS.projects;
const nameEl = document.createElement('span');
nameEl.className = 'chat-live-project-name';
const statusEl = document.createElement('span');
statusEl.className = 'chat-live-project-status';
statusEl.setAttribute('aria-hidden', 'true');
statusEl.textContent = [STATE_WORDS[state] || '', '↗'].filter(Boolean).join(' ');
btn.append(icon, nameEl, statusEl);
targets.set(btn, target);
nameProjectReference(btn, target);
btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('ouro:open-project', {
detail: { project: { ...target }, task_id: String(taskId || ''), quiz_id: String(quizId || '') },
})));
return btn;
}