Merge feature/workbench-w6-speech-bubbles into unify/skills-20261111
This commit is contained in:
commit
08fd189c30
19 files changed
+788
-20
No files matched your search
@@ -41,8 +41,14 @@ OFFICES_ROUTE = "/api/offices"
|
||||
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
|
||||
TASKS_ROUTE = "/api/tasks"
|
||||
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
||||
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
|
||||
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
||||
FONT_TYPE = "font/woff2"
|
||||
VENDOR = {
|
||||
"3d-force-graph.min.js": ("3d-force-graph/3d-force-graph.min.js", SCRIPT_TYPE),
|
||||
"three.module.js": ("three/three.module.js", SCRIPT_TYPE),
|
||||
"three.core.js": ("three/three.core.js", SCRIPT_TYPE),
|
||||
"comic-neue.woff2": ("comic-neue/comic-neue-latin-400-normal.woff2", FONT_TYPE),
|
||||
}
|
||||
VOICE_ROUTE = "/api/voice"
|
||||
VOICE_ITEM = re.compile(r"/api/voice/([0-9a-f]{16})(\.wav)?")
|
||||
MESSAGE_SIGN_ROUTE = "/api/messages/sign"
|
||||
@@ -82,7 +88,7 @@ class Handler(Exchange):
|
||||
elif path == "/":
|
||||
self.index()
|
||||
elif vendored and vendored.group(1) in VENDOR:
|
||||
self.vendored(VENDOR[vendored.group(1)])
|
||||
self.vendored(*VENDOR[vendored.group(1)])
|
||||
elif voice and voice.group(2):
|
||||
self.replay_voice(voice.group(1))
|
||||
elif voice:
|
||||
@@ -248,9 +254,9 @@ class Handler(Exchange):
|
||||
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
|
||||
self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers)
|
||||
|
||||
def vendored(self, name: str) -> None:
|
||||
def vendored(self, name: str, content_type: str) -> None:
|
||||
headers = {"Content-Security-Policy": PLAIN_CSP, "Cache-Control": "private, max-age=86400"}
|
||||
self.attempt((self.server.hub.vendor / name).read_bytes, lambda data: self.send(HTTPStatus.OK, SCRIPT_TYPE, data, headers))
|
||||
self.attempt((self.server.hub.vendor / name).read_bytes, lambda data: self.send(HTTPStatus.OK, content_type, data, headers))
|
||||
|
||||
def stored(self, review_id: str, name: str) -> None:
|
||||
suffix, content_type, csp = STORED[name]
|
||||
|
||||
@@ -49,6 +49,7 @@ SETTINGS = {
|
||||
},
|
||||
"estimation": "none",
|
||||
"trend": {"mode": "off", "days": 7},
|
||||
"bubbles": False,
|
||||
}
|
||||
SPACE_AXES = ("none", "time", "spawn")
|
||||
SPACE_LIMITS = {
|
||||
@@ -220,6 +221,7 @@ def valid_settings(raw: object) -> dict:
|
||||
"space": space_setting(data["space"]),
|
||||
"estimation": data["estimation"],
|
||||
"trend": trend_setting(data["trend"]),
|
||||
"bubbles": flag(data["bubbles"], "bubbles"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const Bubbles = (() => {
|
||||
"use strict";
|
||||
const SCALES = [1, 0.8, 0.65];
|
||||
const SMALLEST = 0.5;
|
||||
const PACE = 180;
|
||||
const SHORTEST = 1.5;
|
||||
|
||||
function scale(position) {
|
||||
return SCALES[position] ?? SMALLEST;
|
||||
}
|
||||
|
||||
function words(text) {
|
||||
return String(text || "").split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function reading(count) {
|
||||
return Math.max(SHORTEST, (count * 60) / PACE);
|
||||
}
|
||||
|
||||
function reveals(count, duration) {
|
||||
return Array.from({length: count}, (_, index) => (index / count) * duration);
|
||||
}
|
||||
|
||||
function shown(count, duration, elapsed) {
|
||||
return reveals(count, duration).filter((at) => at <= elapsed).length;
|
||||
}
|
||||
|
||||
function picked(row, speech) {
|
||||
return row.class === "mayday" || speech.classes.includes(row.class);
|
||||
}
|
||||
|
||||
function plan(head, pending) {
|
||||
const queue = head ? [head, ...pending] : pending;
|
||||
const shift = head ? 0 : 1;
|
||||
const seen = new Set();
|
||||
return queue.flatMap((item, index) => {
|
||||
if (seen.has(item.who)) {
|
||||
return [];
|
||||
}
|
||||
seen.add(item.who);
|
||||
return [{who: item.who, item, scale: scale(index + shift)}];
|
||||
});
|
||||
}
|
||||
|
||||
return {SCALES, SMALLEST, PACE, SHORTEST, scale, words, reading, reveals, shown, picked, plan};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Bubbles;
|
||||
}
|
||||
@@ -411,6 +411,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "style-ui.css" %}
|
||||
|
||||
{% include "style-tasks.css" %}
|
||||
|
||||
{% include "style-bubbles.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -421,6 +423,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
|
||||
<div class="tools">
|
||||
<button type="button" class="btn small ghost icon-only spot-toggle" id="radio-open" title="Radio: post, call or a message to all, with Record, the radio settings and the stations" aria-label="Radio" aria-controls="radio-spot" aria-expanded="false"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-broadcast-pin"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="bubbles-toggle" title="Speech bubbles" aria-label="Speech bubbles" aria-pressed="false" aria-controls="bubbles"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-chat-quote"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Commands (Ctrl+K)" aria-label="Commands"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Settings and ambience" aria-label="Settings"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
|
||||
</div>
|
||||
@@ -442,6 +445,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
<div class="palette" id="palette" hidden></div>
|
||||
<div class="sky-panel" id="sky-panel" role="group" aria-label="View of the sky" hidden></div>
|
||||
<div class="bubbles" id="bubbles" aria-hidden="true" hidden><div class="bubble-bar"></div></div>
|
||||
<script nonce="{{ nonce }}" id="state" type="application/json">{{ state | tojson }}</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{% include "ambience.js" %}
|
||||
@@ -449,6 +453,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<script nonce="{{ nonce }}">
|
||||
{% include "voice.js" %}
|
||||
|
||||
{% include "bubbles.js" %}
|
||||
|
||||
{% include "nav.js" %}
|
||||
|
||||
{% include "popup.js" %}
|
||||
@@ -550,6 +556,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-speech.js" %}
|
||||
|
||||
{% include "page-bubbles.js" %}
|
||||
|
||||
{% include "page-alerts.js" %}
|
||||
|
||||
{% include "page-rules.js" %}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
});
|
||||
document.getElementById("palette-open").addEventListener("click", openPalette);
|
||||
document.getElementById("settings-open").addEventListener("click", settingsSheet);
|
||||
document.getElementById("bubbles-toggle").addEventListener("click", toggleBubbles);
|
||||
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -37,6 +38,7 @@
|
||||
radio.element = radioElement();
|
||||
drawSky();
|
||||
musicButton();
|
||||
syncBubbles();
|
||||
syncMusic();
|
||||
syncRadio();
|
||||
timeBar();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
const BUBBLE_FONT = "/vendor/comic-neue.woff2";
|
||||
const BUBBLE_FADE = 400;
|
||||
const BUBBLE_EDGE = 8;
|
||||
const bubbles = {shown: new Map(), frame: 0, font: null};
|
||||
|
||||
function bubbleLayer() {
|
||||
return document.getElementById("bubbles");
|
||||
}
|
||||
|
||||
function bubbleFont() {
|
||||
// The CSP has no font-src, so a url() face is refused; a face built from fetched bytes loads nothing itself.
|
||||
if (!bubbles.font) {
|
||||
bubbles.font = fetch(BUBBLE_FONT, {cache: "force-cache"})
|
||||
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status)))))
|
||||
.then((data) => new FontFace("Comic Neue", data, {style: "normal", weight: "400"}).load())
|
||||
.then((loaded) => document.fonts.add(loaded))
|
||||
.catch(() => null);
|
||||
}
|
||||
return bubbles.font;
|
||||
}
|
||||
|
||||
function inView(box) {
|
||||
return box.width > 0 && box.height > 0 && box.left + box.width > 0 && box.top + box.height > 0 && box.left < window.innerWidth && box.top < window.innerHeight;
|
||||
}
|
||||
|
||||
function anchorOf(key) {
|
||||
const projected = g3.tab === "agents" ? anchor3d(key)() : null;
|
||||
if (projected && inView(projected)) {
|
||||
return projected;
|
||||
}
|
||||
return [...document.querySelectorAll(`[data-face="${CSS.escape(key)}"]`)].map((node) => node.getBoundingClientRect()).find(inView) || null;
|
||||
}
|
||||
|
||||
function bubbleNode(item) {
|
||||
const words = h("span", {class: "bubble-words"});
|
||||
return {node: h("div", {class: "speech-bubble", "data-who": item.who}, h("strong", {class: "bubble-who"}, nameOf(item.who)), words), words};
|
||||
}
|
||||
|
||||
function placeBubble(view, box, scale) {
|
||||
const layer = bubbleLayer();
|
||||
const bar = layer.querySelector(".bubble-bar");
|
||||
if (!box) {
|
||||
if (view.node.parentElement !== bar) {
|
||||
view.node.style.removeProperty("left");
|
||||
view.node.style.removeProperty("top");
|
||||
bar.append(view.node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (view.node.parentElement !== layer) {
|
||||
layer.append(view.node);
|
||||
}
|
||||
const [width, height] = [view.node.offsetWidth * scale, view.node.offsetHeight * scale];
|
||||
const middle = box.left + box.width / 2;
|
||||
const left = Math.min(Math.max(middle, BUBBLE_EDGE + width / 2), window.innerWidth - BUBBLE_EDGE - width / 2);
|
||||
view.node.style.left = `${fixed(left)}px`;
|
||||
view.node.style.top = `${fixed(Math.max(box.top - 4, BUBBLE_EDGE + height))}px`;
|
||||
view.node.style.setProperty("--tail", `${fixed((middle - left) / scale)}px`);
|
||||
}
|
||||
|
||||
function drawBubble(entry, view) {
|
||||
const {item} = entry;
|
||||
const said = item.words || Bubbles.words(item.line);
|
||||
const count = item.started ? Bubbles.shown(said.length, item.duration, (performance.now() - item.started) / 1000) : 0;
|
||||
const text = said.slice(0, count).join(" ") || "…";
|
||||
if (view.words.textContent !== text) {
|
||||
view.words.textContent = text;
|
||||
view.words.scrollTop = view.words.scrollHeight;
|
||||
}
|
||||
Object.assign(view.node.dataset, {scale: String(entry.scale), state: item.started ? "speaking" : "waiting", words: String(count)});
|
||||
view.node.style.setProperty("--scale", String(entry.scale));
|
||||
placeBubble(view, anchorOf(entry.who), entry.scale);
|
||||
}
|
||||
|
||||
function drawBubbles() {
|
||||
if (!settings.bubbles) {
|
||||
return;
|
||||
}
|
||||
const planned = Bubbles.plan(speech.current || speech.loading, speech.pending);
|
||||
const kept = new Set(planned.map((entry) => entry.item));
|
||||
for (const [item, view] of bubbles.shown) {
|
||||
if (!kept.has(item)) {
|
||||
bubbles.shown.delete(item);
|
||||
view.node.classList.add("leaving");
|
||||
window.setTimeout(() => view.node.remove(), BUBBLE_FADE);
|
||||
}
|
||||
}
|
||||
for (const entry of planned) {
|
||||
if (!bubbles.shown.has(entry.item)) {
|
||||
bubbles.shown.set(entry.item, bubbleNode(entry.item));
|
||||
}
|
||||
drawBubble(entry, bubbles.shown.get(entry.item));
|
||||
}
|
||||
if (bubbles.shown.size && !bubbles.frame) {
|
||||
bubbles.frame = window.requestAnimationFrame(() => {
|
||||
bubbles.frame = 0;
|
||||
drawBubbles();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearBubbles() {
|
||||
window.cancelAnimationFrame(bubbles.frame);
|
||||
bubbles.frame = 0;
|
||||
bubbles.shown.clear();
|
||||
for (const node of bubbleLayer().querySelectorAll(".speech-bubble")) {
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function syncBubbles() {
|
||||
document.getElementById("bubbles-toggle").setAttribute("aria-pressed", String(settings.bubbles));
|
||||
bubbleLayer().hidden = !settings.bubbles;
|
||||
if (settings.bubbles) {
|
||||
bubbleFont();
|
||||
drawBubbles();
|
||||
} else {
|
||||
clearBubbles();
|
||||
dropSilent();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBubbles() {
|
||||
saveSettings({...settings, bubbles: !settings.bubbles});
|
||||
}
|
||||
@@ -125,6 +125,7 @@
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} tasks` : "";
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`, "data-pick": node.id,
|
||||
"data-face": node.kind === "subagent" ? null : node.id,
|
||||
on: {
|
||||
click: () => select(node.id),
|
||||
keydown: (event) => {
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
if (ui.screen === "agents" && (ui.views.agents === "orbit" || ui.views.agents === "chord")) {
|
||||
stage();
|
||||
}
|
||||
drawBubbles();
|
||||
}
|
||||
|
||||
function sentRows() {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
syncMusic();
|
||||
syncRadio();
|
||||
musicButton();
|
||||
syncBubbles();
|
||||
}
|
||||
|
||||
async function saveSettings(next) {
|
||||
@@ -187,6 +188,7 @@
|
||||
space: savedSpace(),
|
||||
estimation: settings.estimation,
|
||||
trend: settings.trend,
|
||||
bubbles: settings.bubbles,
|
||||
};
|
||||
if (await saveSettings(next)) {
|
||||
closeSheet();
|
||||
|
||||
@@ -12,30 +12,66 @@
|
||||
function say(item) {
|
||||
speech.pending = Voice.enqueue(speech.pending, item, SPEECH_QUEUE);
|
||||
pump();
|
||||
drawBubbles();
|
||||
}
|
||||
|
||||
function readSilently(item, next) {
|
||||
Object.assign(item, {started: performance.now(), duration: Bubbles.reading(item.words.length)});
|
||||
speech.current = item;
|
||||
speech.timer = window.setTimeout(() => {
|
||||
Object.assign(speech, {current: null, timer: 0});
|
||||
drawSpeaker();
|
||||
next();
|
||||
}, item.duration * 1000);
|
||||
drawSpeaker();
|
||||
}
|
||||
|
||||
function endSilent() {
|
||||
window.clearTimeout(speech.timer);
|
||||
Object.assign(speech, {current: null, timer: 0, busy: false});
|
||||
}
|
||||
|
||||
function dropSilent() {
|
||||
speech.pending = speech.pending.filter((item) => !item.silent);
|
||||
if (speech.timer) {
|
||||
endSilent();
|
||||
drawSpeaker();
|
||||
pump();
|
||||
}
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
if (speech.busy || !speech.pending.length) {
|
||||
return;
|
||||
}
|
||||
const sound = context();
|
||||
if (!sound) {
|
||||
const [item, ...rest] = speech.pending;
|
||||
const sound = item.silent ? null : context();
|
||||
if (!item.silent && !sound) {
|
||||
speech.pending = [];
|
||||
return;
|
||||
}
|
||||
const [item, ...rest] = speech.pending;
|
||||
speech.pending = rest;
|
||||
speech.busy = true;
|
||||
const next = () => {
|
||||
speech.busy = false;
|
||||
pump();
|
||||
};
|
||||
if (item.silent) {
|
||||
readSilently(item, next);
|
||||
return;
|
||||
}
|
||||
speech.loading = item;
|
||||
drawBubbles();
|
||||
try {
|
||||
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${(await response.text()).trim()}`);
|
||||
}
|
||||
const buffer = await sound.decodeAudioData(await response.arrayBuffer());
|
||||
if (speech.loading !== item) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const source = sound.createBufferSource();
|
||||
const gain = sound.createGain();
|
||||
gain.gain.value = loudness(item.tone);
|
||||
@@ -47,29 +83,45 @@
|
||||
next();
|
||||
}, {once: true});
|
||||
duck(buffer.duration);
|
||||
Object.assign(speech, {current: item, source});
|
||||
Object.assign(item, {started: performance.now(), duration: buffer.duration});
|
||||
Object.assign(speech, {current: item, source, loading: null});
|
||||
drawSpeaker();
|
||||
source.start();
|
||||
} catch (error) {
|
||||
const kept = speech.loading === item;
|
||||
speech.loading = null;
|
||||
speechFailed([String(error.message || error)]);
|
||||
if (kept && settings.bubbles && item.words) {
|
||||
readSilently(item, next);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSpeech() {
|
||||
speech.pending = [];
|
||||
if (speech.timer) {
|
||||
endSilent();
|
||||
}
|
||||
Object.assign(speech, {pending: [], current: null, loading: null});
|
||||
if (speech.source) {
|
||||
speech.source.stop();
|
||||
}
|
||||
clearBubbles();
|
||||
drawSpeaker();
|
||||
}
|
||||
|
||||
function speakRows(rows) {
|
||||
if (!settings.speech.on || !state.speech.tts) {
|
||||
const voiced = settings.speech.on && state.speech.tts;
|
||||
if (!voiced && !settings.bubbles) {
|
||||
return;
|
||||
}
|
||||
for (const row of rows.filter((entry) => entry.ts >= state.now - SPEECH_FRESH && Voice.speakable(entry, settings.speech))) {
|
||||
const line = row.text.split("\n").map((part) => part.trim()).find(Boolean) || "";
|
||||
say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class, who: row.source, line: short(line)});
|
||||
for (const row of rows.filter((entry) => entry.ts >= state.now - SPEECH_FRESH)) {
|
||||
const audible = voiced && Voice.speakable(row, settings.speech);
|
||||
if (audible || (settings.bubbles && Bubbles.picked(row, settings.speech))) {
|
||||
const line = row.text.split("\n").map((part) => part.trim()).find(Boolean) || "";
|
||||
say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class, who: row.source, line: short(line), words: Bubbles.words(row.text), silent: !audible});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
const sky = {canvas: document.getElementById("sky"), stars: Ambience.stars(320, 7), mode: "", frame: 0};
|
||||
const music = {gain: null, filter: null, drone: [], timer: 0, tension: 0, ducked: 0};
|
||||
const radio = {element: null, url: "", level: 0, timer: 0, playing: false, waiting: false, index: -1, pick: null, sig: "", state: "", short: "", note: "", told: null};
|
||||
const speech = {pending: [], busy: false, warned: 0, current: null, source: null};
|
||||
const speech = {pending: [], busy: false, warned: 0, current: null, source: null, loading: null, timer: 0};
|
||||
const mic = {recorder: null, held: false, busy: false, timer: 0};
|
||||
const space = {
|
||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#bubbles-toggle[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.bubbles { position: fixed; inset: 0; z-index: 20; overflow: hidden; pointer-events: none; }
|
||||
.bubbles[hidden] { display: none; }
|
||||
.speech-bubble { --scale: 1; --tail: 0px; position: absolute; display: grid; gap: 2px; width: max-content; max-width: min(280px, 72vw); padding: 8px 13px; border: 2px solid var(--text); border-radius: 18px; background: var(--panel); color: var(--text); box-shadow: var(--shadow-lift); font: 400 var(--fs-md)/1.35 "Comic Neue", "Comic Sans MS", cursive; transform: translate(-50%, -100%) scale(var(--scale)); transform-origin: 50% 100%; transition: transform .3s var(--ease), opacity .4s var(--ease); }
|
||||
.speech-bubble::before, .speech-bubble::after { content: ""; position: absolute; top: 100%; left: calc(50% + var(--tail)); border-style: solid; border-color: transparent; }
|
||||
.speech-bubble::before { margin-left: -9px; border-width: 12px 9px 0; border-top-color: var(--text); }
|
||||
.speech-bubble::after { margin-left: -6px; border-width: 8px 6px 0; border-top-color: var(--panel); }
|
||||
.speech-bubble.leaving { opacity: 0; }
|
||||
.bubble-words { display: block; max-height: calc(6 * 1.35em); overflow: hidden; overflow-wrap: anywhere; scroll-behavior: smooth; }
|
||||
.speech-bubble[data-state="waiting"] .bubble-words { letter-spacing: .12em; }
|
||||
.bubble-who { display: none; color: var(--muted); font: 650 var(--fs-xs)/1.2 var(--sans); }
|
||||
.bubble-bar { position: absolute; right: 0; bottom: calc(var(--sp-4) + env(safe-area-inset-bottom, 0px)); left: 0; display: flex; flex-wrap: wrap-reverse; justify-content: center; align-items: flex-end; gap: var(--sp-3) var(--sp-4); padding: 0 var(--sp-3); }
|
||||
.bubble-bar .speech-bubble { position: relative; transform: scale(var(--scale)); }
|
||||
.bubble-bar .speech-bubble::before, .bubble-bar .speech-bubble::after { display: none; }
|
||||
.bubble-bar .bubble-who { display: block; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.speech-bubble { transition: opacity .4s var(--ease); }
|
||||
.bubble-words { scroll-behavior: auto; }
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue) ComicNeue-LightItalic.ttf: Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue) ComicNeue-Regular.ttf: Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue) ComicNeue-Italic.ttf: Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue) ComicNeue-Bold.ttf: Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue) ComicNeue-BoldItalic.ttf: Copyright 2014 The Comic Neue Project Authors (https://github.com/crozynski/comicneue)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Reference in new issue
Block a user