The Prompt library shelf renders the concept's article.prompt-card:
.card-art (up to two previews), .card-title with a title button and the
category chip, the positive prompt block, .card-meta ("− N negative
variants", "N runs", "N images"), .tags, and .card-footer with the rating
and last-use line and the positive send plus Explore history buttons.
D23: "N runs" is the parent's run_count; the footer reads Used today /
yesterday / N days ago / <Mon D> in the viewer's local calendar days, or
"Not used yet"; stars carry a visually hidden "Rated N out of 5" and an
unrated parent shows no stars. The positive is clamped to two lines while
copy and send use the full text. Long titles wrap inside the title row
and the category chip never breaks mid-word. Card actions are named after
the prompt ("Copy positive prompt: <title>") through a new title option on
the shared prompt blocks. The production .library-card CSS is removed and
the row estimates follow the clamped card height.
The shelf error now reads "Prompt library could not be loaded. Retry."
(Refresh is gone). TESTING.md records D23, the production-only shelf copy
(D40) and the concept search placeholder kept on the Library (D41).
67 lines
3.4 KiB
JavaScript
67 lines
3.4 KiB
JavaScript
export function layoutLibraryRows(heights, columns, {gap = 18, estimate = 468} = {}) {
|
||
if (!Number.isSafeInteger(columns) || columns < 1) throw new TypeError('Invalid Library columns');
|
||
if (!Number.isFinite(gap) || gap < 0 || !Number.isFinite(estimate) || estimate <= 0) throw new TypeError('Invalid Library geometry');
|
||
const rows = [];
|
||
let top = 0;
|
||
for (let first = 0; first < heights.length; first += columns) {
|
||
const last = Math.min(first + columns, heights.length) - 1;
|
||
let height = 0;
|
||
for (let index = first; index <= last; index++) {
|
||
const measured = heights[index];
|
||
height = Math.max(height, Number.isFinite(measured) && measured > 0 ? measured : estimate);
|
||
}
|
||
rows.push({first, last, top, height});
|
||
top += height + gap;
|
||
}
|
||
return {rows, height: rows.length ? top - gap : 0};
|
||
}
|
||
|
||
function lowerBound(rows, after) {
|
||
let low = 0, high = rows.length;
|
||
while (low < high) {
|
||
const middle = low + Math.floor((high - low) / 2);
|
||
if (after(rows[middle])) low = middle + 1;
|
||
else high = middle;
|
||
}
|
||
return low;
|
||
}
|
||
|
||
export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1) {
|
||
if (!rows.length) return [];
|
||
top = Number.isFinite(top) ? Math.max(0, top) : 0;
|
||
viewportHeight = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
|
||
const first = Math.min(rows.length - 1, lowerBound(rows, row => row.top + row.height <= top));
|
||
const end = Math.max(first + 1, lowerBound(rows, row => row.top < top + viewportHeight));
|
||
const visible = new Set();
|
||
for (let index = Math.max(0, first - 2); index < Math.min(rows.length, end + 2); index++) visible.add(index);
|
||
if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0) {
|
||
const focus = lowerBound(rows, row => row.last < focusedIndex);
|
||
if (focus < rows.length && rows[focus].first <= focusedIndex) visible.add(focus);
|
||
}
|
||
return [...visible].sort((a, b) => a - b);
|
||
}
|
||
|
||
// #249 D23: the card footer's words. English month names as the concept's copy, independent of the browser locale.
|
||
const MONTHS = Object.freeze(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']);
|
||
const DAY_MS = 86400000;
|
||
/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */
|
||
const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
|
||
|
||
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>", or "Not used yet" without a valid time. */
|
||
export function usedLabel(lastUsedAt, now = new Date()) {
|
||
const used = typeof lastUsedAt === 'string' && lastUsedAt ? new Date(lastUsedAt) : null;
|
||
if (!used || Number.isNaN(used.getTime())) return 'Not used yet';
|
||
// A server clock ahead of this browser is not a future use.
|
||
const days = Math.max(0, localDay(now) - localDay(used));
|
||
if (days === 0) return 'Used today';
|
||
if (days === 1) return 'Used yesterday';
|
||
if (days < 7) return `Used ${days} days ago`;
|
||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}`;
|
||
}
|
||
|
||
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
|
||
export function ratingLabel(rating) {
|
||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
|
||
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
|
||
}
|