ouroboros/web/modules/project_answer.js
Ouroboros c8869d41af One way to point at a Project: the reference door
"Take me to the Project this belongs to" was drawn six ways in the Main
transcript, in three looks: a card bar reading `in project ↗` or `running in
background ↗`, a pill with the Project's name, and a grey button reading
`Open Project ↗` — or `Open Project` with no arrow and a `title` the factory
silently dropped. Each call truthfully reused a shared primitive, which is why
no review, guard or document saw it: the callers still chose the label, the
glyph and the class.

`web/modules/project_reference.js` is now the only module that builds that
control and the only one that raises `ouro:open-project`. Callers pick a
layout (`inline`, `bar`, `footer`) and, for the converted card, the one closed
state; they pass no label and no class name. The reference names the Project
(a row that was never named reads `Project`, never its id), says the same
thing to a screen reader as to the eye, and carries a late-arriving name into
its text, its accessible name and its press alike.

The bound footer, the converted card, the question mirror, the completion
mirror, the System lifecycle rows and the routed owner message all use it.
`renderProjectChip` and `createSystemMessageAction` are gone with their CSS.
The durable row text, the event payload and the listener are unchanged.
2026-09-21 01:40:41 +03:00

55 lines
2.4 KiB
JavaScript

/**
* Project lifecycle rows in Main (docs/DESIGN.md "Project completion mirror").
*
* A Project root that ended with Ouroboros's own final answer reaches Main as an
* ordinary Ouroboros message, folded when it is long. Every other lifecycle row (a
* start, an ending with no answer, a row written before the answer rode the row)
* keeps its System text. Either way the row ends with the same Project reference:
* the voice of a row never chooses how the UI points at its Project.
*/
import { createSystemMessageActions } from './ui_helpers.js';
import { projectReference } from './project_reference.js';
// The fade says "there is more": it appears only when the fold really hides text. The clamp
// itself is unconditional CSS, so toggling the class never moves layout. A row mounted while
// Chat is not the visible page has no layout box (clientHeight 0): it is left alone and
// measured when Chat is shown; a resize re-measures, because the width decides the height.
function markFold(bubble) {
const message = bubble.querySelector('.message');
if (!message || !message.clientHeight) return;
bubble.classList.toggle('is-folded', message.scrollHeight > message.clientHeight + 1);
}
// One pair of window listeners for the app's lifetime, however many rows exist: nothing is
// held per row, so a released row needs no disposer here.
let watching = false;
let queued = false;
function afterLayout(work) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(work);
}
function refreshFoldsSoon() {
if (queued) return;
queued = true;
afterLayout(() => {
queued = false;
document.querySelectorAll('.chat-bubble.project-answer').forEach(markFold);
});
}
function watchFolds() {
if (watching || typeof window === 'undefined') return;
watching = true;
window.addEventListener('ouro:page-shown', refreshFoldsSoon);
window.addEventListener('resize', refreshFoldsSoon);
}
export function decorateProjectRow(bubble, { role = 'system', projectId = '', projectName = '' } = {}) {
if (!bubble || !projectId) return null;
if (role === 'assistant') {
bubble.classList.add('project-answer');
watchFolds();
afterLayout(() => markFold(bubble));
}
const actions = createSystemMessageActions(projectReference({ id: projectId, name: projectName }));
bubble.querySelector('.message')?.after(actions);
return actions;
}