Compare commits

...
2 Commits
Author SHA1 Message Date
snowspeeder 6b8d55fc92 feat: add Hermes WebUI provisioning and English Dragon HD Omni voices
Rebuild & Deploy / rebuild (push) Successful in 2m17s
2026-10-02 16:07:24 -04:00
snowspeeder 47cc9c88a0 fix: persist verified iPhone certificate onboarding download 2026-10-01 18:02:14 -04:00
59 changed files with 1195 additions and 67 deletions

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
# Copy to .env; NEVER commit actual credentials. # Copy to .env; NEVER commit actual credentials.
AZURE_SPEECH_KEY= AZURE_SPEECH_KEY=
AZURE_SPEECH_REGION=eastus AZURE_SPEECH_REGION=eastus
# Recommended female default: latest Dragon HD Omni. Legacy Dragon HD defaults
# automatically migrate to the equivalent Omni persona. English voices are
# loaded from Azure's regional catalog in the web settings (server-side key only).
AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural AZURE_TTS_VOICE=en-US-Ava:DragonHDOmniLatestNeural
WEB_PORT=9178 WEB_PORT=9178
+1
View File
@@ -13,6 +13,7 @@ jobs:
run: | run: |
docker build -t hope-voice-api-voice:latest . docker build -t hope-voice-api-voice:latest .
docker build -f control.Dockerfile -t hope-voice-control:latest . docker build -f control.Dockerfile -t hope-voice-control:latest .
docker build -f hermes-webui.Dockerfile -t hope-hermes-webui:62e4d7b .
- name: Deploy using protected persistent runtime configuration - name: Deploy using protected persistent runtime configuration
run: | run: |
# Credentials, account DB, certificates and per-user homes are outside # Credentials, account DB, certificates and per-user homes are outside
+3 -1
View File
@@ -58,8 +58,10 @@ COPY web/livekit-client.umd.js /var/www/voice/
COPY web/manifest.json /var/www/voice/ COPY web/manifest.json /var/www/voice/
COPY web/favicon.svg /var/www/voice/ COPY web/favicon.svg /var/www/voice/
COPY web/token_server.py /opt/voice/token_server.py COPY web/token_server.py /opt/voice/token_server.py
COPY web/voice_catalog.py /opt/voice/voice_catalog.py
COPY web/voice_catalog.py /opt/voice-agent/voice_catalog.py
RUN chmod -R a+rX /var/www/voice \ RUN chmod -R a+rX /var/www/voice \
&& chmod 644 /opt/voice-agent/*.py /opt/voice/token_server.py && chmod 644 /opt/voice-agent/*.py /opt/voice/*.py
# Config files # Config files
COPY livekit.yaml /etc/livekit.yaml COPY livekit.yaml /etc/livekit.yaml
+86
View File
@@ -1,5 +1,91 @@
# Multi-user Hermes migration — qualification and operations # Multi-user Hermes migration — qualification and operations
## iPhone certificate onboarding follow-up — 2026-10-01
User reported certificate error at iOS sign-in. Current pinned native HTTPS probe
passed on the physical phone, while deleted John Doe account correctly returned401.
User confirmed deleting the disposable account; do not recreate it. Prior receipts
below describe historical qualification, not current John account existence.
User approved public certificate trust onboarding. Exact nginx route
`/hope-server.mobileconfig` serves only `/etc/voice/certs/hope-server.mobileconfig`
from persistent certificate mount with `application/x-apple-aspen-config` and
`Cache-Control: no-store`. It is a verified public certificate profile, no keys or
credentials; requires manual iOS profile installation + full trust for WSS. Private
key URL remains404. Do not expose the entire cert directory. Initial container-copy
profile disappeared during intervening deployment; persistent alias fixed it.
## Per-account Hermes WebUI — live receipt 2026-10-02
Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12`
inside both existing account containers using `hope-hermes-webui:62e4d7b`
(image `sha256:9324675849a485d4f6b851045d065bd4ebf5c3b7b5d85c0b112e9f91e64571b3`).
WebUI and the existing pinned Hermes gateway run as supervised peer processes.
Control image `sha256:e24aaa314d2e261da3693fd14ebe14dfbd62dbcf2d046e9181399ac033b3c7a8`
is running as `hope-voice-control:webui-upgrade`, also tagged `latest`.
- Admin WebUI: `https://192.168.86.2:30000`.
- Shane WebUI: `https://192.168.86.2:30001`.
- Gateway APIs remain private on `127.0.0.1:20000` and `:20001`.
- Distinct persistent random browser passwords, cookie names, WebUI state and
self-signed TLS certificates per account. Passwords are stored root-only under
`<runtime-root>/hermes/<full-account-id>/webui-password`, not Hope login passwords.
- Protected runtime environment now enables WebUI and its packaged image for new
accounts, using LAN port allocation range `30000-30999`. CI builds the image.
Verified from a separate LAN client: both HTTPS login pages returned200; no host
firewall/router changes were necessary. Authenticated checks on each WebUI:
health200, anonymous sessions401, password login200, authenticated sessions200.
Each authenticated WebUI created a qualification session, submitted a real
Gateway-backed turn and received an SSE stream containing `WEBUI_LAN_OK`.
Both pinned gateways advertise runs, SSE, cancellation and approval capabilities.
Full browser approval/cancellation workflow has not been independently qualified.
Offline regression:81 passed. Voice remains healthy, control healthOK, accounts
ready. No public-internet port forwarding or exposure configured.
Persistent state and runtime env backed up under
`/mnt/user/appdata/hope-voice/rollback/webui-20261002T173832Z` with protected inspect
receipts; previous control retained stopped as `hope-control-pre-webui-20261002T173832Z`.
Homes/workspaces and private gateway credentials retained. Source is still
local/unpushed; an older CI checkout could undo this change. Self-signed browser
certificate trust must be accepted/installed by clients. Different ports on the
same IP are not a browser cookie isolation boundary; distinct account DNS names
provide stronger isolation. Runtime instructions in `control/README.md`.
## Host-network LAN migration — live receipt 2026-10-02
Deployed control image `sha256:cc5cc7dd470e590ffc0c0b8084f4b2c2496074d42028695dde23644e2d4a576f`
as `hope-voice-control:lan-upgrade`, also tagged `hope-voice-control:latest`.
Control startup migrated both existing accounts without changing account names,
credentials or persistent home/workspace mount paths:
- `hope-hermes-admin-69101358`: host networking, ready, API `127.0.0.1:20000`.
- `hope-hermes-shane-fc44d140`: host networking, ready, API `127.0.0.1:20001`.
Authenticated Hermes readiness passed for both. API key SHA256 digests matched
pre-migration backup records. Actual `/proc/net/tcp` listeners were loopback-only.
From inside **each** container, TCP connections succeeded to `192.168.86.5:2099`
(model), `192.168.86.2:9178` (Hope UI), and `192.168.86.2:8094` (Hope Music).
These are reachability checks, not a new end-to-end conversation qualification.
Owned legacy networks were removed. Voice remained healthy and public UI HTTP200;
control `/health` returned OK. Unrelated containers were not redeployed.
Before replacement, control and both Hermes containers were stopped and account
DB/Hermes persistent state archived root-only under
`/mnt/user/appdata/hope-voice/rollback/lan-20261002T172231Z`, alongside protected
Docker inspection receipts. Stale Unix sockets were intentionally skipped by tar.
Original stopped control retained as `hope-control-pre-lan-20261002T172231Z`.
Existing NAS source checkout was not overwritten; clean control source staged
separately. Source changes are still local/unpushed: future CI deployments from
older source could regress this networking change until the update is committed
and deployed through the repository pipeline.
Runtime and migration instructions are in `control/README.md`. Host networking
removes per-user network namespace isolation; the Hermes API is loopback-only,
but arbitrary terminal tools can expose their own ports unless a separate host
ingress policy prevents it. Historical deployment receipts below predate this
migration.
## Deployment ## Deployment
Target: Unraid `192.168.86.2`, public UI `https://192.168.86.2:9178`. Target: Unraid `192.168.86.2`, public UI `https://192.168.86.2:9178`.
+6 -1
View File
@@ -17,11 +17,16 @@ try:
except ImportError: except ImportError:
from hermes_llm import HermesLLM from hermes_llm import HermesLLM
try:
from web.voice_catalog import normalize_default_voice
except ImportError:
from voice_catalog import normalize_default_voice
load_dotenv() load_dotenv()
logger = logging.getLogger("voice-agent") logger = logging.getLogger("voice-agent")
CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/") CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "") CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
DEFAULT_VOICE = os.getenv("AZURE_TTS_VOICE", "en-US-Ava:DragonHDOmniLatestNeural") DEFAULT_VOICE = normalize_default_voice(os.getenv("AZURE_TTS_VOICE", ""))
SYSTEM_PROMPT = """You are Hope, {display_name}'s personal voice assistant, powered by their private Hermes agent. SYSTEM_PROMPT = """You are Hope, {display_name}'s personal voice assistant, powered by their private Hermes agent.
Speak conversationally and keep ordinary answers to one to three sentences. Do not use markdown, Speak conversationally and keep ordinary answers to one to three sentences. Do not use markdown,
read URLs aloud, or expose hidden reasoning. Use your Hermes tools whenever needed; never claim an read URLs aloud, or expose hidden reasoning. Use your Hermes tools whenever needed; never claim an
+1 -1
View File
@@ -1,6 +1,6 @@
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 RUN pip install --no-cache-dir fastapi==0.142.2 httpx==0.28.1 uvicorn==0.54.0 PyYAML==6.0.3
COPY control /app/control COPY control /app/control
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Dedicated trusted management process: never share this socket with speech/Hermes. # Dedicated trusted management process: never share this socket with speech/Hermes.
+74
View File
@@ -79,6 +79,80 @@ For colocated FastAPI integration use `Depends(control.current_user)` and
this application's `app.state.accounts`/settings. Separate services use the this application's `app.state.accounts`/settings. Separate services use the
secret-authenticated internal endpoints instead. secret-authenticated internal endpoints instead.
## Hermes LAN access and migration
User containers use Docker **host networking** for outbound access to the same
LAN resources as Unraid. The control service must also run with host networking
(as in the deployment script). Each Hermes API binds **127.0.0.1 only**, on its
own persistent port; no LAN-facing API publication or Docker port bindings.
`HERMES_API_PORT_RANGE` defaults to `20000-29999`. Reserve this TCP range for
Hermes. Allocation skips ports already bound on the host and assignments are
stored in the protected `api-ports.json` under `HOPE_HERMES_DATA_DIR`. Run only
one control process against this runtime root. Assignments survive restarts and
account deletion alongside retained data; changing the range affects only new
assignments. An unrelated process taking an assigned port is an operator error;
provisioning fails rather than silently changing the account endpoint.
Deploy/restart the updated control service to migrate existing accounts:
startup schedules **all accounts**, including ready ones. Provisioning replaces
owned legacy bridge containers with host-networked containers, retaining the
account name, API key, home, workspace and non-listener configuration. Only the
API host/port settings in the persisted YAML are updated. Owned legacy networks
are removed. Expect brief per-account downtime; failed startup is retriable with
the retained state. The normal reconcile loop also rejects legacy containers as
not ready and schedules provisioning. This is not a live deployment receipt.
**Security tradeoff:** host networking removes per-user network namespace
isolation. Containers can reach host-loopback services and other account API
ports (authentication is still required). Loopback-only binding prevents direct
LAN access to the Hermes API, but is not isolation from other host-networked
containers. File separation, nonroot execution, capability drops and resource
limits remain. This controls the Hermes API listener, **not arbitrary tool
processes**: terminal/browser tools can open their own LAN-facing unprivileged
ports. Strict outbound-only enforcement needs a separate host ingress firewall
policy; host networking alone cannot guarantee that. Host networking does not
bypass LAN ACLs or configure mDNS/DNS.
## Per-account Hermes WebUI
Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the
pinned nesquena/hermes-webui revision with the existing pinned Hermes image and
uses `bootstrap.py --foreground --no-browser --skip-agent-install` as a supervised
second service. Both gateway and WebUI exit/restart together on service failure.
Enable in the protected control runtime environment:
```dotenv
HOPE_WEBUI_ENABLED=true
HERMES_IMAGE=hope-hermes-webui:62e4d7b
HERMES_WEBUI_HOST=192.168.86.2
HERMES_WEBUI_PORT_RANGE=30000-30999
```
Each user gets a persistent port recorded in `webui-ports.json`, a separate random
browser password in `<runtime-root>/<full-account-id>/webui-password` (0600), and
private WebUI state and self-signed TLS certificate under the retained home.
User-selected WebUI passwords have **no minimum length**; only blank/whitespace
and multiline values are rejected to keep authentication enabled. After changing
the protected password file, recreate that account container to update its env.
Passwords are **not** Hope login passwords. An operator can retrieve the intended
account's password over trusted SSH; never print all credentials or Docker envs.
Certificates have the configured LAN IP SAN; clients must trust the self-signed
certificate. Only WebUI binds the configured LAN IP; gateway APIs remain loopback.
No router forwarding/public internet access is configured. No firewall changes
are needed if the allocated LAN listener is already reachable.
Browser chat uses that account's authenticated gateway and runs API, preserving
its execution boundary/concurrency limit. Both API readiness and **HTTPS** WebUI
health must pass. Separate cookie names prevent collisions between ports, but
ports on the same IP are not a cookie isolation boundary: for stronger isolation,
use per-account hostnames. WebUI grants its authenticated owner powerful access
to account files/configuration/tools. Source/dependency installers are disabled
at runtime. Recreating an existing container preserves state, keys, API ports,
WebUI ports and browser passwords. CI builds the pinned WebUI image for future
accounts; keep the above environment enabled across control redeployments.
## Provisioner interface ## Provisioner interface
`create_app(settings: Settings, provisioner=...)` supports offline injection. `create_app(settings: Settings, provisioner=...)` supports offline injection.
+192 -37
View File
@@ -1,5 +1,6 @@
"""Trusted Docker-UDS control plane for independent Hermes accounts. """Trusted Docker-UDS control plane for independent Hermes accounts.
Hermes uses host networking for LAN egress with per-account loopback listeners.
Only this service may receive the host Docker socket. Bind-source paths must be Only this service may receive the host Docker socket. Bind-source paths must be
identical in the control service and Docker host (mount the runtime root there). identical in the control service and Docker host (mount the runtime root there).
The official image's root/s6 boot is deliberately bypassed: a nonroot Python The official image's root/s6 boot is deliberately bypassed: a nonroot Python
@@ -14,8 +15,11 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import secrets import secrets
import socket
import tempfile import tempfile
import yaml
import httpx import httpx
@@ -30,7 +34,7 @@ class OwnershipError(ProvisioningError):
# Run as the image's hermes UID without executing root-only s6 startup. No source # Run as the image's hermes UID without executing root-only s6 startup. No source
# checkout/profile is copied: only the image's public bundled skills are seeded. # checkout/profile is copied: only the image's public bundled skills are seeded.
_BOOTSTRAP = """ _BOOTSTRAP = """
import os, pathlib, shutil import os, pathlib, shutil, subprocess, signal, time, ssl
home = pathlib.Path('/opt/data') home = pathlib.Path('/opt/data')
for name in ('home', 'sessions', 'memories', 'skills', 'cron', 'hooks', 'logs', 'cache'): for name in ('home', 'sessions', 'memories', 'skills', 'cron', 'hooks', 'logs', 'cache'):
(home / name).mkdir(mode=0o700, parents=True, exist_ok=True) (home / name).mkdir(mode=0o700, parents=True, exist_ok=True)
@@ -39,7 +43,55 @@ if source.is_dir() and not (home / '.hope-skills-seeded').exists():
shutil.copytree(source, home / 'skills', dirs_exist_ok=True) shutil.copytree(source, home / 'skills', dirs_exist_ok=True)
(home / '.hope-skills-seeded').touch(mode=0o600) (home / '.hope-skills-seeded').touch(mode=0o600)
os.makedirs('/tmp/hermes-runtime', mode=0o700, exist_ok=True) os.makedirs('/tmp/hermes-runtime', mode=0o700, exist_ok=True)
if os.environ.get('HOPE_WEBUI_ENABLED') != 'true':
os.execv('/opt/hermes/.venv/bin/hermes', ['hermes', 'gateway', 'run']) os.execv('/opt/hermes/.venv/bin/hermes', ['hermes', 'gateway', 'run'])
# Generate a private per-account certificate once; never start without usable TLS.
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import datetime, ipaddress
tls = home / 'webui-tls'
tls.mkdir(mode=0o700, exist_ok=True)
cert, key = tls / 'cert.pem', tls / 'key.pem'
if not cert.exists() and not key.exists():
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Hope Hermes WebUI')])
now = datetime.datetime.now(datetime.timezone.utc)
names = [x509.DNSName('localhost'), x509.IPAddress(ipaddress.ip_address('127.0.0.1')),
x509.IPAddress(ipaddress.ip_address(os.environ['HERMES_WEBUI_HOST']))]
certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject)
.public_key(private.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.SubjectAlternativeName(names), critical=False).sign(private, hashes.SHA256()))
key.write_bytes(private.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
os.chmod(key, 0o600)
cert.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(str(cert), str(key))
children = []
def stop(signum=None, frame=None):
for child in children:
if child.poll() is None:
child.terminate()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
children.append(subprocess.Popen(['/opt/hermes/.venv/bin/hermes', 'gateway', 'run']))
children.append(subprocess.Popen(['/opt/hermes/.venv/bin/python', '/opt/hermes-webui/bootstrap.py',
'--foreground', '--no-browser', '--skip-agent-install']))
while all(child.poll() is None for child in children):
time.sleep(.5)
finally:
stop()
for child in children:
try:
child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
raise SystemExit(1)
""" """
@@ -56,8 +108,19 @@ class Provisioner:
def __init__(self, state_dir=None, *, docker_socket=None, image=None, def __init__(self, state_dir=None, *, docker_socket=None, image=None,
upstream_api_key=None, docker_client=None, health_client=None, upstream_api_key=None, docker_client=None, health_client=None,
ready_timeout=60, poll_interval=.5, runtime_uid=10000, ready_timeout=60, poll_interval=.5, runtime_uid=10000,
runtime_gid=10000, memory_bytes=2 * 1024 ** 3, nano_cpus=2_000_000_000): runtime_gid=10000, api_port_range=None, memory_bytes=2 * 1024 ** 3, nano_cpus=2_000_000_000):
bounds = api_port_range or os.environ.get('HERMES_API_PORT_RANGE', '20000-29999')
try:
if isinstance(bounds, str):
bounds = tuple(map(int, bounds.split('-')))
if len(bounds) != 2 or any(type(port) is not int for port in bounds) or not 1024 <= bounds[0] <= bounds[1] <= 65535:
raise ValueError()
except (TypeError, ValueError) as exc:
raise ValueError('Invalid Hermes API port range') from exc
self.api_port_range = bounds
self.state_dir = Path(state_dir or os.environ.get('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute() self.state_dir = Path(state_dir or os.environ.get('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute()
self.webui_enabled = os.environ.get('HOPE_WEBUI_ENABLED', 'false').lower() == 'true'
self.webui_host = os.environ.get('HERMES_WEBUI_HOST', '192.168.86.2')
self.image = image or os.environ.get('HERMES_IMAGE', 'nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db') self.image = image or os.environ.get('HERMES_IMAGE', 'nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db')
self.upstream_api_key = upstream_api_key if upstream_api_key is not None else os.environ.get('HERMES_UPSTREAM_API_KEY', '') self.upstream_api_key = upstream_api_key if upstream_api_key is not None else os.environ.get('HERMES_UPSTREAM_API_KEY', '')
self.ready_timeout, self.poll_interval = ready_timeout, poll_interval self.ready_timeout, self.poll_interval = ready_timeout, poll_interval
@@ -72,6 +135,48 @@ class Provisioner:
base_url='http://docker', timeout=20, trust_env=False) base_url='http://docker', timeout=20, trust_env=False)
self.health = health_client or httpx.AsyncClient(timeout=3, trust_env=False, follow_redirects=False) self.health = health_client or httpx.AsyncClient(timeout=3, trust_env=False, follow_redirects=False)
self._locks = {} self._locks = {}
self._ports_lock = asyncio.Lock()
def _ports(self, webui=False):
path = self.state_dir / ('webui-ports.json' if webui else 'api-ports.json')
if path.is_symlink():
raise ProvisioningError('Port registry cannot be a symlink')
try:
ports = json.loads(path.read_text())
except FileNotFoundError:
return {}
if not isinstance(ports, dict) or any(
not re.fullmatch(r'[0-9a-f]{32}', uid) or type(port) is not int
or not 1024 <= port <= 65535 for uid, port in ports.items()):
raise ProvisioningError('Invalid Hermes port registry')
if len(set(ports.values())) != len(ports):
raise ProvisioningError('Duplicate Hermes API ports')
return ports
async def _port(self, user, webui=False):
# One control process owns this registry, including concurrent accounts.
async with self._ports_lock:
ports = self._ports(webui)
if user['id'] in ports:
return ports[user['id']]
used = set(ports.values()) | set(self._ports(not webui).values())
bounds = tuple(map(int, os.environ.get('HERMES_WEBUI_PORT_RANGE', '30000-30999').split('-'))) if webui else self.api_port_range
if len(bounds) != 2 or not 1024 <= bounds[0] <= bounds[1] <= 65535:
raise ProvisioningError('Invalid WebUI port range')
for port in range(bounds[0], bounds[1] + 1):
if port in used:
continue
# Control must run in the Docker host network namespace.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
try:
probe.bind((self.webui_host if webui else '127.0.0.1', port))
except OSError:
continue
ports[user['id']] = port
self._directory(self.state_dir)
self._write_private(self.state_dir / ('webui-ports.json' if webui else 'api-ports.json'), json.dumps(ports) + '\n')
return port
raise ProvisioningError('No available Hermes API port')
async def aclose(self): async def aclose(self):
if self._own_docker: if self._own_docker:
@@ -147,7 +252,7 @@ class Provisioner:
raise ProvisioningError('Invalid persisted Hermes API credential') raise ProvisioningError('Invalid persisted Hermes API credential')
return key return key
def _prepare(self, user, exists): def _prepare(self, user, exists, port):
private, home, workspace = self._paths(user) private, home, workspace = self._paths(user)
self._directory(self.state_dir) self._directory(self.state_dir)
for path in (private, home, workspace): for path in (private, home, workspace):
@@ -161,9 +266,23 @@ class Provisioner:
config = home / 'config.yaml' config = home / 'config.yaml'
if not config.exists(): if not config.exists():
# JSON is a YAML subset; no credentials are placed in configuration. # JSON is a YAML subset; no credentials are placed in configuration.
self._write_private(config, json.dumps(self.config(), indent=2) + '\n') self._write_private(config, json.dumps(self.config(port), indent=2) + '\n')
elif config.is_symlink(): elif config.is_symlink():
raise ProvisioningError('Hermes config cannot be a symlink') raise ProvisioningError('Hermes config cannot be a symlink')
else:
# Preserve user configuration; only control-owned listener settings change.
try:
value = yaml.safe_load(config.read_text())
if not isinstance(value, dict):
raise ValueError()
server = value.setdefault('gateway', {}).setdefault('api_server', {})
if not isinstance(server, dict):
raise ValueError()
if server.get('host') != '127.0.0.1' or server.get('port') != port:
server.update(host='127.0.0.1', port=port)
self._write_private(config, yaml.safe_dump(value, sort_keys=False))
except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc:
raise ProvisioningError('Invalid Hermes runtime configuration') from exc
if os.geteuid() == 0: if os.geteuid() == 0:
# Only seeded directory roots/config, never recursive retained data. # Only seeded directory roots/config, never recursive retained data.
for path in (home, workspace, config): for path in (home, workspace, config):
@@ -173,7 +292,7 @@ class Provisioner:
return key, home, workspace return key, home, workspace
@staticmethod @staticmethod
def config(): def config(port=20000):
model = os.environ.get('HERMES_UPSTREAM_MODEL', 'auto') model = os.environ.get('HERMES_UPSTREAM_MODEL', 'auto')
return { return {
'model': {'default': model, 'provider': 'custom:hope-upstream'}, 'model': {'default': model, 'provider': 'custom:hope-upstream'},
@@ -184,38 +303,54 @@ class Provisioner:
'terminal': {'backend': 'local', 'cwd': '/workspace'}, 'terminal': {'backend': 'local', 'cwd': '/workspace'},
'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']}, 'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']},
'gateway': {'multiplex_profiles': False, 'gateway': {'multiplex_profiles': False,
'api_server': {'enabled': True, 'host': '0.0.0.0', 'port': 8642, 'api_server': {'enabled': True, 'host': '127.0.0.1', 'port': port,
'max_concurrent_runs': 1, 'tool_progress_events': True}}, 'max_concurrent_runs': 1, 'tool_progress_events': True}},
} }
async def _network(self, user): async def _remove_legacy_network(self, user):
name = 'hope-hermes-net-' + user['id'] name = 'hope-hermes-net-' + user['id']
response = await self._request('GET', f'/networks/{name}', allowed=(200, 404)) response = await self._request('GET', f'/networks/{name}', allowed=(200, 404))
if response.status_code == 404: if response.status_code == 200:
await self._request('POST', '/networks/create', allowed=(201, 409), json={
'Name': name, 'Driver': 'bridge', 'CheckDuplicate': True,
'Labels': {'hope.managed': 'true', 'hope.user_id': user['id']}})
response = await self._request('GET', f'/networks/{name}')
network = response.json() network = response.json()
self._owned(network, user) self._owned(network, user)
if network.get('Driver') != 'bridge' or network.get('Internal'): await self._request('DELETE', f'/networks/{network["Id"]}', allowed=(204, 404))
raise ProvisioningError('Owned Hermes network is not an outbound-capable dedicated bridge')
return name
def _spec(self, user, key, home, workspace, network): def _spec(self, user, key, home, workspace, port):
extra_env = []
if self.webui_enabled:
private, _, _ = self._paths(user)
password_path = private / 'webui-password'
if password_path.is_symlink():
raise ProvisioningError('WebUI password cannot be a symlink')
if not password_path.exists():
self._write_private(password_path, secrets.token_urlsafe(32) + '\n')
password = password_path.read_text().strip()
if not password or '\n' in password or '\r' in password:
raise ProvisioningError('WebUI password must be nonempty and single-line')
extra_env = ['HOPE_WEBUI_ENABLED=true', f'HERMES_WEBUI_HOST={self.webui_host}',
f'HERMES_WEBUI_PORT={self._ports(True)[user["id"]]}',
f'HERMES_WEBUI_PASSWORD={password}', f'HERMES_WEBUI_COOKIE_NAME=hope_webui_{user["id"]}',
'HERMES_WEBUI_SECURE=1', 'HERMES_WEBUI_SKIP_ONBOARDING=1',
'HERMES_WEBUI_PRESERVE_ENV=1', 'HERMES_WEBUI_AUTO_INSTALL=0',
'HERMES_WEBUI_SESSION_TTL=86400', 'HERMES_CONFIG_PATH=/opt/data/config.yaml',
'HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace', 'HERMES_WEBUI_SERVER_CWD=/workspace',
'HERMES_WEBUI_STATE_DIR=/opt/data/webui',
'HERMES_WEBUI_TLS_CERT=/opt/data/webui-tls/cert.pem',
'HERMES_WEBUI_TLS_KEY=/opt/data/webui-tls/key.pem',
'HERMES_WEBUI_CHAT_BACKEND=gateway', f'HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:{port}',
f'HERMES_WEBUI_GATEWAY_API_KEY={key}', 'HERMES_WEBUI_GATEWAY_USE_RUNS_API=true']
return { return {
'Image': self.image, 'User': f'{self.runtime_uid}:{self.runtime_gid}', 'Image': self.image, 'User': f'{self.runtime_uid}:{self.runtime_gid}',
'Entrypoint': ['/opt/hermes/.venv/bin/python'], 'Cmd': ['-c', _BOOTSTRAP], 'Entrypoint': ['/opt/hermes/.venv/bin/python'], 'Cmd': ['-c', _BOOTSTRAP],
'WorkingDir': '/workspace', 'WorkingDir': '/workspace',
'Labels': {'hope.managed': 'true', 'hope.user_id': user['id']}, 'Labels': {'hope.managed': 'true', 'hope.user_id': user['id'], **({'hope.webui': '62e4d7b'} if self.webui_enabled else {})},
'Env': ['HOME=/opt/data/home', 'HERMES_HOME=/opt/data', 'Env': ['HOME=/opt/data/home', 'HERMES_HOME=/opt/data',
'HERMES_GATEWAY_NO_SUPERVISE=1', 'API_SERVER_ENABLED=true', 'HERMES_GATEWAY_NO_SUPERVISE=1', 'API_SERVER_ENABLED=true',
'API_SERVER_HOST=0.0.0.0', 'API_SERVER_PORT=8642', f'API_SERVER_KEY={key}', 'API_SERVER_HOST=127.0.0.1', f'API_SERVER_PORT={port}', f'API_SERVER_KEY={key}',
f'HERMES_UPSTREAM_API_KEY={self.upstream_api_key}'], f'HERMES_UPSTREAM_API_KEY={self.upstream_api_key}'] + extra_env,
'ExposedPorts': {'8642/tcp': {}},
'HostConfig': { 'HostConfig': {
'Binds': [f'{home}:/opt/data:rw', f'{workspace}:/workspace:rw'], 'Binds': [f'{home}:/opt/data:rw', f'{workspace}:/workspace:rw'],
'NetworkMode': network, 'PortBindings': {'8642/tcp': [{'HostIp': '127.0.0.1', 'HostPort': ''}]}, 'NetworkMode': 'host',
'Init': True, 'Privileged': False, 'CapDrop': ['ALL'], 'Init': True, 'Privileged': False, 'CapDrop': ['ALL'],
'SecurityOpt': ['no-new-privileges:true'], 'PidsLimit': 256, 'SecurityOpt': ['no-new-privileges:true'], 'PidsLimit': 256,
'Memory': self.memory_bytes, 'MemorySwap': self.memory_bytes, 'NanoCpus': self.nano_cpus, 'Memory': self.memory_bytes, 'MemorySwap': self.memory_bytes, 'NanoCpus': self.nano_cpus,
@@ -230,19 +365,28 @@ class Provisioner:
env = info.get('Config', {}).get('Env') or [] env = info.get('Config', {}).get('Env') or []
if f'API_SERVER_KEY={key}' not in env: if f'API_SERVER_KEY={key}' not in env:
raise ProvisioningError('Owned container API credential does not match private state') raise ProvisioningError('Owned container API credential does not match private state')
bindings = info.get('NetworkSettings', {}).get('Ports', {}).get('8642/tcp') or [] if info.get('HostConfig', {}).get('NetworkMode') != 'host':
if len(bindings) != 1 or bindings[0].get('HostIp') != '127.0.0.1': raise ProvisioningError('Hermes requires host networking')
raise ProvisioningError('Hermes API must have exactly one loopback-only publication') port = self._ports().get(user['id'])
port = str(bindings[0].get('HostPort', '')) if not port or f'API_SERVER_HOST=127.0.0.1' not in env or f'API_SERVER_PORT={port}' not in env:
if not port.isdigit() or not 1 <= int(port) <= 65535: raise ProvisioningError('Hermes API must use its assigned loopback listener')
raise ProvisioningError('Hermes API has no allocated host port')
return {'container_name': container_name(user), 'hermes_url': f'http://127.0.0.1:{port}', 'hermes_api_key': key} return {'container_name': container_name(user), 'hermes_url': f'http://127.0.0.1:{port}', 'hermes_api_key': key}
async def _healthy(self, connection): async def _healthy(self, connection):
try: try:
response = await self.health.get(connection['hermes_url'] + '/v1/models', response = await self.health.get(connection['hermes_url'] + '/v1/models',
headers={'Authorization': 'Bearer ' + connection['hermes_api_key']}) headers={'Authorization': 'Bearer ' + connection['hermes_api_key']})
return response.status_code == 200 if response.status_code != 200:
return False
if self.webui_enabled:
api_port = int(connection['hermes_url'].rsplit(':', 1)[1])
uid = next((uid for uid, port in self._ports().items() if port == api_port), None)
if not uid:
return False
async with httpx.AsyncClient(verify=False, trust_env=False, timeout=3) as client:
webui = await client.get(f'https://{self.webui_host}:{self._ports(True)[uid]}/health')
return webui.status_code == 200 and webui.json().get('status') == 'ok'
return True
except httpx.HTTPError: except httpx.HTTPError:
return False return False
@@ -254,15 +398,30 @@ class Provisioner:
info = await self._inspect(name) info = await self._inspect(name)
if info: if info:
self._owned(info, user) self._owned(info, user)
key, home, workspace = self._prepare(user, info is not None) port = await self._port(user)
if self.webui_enabled:
await self._port(user, True)
key, home, workspace = self._prepare(user, info is not None, port)
# Validate credentials/spec before deleting the previous container.
spec = self._spec(user, key, home, workspace, port)
if info and (info.get('HostConfig', {}).get('NetworkMode') != 'host' or
(self.webui_enabled and info.get('Config', {}).get('Labels', {}).get('hope.webui') != '62e4d7b')):
# Verify retained credentials before replacing a legacy container.
if f'API_SERVER_KEY={key}' not in (info.get('Config', {}).get('Env') or []):
raise ProvisioningError('Owned container API credential does not match private state')
await self._request('DELETE', f'/containers/{info["Id"]}',
params={'force': 'true', 'v': 'false'}, allowed=(204, 404))
info = None
if info is None: if info is None:
network = await self._network(user)
await self._request('POST', '/containers/create', params={'name': name}, await self._request('POST', '/containers/create', params={'name': name},
json=self._spec(user, key, home, workspace, network), allowed=(201, 409)) json=spec, allowed=(201, 409))
info = await self._inspect(name) info = await self._inspect(name)
if info is None: if info is None:
raise ProvisioningError('Docker container creation did not persist; retry') raise ProvisioningError('Docker container creation did not persist; retry')
self._owned(info, user) self._owned(info, user)
# Never start a host-networked container with an unexpected listener.
self._connection(user, info, key)
await self._remove_legacy_network(user)
if not info.get('State', {}).get('Running'): if not info.get('State', {}).get('Running'):
await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304)) await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304))
# One overall deadline also bounds Docker inspect / health I/O; no # One overall deadline also bounds Docker inspect / health I/O; no
@@ -301,12 +460,8 @@ class Provisioner:
# are immutable for the lifetime of that Docker container ID. # are immutable for the lifetime of that Docker container ID.
await self._request('DELETE', f'/containers/{info["Id"]}', await self._request('DELETE', f'/containers/{info["Id"]}',
params={'force': 'true', 'v': 'false'}, allowed=(204, 404)) params={'force': 'true', 'v': 'false'}, allowed=(204, 404))
name = 'hope-hermes-net-' + user['id'] await self._remove_legacy_network(user)
response = await self._request('GET', f'/networks/{name}', allowed=(200, 404)) # Retain port assignments alongside retained keys/data for stable recovery.
if response.status_code == 200:
network = response.json()
self._owned(network, user)
await self._request('DELETE', f'/networks/{network["Id"]}', allowed=(204, 404))
# Intentionally retain private credential, home, workspace and memory. # Intentionally retain private credential, home, workspace and memory.
+1
View File
@@ -1,3 +1,4 @@
fastapi>=0.115,<1 fastapi>=0.115,<1
uvicorn>=0.30,<1 uvicorn>=0.30,<1
httpx>=0.27,<1 httpx>=0.27,<1
PyYAML>=6,<7
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-labelledby="title"><title id="title">Hope — overlapping speech bubbles</title><rect width="100" height="100" rx="23" fill="#2454df"/><path d="M23 26h39a8 8 0 0 1 8 8v20a8 8 0 0 1-8 8H42L28 73V62h-5a8 8 0 0 1-8-8V34a8 8 0 0 1 8-8Z" fill="#fff"/><path d="M47 40h30a8 8 0 0 1 8 8v17a8 8 0 0 1-8 8h-3v10L61 73H47a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8Z" fill="#a9c0ff" stroke="#2454df" stroke-width="4"/></svg>

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light"><title>Hope — CLEAR design demo</title>
<style>
:root{--blue:#2454df;--ink:#192334;--muted:#586578;--line:#e3e8ef;--group:#f3f6fa;--pale:#edf3ff;--red:#b62b36;--green:#197447;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",sans-serif;color:var(--ink);background:#f4f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button{cursor:pointer;min-height:44px;color:inherit}button:disabled{cursor:default;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid var(--blue);outline-offset:4px}button:hover:not(:disabled){filter:brightness(.96)}button:active:not(:disabled){transform:translateY(1px)}a{color:var(--blue)}[hidden]{display:none!important}.sheet{max-width:1140px;margin:auto;min-height:100vh;display:grid;grid-template-columns:1fr 438px;gap:90px;align-items:center;padding:56px 48px}.presentation{max-width:450px}.direction{display:flex;align-items:center;gap:12px;font-size:14px;font-weight:600;color:var(--muted)}.direction:before{content:"";width:25px;height:2px;background:var(--blue)}h1{font-size:76px;letter-spacing:-.04em;line-height:1.05;margin:28px 0 22px;font-weight:650}h1 span{display:block;font-weight:400;color:var(--blue)}.thesis{font-size:20px;line-height:1.6;margin:0;max-width:33ch;letter-spacing:-.015em}.design-notes{margin:34px 0 0;padding:0;list-style:none}.design-notes li{padding:17px 0;border-top:1px solid #dce3ec;font-size:14px;line-height:1.55;color:var(--muted)}.design-notes strong{display:block;font-size:16px;font-weight:600;color:var(--ink);margin-bottom:4px}.identity{margin-top:36px;display:flex;gap:22px;align-items:center}.app-icon{width:96px;height:96px;display:block}.small-icon{width:32px;height:32px}.icon-caption{margin:8px 0 0;color:var(--muted);font-size:12px}.trial{font-size:13px;line-height:1.6;color:var(--muted);margin:28px 0 0;max-width:43ch}.device-wrap{justify-self:center}.device{width:414px;padding:11px;background:#202733;border-radius:53px;box-shadow:0 22px 48px #26364b20,0 2px 4px #26364b25;position:relative}.device:before{content:"";position:absolute;left:-3px;top:160px;width:3px;height:62px;border-radius:2px;background:#384354}.screen{height:780px;overflow:hidden;border-radius:43px;background:white;display:flex;flex-direction:column;position:relative}.statusbar{height:54px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:0 27px;font-size:14px;font-weight:650;position:relative}.island{position:absolute;left:50%;transform:translateX(-50%);top:10px;width:116px;height:30px;border-radius:18px;background:#18212b}.status-icons{display:flex;gap:6px;align-items:center}.status-icons svg{width:17px;height:17px}.demo-strip{font-size:10px;font-weight:600;letter-spacing:.03em;color:#52617b;text-align:center;padding:7px 6px;background:var(--pale)}.app-header{padding:22px 24px 12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}.app-header h2{margin:0;font-size:36px;letter-spacing:-.035em;font-weight:700}.brand-mini{width:38px;height:38px}.icon{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.icon-button{width:44px;height:44px;padding:10px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px;color:var(--blue)}.app-body{flex:1;overflow-y:auto;overscroll-behavior:contain;padding:0 24px 18px}.welcome{padding:18px 0 25px}.welcome h3{font-size:28px;line-height:1.22;font-weight:600;letter-spacing:-.03em;margin:0 0 12px;max-width:10ch}.welcome p{font-size:15px;line-height:1.55;margin:0;color:var(--muted);max-width:29ch}.segmented{display:flex;background:#edf0f5;border-radius:9px;padding:3px;margin-bottom:14px}.segmented button{flex:1;min-height:44px;border:0;background:transparent;border-radius:7px;display:flex;gap:8px;align-items:center;justify-content:center;font-size:14px;font-weight:600;color:var(--muted)}.segmented button[aria-pressed=true]{background:white;color:var(--ink);box-shadow:0 1px 3px #18233815}.segmented .icon{width:18px;height:18px}.primary{width:100%;min-height:54px;border:0;border-radius:12px;background:var(--blue);color:#fff;display:flex;justify-content:center;align-items:center;gap:10px;font-weight:600;font-size:16px}.local-note{text-align:center;font-size:11px;color:var(--muted);margin:10px 0 0}.section-heading{display:flex;align-items:baseline;justify-content:space-between;margin-top:33px;margin-bottom:9px}.section-heading h3{font-size:18px;letter-spacing:-.015em;margin:0}.section-heading span{font-size:11px;color:var(--muted)}.list{border-top:1px solid var(--line)}.conversation-row{border:0;border-bottom:1px solid var(--line);background:transparent;display:flex;align-items:center;gap:12px;text-align:left;width:100%;padding:17px 0;min-height:78px}.row-symbol{width:36px;height:36px;background:var(--group);border-radius:9px;display:grid;place-items:center;color:var(--muted);flex-shrink:0}.row-text{flex:1;min-width:0}.row-text strong{displLine truncated
@media(max-width:950px){.sheet{gap:35px;padding:40px 28px;grid-template-columns:1fr 414px}h1{font-size:60px}.thesis{font-size:18px}.design-notes{margin-top:25px}}
@media(max-width:760px){.sheet{display:flex;flex-direction:column;padding:32px 20px;gap:30px}.presentation{width:100%;max-width:414px}.direction{font-size:12px}h1{font-size:48px;margin:18px 0 15px}h1 span{display:inline}.thesis{max-width:36ch;font-size:17px}.design-notes{display:none}.identity{margin-top:20px;gap:14px}.app-icon{width:58px;height:58px}.trial{margin-top:18px}.device-wrap{max-width:100%;width:414px}.device{width:100%;padding:8px;border-radius:45px}.screen{height:780px;border-radius:37px}.device-caption{line-height:1.5}}@media(max-width:430px){.sheet{padding:24px 12px}.device{padding:6px;border-radius:39px}.screen{border-radius:33px}.app-body{padding-left:20px;padding-right:20px}.app-header{padding-left:20px;padding-right:20px}.statusbar{padding-left:23px;padding-right:23px}.presentation{padding:0 8px}h1{font-size:44px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
</style>
</head>
<body>
<svg aria-hidden="true" width="0" height="0" style="position:absolute"><defs>
<symbol id="hope-mark" viewBox="0 0 100 100"><rect width="100" height="100" rx="23" fill="#2454df"/><path d="M23 26h39a8 8 0 0 1 8 8v20a8 8 0 0 1-8 8H42L28 73V62h-5a8 8 0 0 1-8-8V34a8 8 0 0 1 8-8Z" fill="#fff"/><path d="M47 40h30a8 8 0 0 1 8 8v17a8 8 0 0 1-8 8h-3v10L61 73H47a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8Z" fill="#a9c0ff" stroke="#2454df" stroke-width="4"/></symbol>
<symbol id="mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11v2a7 7 0 0 0 14 0v-2M12 20v2M8 22h8"/></symbol>
<symbol id="bubble" viewBox="0 0 24 24"><path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6 4V6a2 2 0 0 1 2-2Z"/><path d="M7 9h10M7 13h6"/></symbol>
<symbol id="activity" viewBox="0 0 24 24"><path d="M3 12h4l3-8 4 16 3-8h4"/></symbol>
<symbol id="settings" viewBox="0 0 24 24"><path d="m10 3-.7 2.2-2 .9-2.2-.7-2 3.4 1.6 1.6v2.3L3 14.3l2 3.4 2.2-.7 2 .9.8 2.1h4l.8-2.1 2-.9 2.2.7 2-3.4-1.7-1.6v-2.3L21 8.8l-2-3.4-2.2.7-2-.9L14 3Z"/><circle cx="12" cy="11.5" r="3"/></symbol>
<symbol id="chevron" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></symbol><symbol id="arrow-back" viewBox="0 0 24 24"><path d="m15 5-7 7 7 7"/></symbol><symbol id="arrow" viewBox="0 0 24 24"><path d="M12 19V5m-6 6 6-6 6 6"/></symbol><symbol id="mic-muted" viewBox="0 0 24 24"><path d="m3 3 18 18M9 9v3a3 3 0 0 0 5 2M9 5a3 3 0 0 1 6 0v6M5 11v2a7 7 0 0 0 12 5M19 11v2M12 20v2"/></symbol><symbol id="stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol><symbol id="check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
</defs></svg>
<main class="sheet">
<section class="presentation" aria-label="Design direction">
<a href="index.html" style="display:inline-flex;align-items:center;min-height:44px;font-size:14px;text-decoration:none;margin-bottom:18px">← All three directions</a><div class="direction">Hope for iOS · Design direction CLEAR</div>
<h1>A little<br>more <span>clarity.</span></h1>
<p class="thesis">A conversation should feel easy to start. And just as easy to come back to.</p>
<ul class="design-notes"><li><strong>Conversation comes first.</strong>A welcoming home, a single starting point, and recent conversations within reach.</li><li><strong>Daylight, not a light show.</strong>White space, readable system type, and cobalt where there’s something to do.</li><li><strong>A mark that means something.</strong>Two voices, two overlapping bubbles. Distinct at 32 pixels. No glow required.</li></ul>
<div class="identity"><svg class="app-icon" role="img" aria-label="Hope app icon: overlapping speech bubbles on cobalt"><use href="#hope-mark"/></svg><div><svg class="small-icon" role="img" aria-label="Hope icon at 32 pixels"><use href="#hope-mark"/></svg><p class="icon-caption">An exchange, not an orb.</p></div></div>
<p class="trial">Interactive design demo. Try a conversation, switch to text, or explore Activity and Settings. All content is sample data; no microphone, network, or account access.</p>
</section>
<section class="device-wrap" aria-label="Interactive iPhone demo">
<div class="device"><div class="screen">
<div class="statusbar" aria-hidden="true"><span>9:41</span><span class="island"></span><span class="status-icons"><svg viewBox="0 0 20 20" fill="currentColor"><rect x="1" y="12" width="3" height="5" rx="1"/><rect x="6" y="9" width="3" height="8" rx="1"/><rect x="11" y="6" width="3" height="11" rx="1"/><rect x="16" y="3" width="3" height="14" rx="1"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9a14 14 0 0 1 18 0M6 12a9 9 0 0 1 12 0M9 15a4 4 0 0 1 6 0"/><circle cx="12" cy="18" r="1" fill="currentColor"/></svg><svg viewBox="0 0 25 16" fill="none" stroke="currentColor"><rect x="1" y="2" width="20" height="12" rx="3"/><rect x="3" y="4" width="16" height="8" rx="1" fill="currentColor"/><path d="M23 6v4" stroke-width="2"/></svg></span></div>
<div class="demo-strip">INTERACTIVE DEMO · SAMPLE DATA · NO MIC ACCESS</div>
<header class="app-header"><h2 id="page-title">Hope</h2><svg id="header-mark" class="brand-mini" aria-label="Hope" role="img"><use href="#hope-mark"/></svg></header>
<div class="app-body" id="app-body" tabindex="-1">
<section class="welcome"><h3>What’s on<br>your mind?</h3><p>Talk it through, make a plan,<br>or follow a little curiosity.</p></section>
<div class="segmented" aria-label="Conversation input"><button type="button" data-mode="voice" aria-pressed="true"><svg class="icon"><use href="#mic"/></svg>Voice</button><button type="button" data-mode="text" aria-pressed="false"><svg class="icon"><use href="#bubble"/></svg>Text</button></div>
<button class="primary" id="start"><svg class="icon"><use href="#mic"/></svg>Start a conversation</button><p class="local-note">A simulated conversation. Your mic stays off.</p>
<div class="section-heading"><h3>Recent conversations</h3><span>Sample data</span></div>
<div class="list" id="recent-list"><button class="conversation-row" data-sample="0"><span class="row-symbol"><svg class="icon"><use href="#bubble"/></svg></span><span class="row-text"><strong>A slower Sunday</strong><small>Today · A little room to recharge</small></span><svg class="icon chevron"><use href="#chevron"/></svg></button><button class="conversation-row" data-sample="1"><span class="row-symbol"><svg class="icon"><use href="#bubble"/></svg></span><span class="row-text"><strong>Something good for dinner</strong><small>Yesterday · What’s in the fridge</small></span><svg class="icon chevron"><use href="#chevron"/></svg></button><button class="conversation-row" data-sample="2"><span class="row-symbol"><svg class="icon"><use href="#bubble"/></svg></span><span class="row-text"><strong>A thought worth keeping</strong><small>Monday · An idea for the weekend</small></span><svg class="icon chevron"><use href="#chevron"/></svg></button></div>
</div>
<nav class="tabbar" aria-label="App navigation"><button data-page="home" aria-current="page"><svg class="icon"><use href="#bubble"/></svg>Talk</button><button data-page="activity"><svg class="icon"><use href="#activity"/></svg>Activity</button><button data-page="settings"><svg class="icon"><use href="#settings"/></svg>Settings</button></nav><div class="home-indicator" aria-hidden="true"></div><div id="announcement" class="sr-only" role="status" aria-live="polite"></div>
</div></div><p class="device-caption">CLEAR · An interactive proposal, not the live app</p>
</section>
</main>
<script>
(()=>{'use strict';
const body=document.getElementById('app-body'),title=document.getElementById('page-title'),mark=document.getElementById('header-mark'),homeHTML=body.innerHTML,announce=document.getElementById('announcement');
const samples=[{title:'A slower Sunday',user:'I’d like a quieter Sunday. Where should I start?',reply:'Leave the first hour unplanned. A walk, a slow breakfast, and one thing you’re looking forward to could be enough.'},{title:'Something good for dinner',user:'I have chickpeas, tomatoes, and a little feta.',reply:'Try warm chickpeas with tomatoes and olive oil, then crumble the feta on top. Some toasted bread would make it a lovely dinner.'},{title:'A thought worth keeping',user:'Maybe we could take the train to the coast this weekend.',reply:'That sounds like a good change of pace. Start with a morning train and leave the afternoon open for a walk by the water.'}];
let mode='voice',page='home',timers=[],muted=false,state='idle',events=[],voice='Ava',spoken=true,session=0;
const icon=(name)=>`<svg class="icon" aria-hidden="true"><use href="#${name}"/></svg>`;
function clearTimers(){timers.forEach(clearTimeout);timers=[];session++;}
function later(fn,ms){const current=session;timers.push(setTimeout(()=>{if(current===session)fn();},ms));}
function say(text){announce.textContent=text;}
function setPage(next){clearTimers();page=next;state='idle';title.textContent=next==='home'?'Hope':next==='activity'?'Activity':next==='settings'?'Settings':'Conversation';title.style.fontSize=next==='conversation'?'28px':'';mark.hidden=next!=='home';document.querySelectorAll('[data-page]').forEach(b=>{if(b.dataset.page===(next==='conversation'?'home':next))b.setAttribute('aria-current','page');else b.removeAttribute('aria-current');});body.scrollTop=0;}
function modePicker(){return `<div class="segmented" aria-label="Conversation input"><button data-mode="voice" aria-pressed="${mode==='voice'}">${icon('mic')}Voice</button><button data-mode="text" aria-pressed="${mode==='text'}">${icon('bubble')}Text</button></div>`;}
function updateMode(){body.querySelectorAll('[data-mode]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.mode===mode)));if(page==='home'){document.getElementById('start').innerHTML=icon(mode==='voice'?'mic':'bubble')+(mode==='voice'?'Start a conversation':'Write a message');body.querySelector('.local-note').textContent=mode==='voice'?'A simulated conversation. Your mic stays off.':'A local text demo. Nothing is sent.';}}
function home(){setPage('home');body.innerHTML=homeHTML;updateMode();}
function addMessage(role,text){const box=document.createElement('div');box.className='message '+(role==='You'?'user':'hope');const label=document.createElement('small');label.textContent=role;const p=document.createElement('p');p.textContent=text;box.append(label,p);document.getElementById('transcript').append(box);body.scrollTop=body.scrollHeight;}
function conversation(sample){setPage('conversation');muted=false;body.innerHTML=`<button class="back" id="back">${icon('arrow-back')}All conversations</button><h3 class="conversation-heading" id="conversation-title">New conversation</h3><p class="conversation-meta">Local demo · No audio is recorded</p><div id="transcript" class="transcript" role="log" aria-live="polite" aria-label="Conversation transcript"></div>${modePicker()}<div id="interaction"></div>`;if(sample){document.getElementById('conversation-title').textContent=sample.title;addMessage('You',sample.user);addMessage('Hope',sample.reply);}renderInteraction(!sample);}
function renderInteraction(startVoice){clearTimers();const el=document.getElementById('interaction');if(mode==='text'){state='text';el.innerHTML=`<form class="composer" id="text-form"><label for="message" class="sr-only">Message Hope</label><input id="message" placeholder="Message Hope…" maxlength="2000" autocomplete="off" required><button aria-label="Send message">${icon('arrow')}</button></form><p class="hint">Demo replies are prewritten. Your text stays on this page.</p>`;if(startVoice)document.getElementById('message').focus();}else{el.innerHTML=`<div class="voice-panel"><div class="mic-emblem" id="mic-emblem">${icon('mic')}</div><h3 id="voice-status">Ready to listen</h3><p id="voice-detail">Your real microphone is off.</p><div class="voice-controls"><button class="control" id="mute">${icon('mic-muted')}Mute</button><button class="control end" id="end">${icon('stop')}End</button></div></div><button class="primary" id="listen">${icon('mic')}Try a voice turn</button><p class="hint">Simulated voice only. No sound is played or recorded.</p>`;state='idle';if(startVoice)listen();}}
function status(next,heading,detail){state=next;const s=document.getElementById('voice-status');if(!s)return;s.textContent=heading;document.getElementById('voice-detail').textContent=detail;say(heading+'. '+detail);}
function listen(){clearTimers();muted=false;document.getElementById('mute').disabled=false;document.getElementById('end').disabled=false;document.getElementById('mute').innerHTML=icon('mic-muted')+'Mute';document.getElementById('mute').setAttribute('aria-pressed','false');document.getElementById('listen').disabled=true;document.getElementById('mic-emblem').innerHTML=icon('mic');status('listening','Listening…','Simulated mic on · Try Mute or End');later(()=>{addMessage('You','Help me make a little space in my day.');status('working','Thinking it through…','Mic paused · Preparing a sample reply');},2400);later(()=>{addMessage('Hope','Pick one thing that can wait, and give yourself ten minutes without a plan. What would you like that little break to feel like?');status('reply','Hope is replying','Simulated reply · No audio is playing');events.unshift({title:'A little space in the day',description:'Completed a simulated voice turn.'});},4100);later(()=>{status('idle','Ready when you are','Simulated mic off · Tap below to try again');document.getElementById('listen').disabled=false;},6200);}
function activity(){setPage('activity');body.innerHTML=`<p class="activity-intro">A simple record of your conversations.<br>Everything here is local sample data.</p><div class="section-heading"><h3>Today</h3><span>On this page</span></div><div id="activity-events"></div><div class="event"><span class="row-symbol">${icon('check')}</span><div><strong>A slower Sunday</strong><p>A sample conversation about making room to recharge.</p><time>9:12 AM · Sample</time></div></div><div class="section-heading"><h3>Yesterday</h3></div><div class="event"><span class="row-symbol">${icon('bubble')}</span><div><strong>Something good for dinner</strong><p>A sample conversation with a simple dinner idea.</p><time>6:24 PM · Sample</time></div></div><p class="live-note">Try a voice or text conversation to add a demo event here. This record resets when you reload.</p>`;events.forEach(e=>{const row=document.createElement('div');row.className='event';const symbol=document.createElement('span');symbol.className='row-symbol';symbol.innerHTML=icon('check');const detail=document.createElement('div');const strong=document.createElement('strong');strong.textContent=e.title;const p=document.createElement('p');p.textContent=e.description;const time=document.createElement('time');time.textContent='Just now · Demo';detail.append(strong,p,time);row.append(symbol,detail);document.getElementById('activity-events').append(row);});}
function settings(){setPage('settings');body.innerHTML=`<p class="activity-intro">Make Hope feel comfortable for you.</p><div class="group-label">Conversation</div><div class="grouped"><label class="setting-row" for="voice-choice"><span>Voice preference<small>Used for simulated voice only</small></span><select id="voice-choice"><option>Ava</option><option>Andrew</option><option>Emma</option></select></label><label class="setting-row" for="spoken"><span>Spoken replies<small>Preference only; no audio plays</small></span><input type="checkbox" id="spoken"></label></div><p class="settings-note">Preferences stay in this demo until you reload. Voice choice doesn’t change the sample transcript.</p><div class="group-label">About this demo</div><div class="grouped"><div class="setting-row"><span>Hope for iOS</span><span style="color:var(--muted)">CLEAR</span></div><div class="setting-row"><span>Data & privacy<small>No account, microphone, or network access.<br>All conversations are local examples.</small></span></div></div><div class="group-label">Account</div><div class="grouped"><div class="setting-row"><span>Demo account<small>You are not signed in to a real account.</small></span></div><button class="danger-button" id="signout">Simulate sign out</button></div><div id="account-message" role="status"></div>`;document.getElementById('voice-choice').value=voice;document.getElementById('spoken').checked=spoken;}
body.addEventListener('click',e=>{const button=e.target.closest('button');if(!button)return;if(button.dataset.mode){mode=button.dataset.mode;if(page==='home')updateMode();else renderInteraction(false);say(mode==='voice'?'Voice mode. Microphone access is simulated.':'Text mode. Messages stay on this page.');return;}if(button.dataset.sample!==undefined){conversation(samples[Number(button.dataset.sample)]);return;}switch(button.id){case'start':conversation();break;case'back':home();break;case'listen':listen();break;case'mute':muted=!muted;clearTimers();button.setAttribute('aria-pressed',String(muted));button.innerHTML=icon(muted?'mic':'mic-muted')+(muted?'Unmute':'Mute');if(muted){status('muted','Microphone muted','Simulation paused · Unmute to try again');document.getElementById('mic-emblem').innerHTML=icon('mic-muted');document.getElementById('listen').disabled=true;}else listen();break;case'end':clearTimers();status('ended','Conversation ended','Demo stopped · Your real mic was never on');document.getElementById('mute').disabled=true;document.getElementById('end').disabled=true;document.getElementById('listen').disabled=false;document.getElementById('listen').innerHTML=icon('mic')+'Start another voice turn';events.unshift({title:'Conversation ended',description:'Stopped the simulated conversation.'});break;case'signout':document.getElementById('account-message').innerHTML='<div class="notice"><strong>Simulated sign out</strong><br>No real account was signed out. This is only a design demo.<br><button id="reset-account">Return to demo account</button></div>';button.disabled=true;break;case'reset-account':document.getElementById('account-message').innerHTML='';document.getElementById('signout').disabled=false;say('Demo account restored. No real account action occurred.');break;}});
body.addEventListener('change',e=>{if(e.target.id==='voice-choice'){voice=e.target.value;say(voice+' selected for this demo.');}if(e.target.id==='spoken'){spoken=e.target.checked;say('Spoken replies '+(spoken?'enabled':'disabled')+' as a demo preference. No audio plays.');}});
body.addEventListener('submit',e=>{if(e.target.id!=='text-form')return;e.preventDefault();const field=document.getElementById('message'),text=field.value.trim();if(!text)return;addMessage('You',text);field.value='';addMessage('Hope','Let’s take it one step at a time. What’s the part you’d most like to talk through? (This is a prewritten demo reply.)');events.unshift({title:'Text conversation',description:'Sent a local demo message. Nothing was transmitted.'});field.focus();say('Local demo message added.');});
document.querySelector('.tabbar').addEventListener('click',e=>{const b=e.target.closest('[data-page]');if(!b)return;if(b.dataset.page==='home')home();else if(b.dataset.page==='activity')activity();else settings();body.focus({preventScroll:true});say(title.textContent+' page');});
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><rect width="1024" height="1024" fill="#db6137"/><g transform="translate(112 112) scale(8)"><path fill="#fff" d="M18 19h64v48H51L31 83V67H18z"/><path d="M36 34v19m14-24v29m14-24v19" stroke="#db6137" stroke-width="6" stroke-linecap="round"/></g></svg>

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Hope — Gather</title><style>
:root{--bg:#edf1ef;--ink:#183d34;--quiet:#52685f;--tint:#17614d;--wash:#e5eee9;--line:#d6e0da;--surface:#fff;--on:#fff}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}button,input,select{font:inherit}button{cursor:pointer;color:inherit;min-height:44px;border:0;background:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #b14d22;outline-offset:3px}a{color:inherit}h1,h2,p{margin:0}h1,h2{text-wrap:balance}[hidden]{display:none!important}.studio{max-width:1260px;margin:auto;min-height:100vh;display:grid;grid-template-columns:1fr 390px 200px;gap:50px;align-items:center;padding:44px}.intro{align-self:start;padding-top:70px}.back{font-size:14px;text-decoration:none;display:inline-flex;align-items:center;min-height:44px}.direction{display:block;margin:38px 0 18px;font-size:15px;font-weight:600}.intro h1{font-family:Georgia,serif;font-size:64px;font-weight:400;letter-spacing:-.035em;line-height:1.06}.lede{font-size:18px;line-height:1.6;margin-top:24px;max-width:33ch}.notes{margin-top:40px}.note{border-top:1px solid #c8d5cd;padding:18px 0;font-size:14px;line-height:1.55}.note strong{display:block;font-size:15px;margin-bottom:4px}.prototype-label{font-size:12px;margin:0 0 12px;text-align:center;color:var(--quiet)}.phone{width:390px;height:804px;background:var(--surface);border:8px solid #19352c;border-radius:48px;position:relative;overflow:hidden;display:flex;flex-direction:column}.statusbar{height:48px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:10px 26px 0;font-size:14px;font-weight:650}.island{position:absolute;top:9px;left:50%;transform:translateX(-50%);width:110px;height:28px;background:#19352c;border-radius:20px}.topbar{display:flex;align-items:center;justify-content:space-between;padding:18px 20px 12px}.brand{font-size:28px;font-weight:650;letter-spacing:-.035em;display:flex;gap:9px;align-items:center}.brand svg{width:27px;height:27px;color:#db6137}.account{border:1px solid var(--line);border-radius:50%;width:44px;font-size:14px;font-weight:600}.view{flex:1;overflow:auto;padding:0 20px 18px;min-height:0}.welcome{padding:21px 0 23px}.welcome h2{font-family:Georgia,serif;font-size:34px;font-weight:400;line-height:1.16;letter-spacing:-.025em}.welcome p{color:var(--quiet);font-size:15px;line-height:1.5;margin-top:12px}.meta{display:flex;align-items:center;gap:7px;color:var(--quiet);font-size:12px;margin:0 0 18px}.dot{width:7px;height:7px;background:var(--tint);border-radius:50%}.topic{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;font-size:13px;color:var(--quiet)}.tag{background:var(--wash);padding:6px 10px;border-radius:6px;color:var(--ink)}.messages{display:flex;flex-direction:column;gap:22px}.message{font-size:16px;line-height:1.55}.who{font-size:12px;color:var(--quiet);font-weight:600;margin-bottom:7px}.message.user{margin-left:36px;background:var(--wash);padding:12px 15px;border-radius:12px}.message.assistant{padding-right:7px}.message p{white-space:pre-wrap;overflow-wrap:anywhere}.suggestion{display:flex;justify-content:space-between;width:100%;text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);font-size:14px;margin-top:24px;padding:10px 0;color:var(--tint)}.dock{padding:14px 20px 15px;border-top:1px solid var(--line)}.voice-line{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.voice-status{font-size:13px;font-weight:600}.voice-sub{font-size:12px;color:var(--quiet);margin-top:4px}.wave{height:24px;display:flex;align-items:center;gap:3px;color:var(--tint)}.wave i{width:3px;height:7px;background:currentColor;border-radius:3px}.wave i:nth-child(2n){height:17px}.wave i:nth-child(3n){height:24px}.controls{display:flex;gap:8px}.primary{background:var(--tint);color:var(--on);border-radius:12px;font-weight:600;flex:1;min-height:52px}.secondary{background:var(--wash);border-radius:12px;padding:0 14px;min-height:52px}.end{color:#9b352a;border:1px solid var(--line);border-radius:12px;padding:0 12px}.tabs{display:flex;justify-content:space-around;padding:7px 12px 17px;border-top:1px solid var(--line);flex-shrink:0}.tab{display:flex;flex-direction:column;gap:4px;align-items:center;color:var(--quiet);min-width:80px;font-size:11px;padding:4px}.tab svg{width:23px;height:23px}.tab[aria-selected=true]{color:var(--tint);font-weight:700}.home{position:absolute;bottom:6px;width:120px;height:4px;background:var(--ink);left:50%;transform:translateX(-50%);border-radius:3px}.app-icon{width:150px;height:150px;border-radius:34px;background:#db6137;display:grid;place-items:center}.app-icon svg{width:110px;height:110px;color:#fff}.icon-title{font-size:17px;font-weight:650;margin-top:20px}.icon-caption{font-size:14px;line-height:1.6;margin-top:10px;color:var(--quiet)}.small-icons{display:flex;gap:14px;align-items:end;margLine truncated
</style></head><body><svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="mark" viewBox="0 0 100 100"><path fill="currentColor" d="M18 19h64v48H51L31 83V67H18z"/><path d="M36 34v19m14-24v29m14-24v19" stroke="#db6137" stroke-width="6" stroke-linecap="round"/></symbol><symbol id="talk" viewBox="0 0 24 24"><path d="M4 4h16v12H10l-5 4v-4H4z"/></symbol><symbol id="activity" viewBox="0 0 24 24"><path d="M9 5h11M9 12h11M9 19h11M3 5l1 1 2-3M3 12l1 1 2-3M3 19l1 1 2-3"/></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M3 7h18M3 17h18"/><circle cx="8" cy="7" r="3"/><circle cx="16" cy="17" r="3"/></symbol></defs></svg>
<main class="studio"><section class="intro"><a class="back" href="index.html">← All three directions</a><span class="direction">Gather / conversation first</span><h1>A place for<br>your thoughts.</h1><p class="lede">Less dashboard. More conversation. A readable thread that keeps your words—and Hope’s answers—at the center.</p><div class="notes"><div class="note"><strong>The transcript is the interface.</strong>No oversized visualizer. Pick up a conversation without hunting for its context.</div><div class="note"><strong>Voice stays within reach.</strong>One persistent dock for talking, muting, or switching to text.</div><div class="note"><strong>A warmer identity, without the glow.</strong>Forest-green controls. A terracotta speech mark that reads clearly on your home screen.</div></div></section>
<section class="demo" aria-label="Interactive Gather prototype"><p class="prototype-label">Interactive iOS concept · sample conversation</p><div class="phone" id="phone"><div class="statusbar"><span>9:41</span><div class="island"></div><span aria-hidden="true">▮▮▮ ▰</span></div><header class="topbar"><div class="brand">Hope <svg aria-hidden="true"><use href="#mark"/></svg></div><button class="account" id="account" aria-label="Open account settings">S</button></header><section class="view" id="talk-view" aria-label="Conversation"><div class="welcome"><h2>Let’s make room<br>for a good weekend.</h2><p>One conversation. Speak or type.</p></div><div class="meta"><span class="dot"></span>Your personal assistant · demo</div><div class="topic"><span class="tag">Weekend plans</span><span>Today, 9:38</span></div><div class="messages" id="messages" aria-live="polite"><article class="message user"><div class="who">You</div><p>I want a quiet weekend. Help me find a starting point?</p></article><article class="message assistant"><div class="who">Hope</div><p>Start with one thing you don’t want to rush. A morning walk, a good book, or cooking something new. What sounds good to you?</p></article></div><button class="suggestion" id="suggestion"><span>Let’s start with a morning walk</span><span aria-hidden="true">↗</span></button></section>
<section class="view" id="activity-view" aria-label="Assistant activity" hidden><h2 class="section-title">Activity</h2><p class="section-sub">See what Hope is working on, without leaving your conversation.</p><div class="row"><strong id="task-title">No task running</strong><p id="task-detail">Start the voice demo to see progress here.</p><small id="task-status">Ready</small></div><div class="row"><strong>Weekend conversation</strong><p>Your latest conversation is available in Talk.</p><small>Sample history · Today, 9:38</small></div></section><section class="view" id="settings-view" aria-label="Settings" hidden><h2 class="section-title">Settings</h2><p class="section-sub">Your voice, your preferences.</p><div class="group"><div class="setting"><span>Account</span><strong>Shane · demo</strong></div><label class="setting"><span>Hope’s voice</span><select id="voice"><option>Ava</option><option>Andrew</option><option>Emma</option></select></label><label class="setting"><span>Read replies aloud</span><input class="switch" type="checkbox" checked></label><label class="setting"><span>Dark appearance</span><input class="switch" id="dark-switch" type="checkbox"></label></div><p class="notice">Preferences are local to this demonstration. No server or account is connected.</p><button class="suggestion" id="signout">Sign out of demo <span aria-hidden="true">→</span></button><p class="notice" id="settings-note" role="status"></p></section>
<div class="dock" id="dock"><div class="voice-line"><div><div class="voice-status" id="voice-status" role="status">Ready when you are</div><div class="voice-sub" id="voice-sub">Microphone off · simulated audio</div></div><div class="wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div></div><div class="controls" id="voice-controls"><button class="primary" id="start">Talk to Hope</button><button class="secondary" id="text" aria-label="Switch to text">Aa</button><button class="end" id="end" hidden>End</button></div><form class="composer" id="composer" hidden><label class="sr-only" for="input">Message Hope</label><input id="input" placeholder="Message Hope…" maxlength="1000" autocomplete="off"><button aria-label="Send message" type="submit">↑</button></form><button class="mode-back" id="voice-back" hidden>Switch to voice</button></div>
<nav class="tabs" role="tablist" aria-label="Main sections"><button class="tab" role="tab" aria-selected="true" aria-controls="talk-view" data-view="talk"><svg fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><use href="#talk"/></svg>Talk</button><button class="tab" role="tab" aria-selected="false" aria-controls="activity-view" data-view="activity"><svg fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><use href="#activity"/></svg>Activity</button><button class="tab" role="tab" aria-selected="false" aria-controls="settings-view" data-view="settings"><svg fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><use href="#settings"/></svg>Settings</button></nav><div class="home" aria-hidden="true"></div></div></section>
<aside class="icons"><div class="app-icon" aria-label="New Gather app icon"><svg aria-hidden="true"><use href="#mark"/></svg></div><h2 class="icon-title">The speech mark</h2><p class="icon-caption">A conversation bubble with three voice bars. Clear silhouette. No capsule, no halo.</p><div class="small-icons" aria-label="Icon at home-screen and small sizes"><div class="small-icon"><svg aria-hidden="true"><use href="#mark"/></svg></div><div class="small-icon tiny"><svg aria-hidden="true"><use href="#mark"/></svg></div></div><button class="theme" id="theme">Preview dark appearance</button><p class="demo-note">Try Talk to Hope, text input, Activity, and Settings. All replies and progress are simulated; this page never accesses your microphone.</p></aside></main>
<script>const $=id=>document.getElementById(id);let active=false,muted=false,timers=[],busy=false;function stopTimers(){timers.forEach(clearTimeout);timers=[];busy=false}function status(title,sub){$('voice-status').textContent=title;$('voice-sub').textContent=sub}function show(view){document.querySelectorAll('.view').forEach(el=>el.hidden=el.id!==view+'-view');document.querySelectorAll('.tab').forEach(el=>el.setAttribute('aria-selected',String(el.dataset.view===view)));$('dock').hidden=view==='settings'}document.querySelectorAll('.tab').forEach(btn=>btn.onclick=()=>show(btn.dataset.view));$('account').onclick=()=>show('settings');function add(role,text){let a=document.createElement('article');a.className='message '+role;let who=document.createElement('div');who.className='who';who.textContent=role==='user'?'You':'Hope';let p=document.createElement('p');p.textContent=text;a.append(who,p);$('messages').append(a);$('suggestion').hidden=true;$('talk-view').scrollTop=$('talk-view').scrollHeight}function reply(text){if(busy)return;busy=true;add('user',text);status('Finding a starting point…','Working · sample response');$('task-title').textContent='Putting together a suggestion';$('task-detail').textContent='Hope is preparing a response for your conversation.';$('task-status').textContent='In progress · simulated';timers.push(setTimeout(()=>{add('assistant','Keep it simple: choose a nearby route, leave your headphones at home, and give yourself thirty minutes with no schedule. Want to plan the rest of the day?');status(active&&!muted?'Listening':muted?'Microphone muted':'Ready when you are',active?'Demo session · no microphone access':'Microphone off · simulated audio');$('task-title').textContent='Suggestion ready';$('task-detail').textContent='Hope’s reply is in your conversation.';$('task-status').textContent='Completed · simulated';busy=false},1400))}function begin(){active=true;muted=false;$('start').textContent='Mute';$('start').setAttribute('aria-pressed','false');$('end').hidden=false;status('Listening','Demo session · no microphone access');timers.push(setTimeout(()=>{if(active&&!muted)reply('Let’s start with a morning walk.')},2000))}$('start').onclick=()=>{if(!active){begin();return}muted=!muted;$('start').textContent=muted?'Unmute':'Mute';$('start').setAttribute('aria-pressed',String(muted));status(muted?'Microphone muted':'Listening','Demo session · no microphone access')};$('end').onclick=()=>{stopTimers();active=false;muted=false;$('start').textContent='Talk to Hope';$('start').removeAttribute('aria-pressed');$('end').hidden=true;status('Conversation ended','Microphone off · sample transcript kept');$('task-status').textContent='Stopped'};function textMode(on){$('voice-controls').hidden=on;$('composer').hidden=!on;$('voice-back').hidden=!on;if(on){muted=active;status('Type to Hope','Voice input off');$('input').focus()}else status(active?'Microphone muted':'Ready when you are','Microphone off · simulated audio')}$('text').onclick=()=>textMode(true);$('voice-back').onclick=()=>{textMode(false);if(active)$('start').textContent='Unmute'};$('composer').onsubmit=e=>{e.preventDefault();const val=$('input').value.trim();if(val&&!busy){$('input').value='';reply(val)}};$('suggestion').onclick=()=>reply('Let’s start with a morning walk.');function dark(on){$('phone').classList.toggle('dark',on);$('dark-switch').checked=on;$('theme').textContent=on?'Preview light appearance':'Preview dark appearance'}$('theme').onclick=()=>dark(!$('phone').classList.contains('dark'));$('dark-switch').onchange=e=>dark(e.target.checked);$('voice').onchange=e=>$('settings-note').textContent='Demo voice set to '+e.target.value+'.';$('signout').onclick=()=>{$('end').click();$('settings-note').textContent='Demo signed out. No real account was changed. Reload to reset the sample.'};document.querySelector('.tabs').addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(e.key))return;e.preventDefault();let tabs=[...document.querySelectorAll('.tab')],i=tabs.indexOf(document.activeElement);i=e.key==='Home'?0:e.key==='End'?tabs.length-1:(i+(e.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length;tabs[i].focus();tabs[i].click()});</script></body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hope — Three new directions</title><style>*{box-sizing:border-box}body{margin:0;background:#f4f6f7;color:#20313b;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1360px;margin:auto;padding:54px 40px}header{display:flex;align-items:start;justify-content:space-between;gap:30px;margin-bottom:38px}h1{font-size:48px;letter-spacing:-.035em;margin:12px 0 16px;line-height:1.08;font-weight:600}p{line-height:1.6;margin:0;max-width:64ch;color:#51616b}.wordmark{font-size:19px;font-weight:700}.badge{border:1px solid #c3cdd3;padding:10px 16px;border-radius:8px;font-size:13px;white-space:nowrap}.options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28px}.option{text-decoration:none;color:inherit;display:block}.preview{background:#e5ecf2;aspect-ratio:410/830;overflow:hidden;border-radius:16px;margin-bottom:22px}.preview img{width:100%;height:100%;object-fit:cover;display:block}.option h2{font-size:25px;letter-spacing:-.02em;margin:0 0 10px}.option p{font-size:15px;line-height:1.6}.open{display:inline-flex;align-items:center;gap:14px;min-height:44px;font-weight:600;font-size:14px;margin-top:15px;color:#235fc0}.option:focus-visible{outline:3px solid #235fc0;outline-offset:8px}.recommendation{margin-top:42px;padding-top:24px;border-top:1px solid #cbd5da;display:flex;gap:38px;align-items:baseline}.recommendation strong{font-size:18px;min-width:190px}.footer{font-size:13px;margin-top:28px;color:#51616b}@media(min-width:1100px){.preview{max-height:630px;aspect-ratio:390/804;margin-left:auto;margin-right:auto}}@media(max-width:760px){main{padding:30px 20px}header{display:block}h1{font-size:36px}.badge{display:inline-block;margin-top:20px}.options{grid-template-columns:1fr;gap:38px}.preview{max-width:390px;margin-left:auto;margin-right:auto}.recommendation{display:block}.recommendation p{margin-top:12px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}</style></head><body><main><header><div><div class="wordmark">Hope / design exploration</div><h1>Three ways to feel at home.</h1><p>Three different interaction models, not three color swaps. Each includes a new app icon and an interactive iPhone-sized demonstration.</p></div><div class="badge">HTML prototypes · no live services</div></header><section class="options" aria-label="Three design options"><a class="option" href="clear.html"><div class="preview"><img src="clear-phone.png" alt="Clear concept: a bright native-feeling Hope interface" width="390" height="804"></div><h2>Clear</h2><p>Bright, familiar, and practical. A clear starting point, compact voice controls, and recent conversations.</p><span class="open">Explore Clear <span aria-hidden="true">↗</span></span></a><a class="option" href="signal.html"><div class="preview"><img src="signal-phone.png" alt="Signal concept: a dark focused voice interface" width="390" height="804"></div><h2>Signal</h2><p>Voice comes first. A low-light audio interface with precise feedback and a waveform instead of an LED matrix.</p><span class="open">Explore Signal <span aria-hidden="true">↗</span></span></a><a class="option" href="gather.html"><div class="preview"><img src="gather-phone.png" alt="Gather concept: a readable green conversation interface" width="390" height="804"></div><h2>Gather</h2><p>The conversation is the canvas. Readable replies, context always visible, and voice one tap away.</p><span class="open">Explore Gather <span aria-hidden="true">↗</span></span></a></section><section class="recommendation"><strong>My pick: Clear.</strong><p>It makes the biggest usability improvement with the least learning. Pick Signal if Hope is mostly a hands-free voice app; pick Gather if you regularly read and type as well as talk. The icon can be chosen independently.</p></section><p class="footer">All conversation data, audio states, progress, and account actions are simulated. Nothing changes the installed iOS app, server, or microphone permissions.</p></main></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hope — A softer direction</title><style>*{box-sizing:border-box}body{margin:0;background:linear-gradient(120deg,#f2e8e4,#eee7f4 45%,#ececf7);color:#342a3d;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1360px;margin:auto;padding:50px 40px}header{max-width:750px;margin-bottom:38px}.brand{font-size:17px;font-weight:600}h1{font-weight:450;font-size:49px;letter-spacing:-.035em;line-height:1.1;margin:18px 0}h1 em{font-family:Georgia,serif;font-weight:400;color:#9464aa}p{font-size:16px;line-height:1.7;color:#685b73;margin:0}.options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:34px}.option{display:block;text-decoration:none;color:inherit}.preview{height:650px;display:flex;justify-content:center;overflow:hidden;margin-bottom:23px}.preview img{height:100%;width:auto;max-width:100%;object-fit:contain}.icon-row{display:flex;gap:10px;align-items:center;margin:14px 0 4px;font-size:12px;color:#685b73}.icon-row img{width:38px;height:38px;border-radius:9px}.option h2{font-weight:500;font-size:27px;letter-spacing:-.02em;margin:0 0 11px}.option p{font-size:14px}.open{display:inline-flex;align-items:center;min-height:44px;color:#815099;font-size:14px;font-weight:600;margin-top:12px}.option:focus-visible{outline:3px solid #815099;outline-offset:7px}.note{display:flex;gap:35px;border-top:1px solid #d7c9e0;margin-top:35px;padding-top:24px}.note strong{font-size:17px;font-weight:500;white-space:nowrap}.note p{font-size:14px;max-width:75ch}.footer{font-size:12px;margin-top:25px}@media(max-width:850px){main{padding:30px 20px}.options{gap:20px}.preview{height:510px}h1{font-size:40px}}@media(max-width:680px){.options{grid-template-columns:1fr;gap:40px}.preview{height:690px;max-width:390px;margin:auto auto 22px}h1{font-size:36px}.note{display:block}.note p{margin-top:10px}.option p{max-width:45ch}}</style></head><body><main><header><div class="brand">hope / a new design exploration</div><h1>A softer space.<br><em>Three fresh possibilities.</em></h1><p>Inspired by your reference: luminous white, pearlescent color, sculptural voice visuals, and lighter controls. Three new interfaces—not variations of the first set.</p></header><section class="options" aria-label="New design directions"><a class="option" href="opal.html"><div class="preview"><img src="opal-phone.png" alt="Opal: luminous white home with a pearlescent sphere"></div><h2>Opal</h2><div class="icon-row"><img src="opal-icon.svg" alt="New Opal app icon">New icon direction</div><p>A luminous home, a reflective pearl, and a personal welcome. The closest to your inspiration, with voice and text sharing one floating composer.</p><span class="open">Try Opal ↗</span></a><a class="option" href="ribbon.html"><div class="preview"><img src="ribbon-phone.png" alt="Ribbon: translucent sculptural waveform and focused voice controls"></div><h2>Ribbon</h2><div class="icon-row"><img src="ribbon-icon.svg" alt="New Ribbon app icon">New icon direction</div><p>A flowing pastel ribbon turns voice into a sculptural listening space. Focused, spacious, and designed around speaking rather than browsing.</p><span class="open">Try Ribbon ↗</span></a><a class="option" href="prism.html"><div class="preview"><img src="prism-phone.png" alt="Prism: a soft conversation interface with pearlescent sculptural forms"></div><h2>Prism</h2><div class="icon-row"><img src="prism-icon.svg" alt="New Prism app icon">New icon direction</div><p>A quiet conversation that unfolds into useful answers. A compact pearlescent identity, readable replies, and a floating voice-and-text composer.</p><span class="open">Try Prism ↗</span></a></section><section class="note"><strong>My pick: Opal.</strong><p>It captures the reference’s personality most directly. Ribbon is the strongest voice-first option; Prism gives the most space to reading replies. Each demo includes a different replacement app icon, shown large and small.</p></section><p class="footer">Interactive HTML concepts only. All replies and voice states are simulated. No microphone access, live account actions, or changes to the installed app.</p></main></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Hope: two pearlescent circular speech forms"><defs><linearGradient id="pearl" x1="0" y1="0" x2="1" y2="1"><stop stop-color="white"/><stop offset=".4" stop-color="#f2ddec"/><stop offset=".7" stop-color="#dbe8f8"/><stop offset="1" stop-color="#cbb7eb"/></linearGradient></defs><rect width="100" height="100" rx="23" fill="#9b7fbe"/><circle cx="40" cy="42" r="25" fill="url(#pearl)"/><path d="m23 56-3 16 16-9" fill="url(#pearl)"/><circle cx="63" cy="58" r="23" fill="url(#pearl)" stroke="#9b7fbe" stroke-width="2"/><path d="m70 77 13 9-2-17" fill="url(#pearl)"/><path d="M21 39C23 28 31 22 42 21M46 57C46 46 53 39 63 38" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".9" fill="none"/><path d="M55 23C69 34 65 53 52 63" stroke="#b5a2d1" fill="none" opacity=".7"/></svg>

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Hope · Opal / Pink Persona</title><style>
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#302b40;background:#eeeaf7;--ink:#302b40;--muted:#6e657c;--violet:#7453ab;--pink:#f8edf2;--lilac:#f1edfa;--ice:#eaf4fb}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer;color:inherit;border:0;min-width:44px;min-height:44px;background:none}button:hover{background-color:#f2eef8}button:active{transform:scale(.97)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #8763bd;outline-offset:4px}a{color:inherit}button,select{transition:background .18s,transform .18s}[hidden]{display:none!important}.layout{min-height:100vh;max-width:1150px;margin:auto;display:grid;grid-template-columns:1fr 414px;gap:115px;align-items:center;padding:58px 48px}.presentation{max-width:430px}.back-link{display:inline-flex;min-height:44px;align-items:center;gap:10px;text-decoration:none;font-size:13px;color:#675d79;margin-bottom:37px}.concept{font-size:13px;color:#675d79;display:flex;gap:10px;align-items:center}.concept:before{content:"";height:7px;width:7px;border-radius:50%;background:#a281c2}.presentation h1{font-size:82px;font-weight:400;letter-spacing:-.04em;line-height:1;margin:19px 0 23px}.lead{font-size:23px;letter-spacing:-.025em;line-height:1.5;max-width:25ch;margin:0 0 35px}.notes{padding:0;list-style:none;margin:0}.notes li{padding:18px 0;border-top:1px solid #d8d0e6;font-size:14px;line-height:1.6;color:#675d79}.notes strong{display:block;font-weight:550;font-size:15px;color:#302b40;margin-bottom:3px}.identity{display:flex;align-items:center;gap:24px;margin-top:29px}.icon-large{width:100px;height:100px;filter:drop-shadow(0 8px 12px #64508118)}.icon-small{width:32px;height:32px}.identity p{font-size:12px;color:#675d79;line-height:1.6;margin:8px 0 0}.demo-note{font-size:12px;line-height:1.75;color:#675d79;margin-top:28px;max-width:43ch}.device{width:414px;padding:11px;background:#faf9fd;border-radius:53px;box-shadow:0 28px 65px #5e4f7c20,0 0 0 1px #ffffffaa,inset 0 0 0 2px #e8e3ed}.screen{width:390px;height:800px;border-radius:43px;background:#fff;overflow:hidden;position:relative;isolation:isolate;box-shadow:0 0 0 1px #ded9e6}.statusbar{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 27px;font-size:14px;font-weight:600;position:relative}.island{width:110px;height:29px;border-radius:30px;background:#201d26;position:absolute;left:50%;top:10px;transform:translateX(-50%)}.status-icons{display:flex;gap:5px;align-items:center}.status-icons svg{width:15px;height:15px}.status-icons .battery{width:23px}.app-header{height:66px;display:flex;align-items:center;padding:0 21px;gap:8px}.brand{display:flex;align-items:center;gap:8px;margin-right:auto;font-size:21px;font-weight:530;letter-spacing:-.03em}.brand svg{width:28px;height:28px}.round-button{display:grid;place-items:center;border-radius:50%;width:44px;height:44px}.round-button svg,.glyph{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round}.app-header .round-button{color:#7b708b}.content{height:552px;overflow-y:auto;scrollbar-width:thin;padding:0 27px}.home{text-align:center;padding-top:26px}.greeting{font-size:32px;line-height:1.24;letter-spacing:-.035em;font-weight:400;margin:0}.greeting em{font-family:Georgia,serif;font-weight:400}.welcome-copy{color:var(--muted);font-size:13px;margin:15px 0 0;line-height:1.6}.pearl-wrap{width:240px;height:210px;margin:8px auto 0;position:relative}.pearl{width:100%;height:100%;overflow:visible}.pearl-shadow{position:absolute;width:120px;height:8px;bottom:6px;left:calc(50% - 60px);background:#c9c0df;opacity:.22;filter:blur(8px);border-radius:50%}.voice-start{display:flex;gap:10px;align-items:center;justify-content:center;margin:5px auto 0;font-size:14px;font-weight:500;padding:0 17px 0 7px;border-radius:40px;background:#faf7fc;min-height:52px}.mic-halo{width:39px;height:39px;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle at 35% 20%,#fff 0,#eee1f5 60%,#eadff6 100%);box-shadow:0 0 0 5px #f8f2fb;color:#775393}.mic-halo svg{width:19px}.prompts{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:23px}.prompt{font-size:12px;border-radius:30px;padding:0 15px;min-height:44px;color:#63586f}.prompt:nth-child(1){background:var(--pink)}.prompt:nth-child(2){background:var(--lilac)}.prompt:nth-child(3){background:var(--ice)}.composer-zone{position:absolute;bottom:27px;left:19px;right:19px;z-index:2}.composer{display:flex;align-items:center;height:64px;padding:8px 9px 8px 19px;border-radius:34px;background:#fff;box-shadow:0 5px 22px #69587814,0 1px 4px #69587812;gap:5px}.composer input{min-width:0;flex:1;border:0;outline:0;background:none;font-size:14px;color:var(--ink);height:44px}.composer input::placeholder{color:#756b81}.composer input:focus-visible{outline:0}.composer:focus-within{box-Line truncated
@media(max-width:1050px){.layout{gap:55px;padding:40px 30px}.presentation h1{font-size:70px}.lead{font-size:21px}}@media(max-width:780px){.layout{display:flex;flex-direction:column;padding:30px 20px;gap:35px}.presentation{width:100%;max-width:414px}.presentation h1{font-size:56px;margin:12px 0 14px}.back-link{margin-bottom:20px}.lead{font-size:19px;margin-bottom:20px;max-width:33ch}.notes{display:none}.identity{margin-top:20px}.icon-large{width:64px;height:64px}.demo-note{margin-top:18px}.device-wrap{max-width:100%}}@media(max-width:440px){body{background:#fff}.layout{padding:0;gap:0}.presentation,.device-caption{display:none}.device{width:100vw;padding:0;border-radius:0;background:#fff;box-shadow:none}.screen{width:100%;height:100svh;min-height:760px;border-radius:0;box-shadow:none}.content{height:calc(100% - 248px)}.home{padding-top:20px}.composer-zone{bottom:max(27px,env(safe-area-inset-bottom))}.statusbar{padding-left:29px;padding-right:29px}.home-indicator{bottom:8px}.live{bottom:21px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
:root{background:#f6eaf0;--violet:#a64270;--pink:#faedf3;--lilac:#f8eef4;--ice:#f7eff4}.layout{background:radial-gradient(ellipse at 80% 20%,#fff9,transparent 55%)}.screen{background:#fffdfd}.greeting em,.live-state,.back,.thread-title{color:#a64270}.concept:before{background:#d17ba0}.prompt{background:#f9edf3!important;color:#7c3e59}.mic-button,.send-button{background:#f4dce8!important;color:#984269}.voice-start{background:#fcf1f6}.mic-halo{background:radial-gradient(circle,#f7e0eb,#fff5fa)}.state-dot{background:#c75e8d}.live-controls .listen{background:#f6deea!important}.live{background:#fffdfd!important}.persona-stage{height:210px;margin:18px auto 15px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px}.persona-panel{width:184px;height:184px;border-radius:16px;background:#fff2f7;border:1px solid #f2d9e5;overflow:hidden;flex-shrink:0}.persona{display:block;width:100%;height:100%}.persona-caption{font-size:11px;color:#975875}.live-persona{width:178px;height:178px;margin:18px auto 21px}.live h2{margin-top:12px}.preview-states{display:flex;gap:6px;flex-wrap:wrap;margin:20px 0}.preview-states button{border:1px solid #e6bfd0;border-radius:8px;background:#fff9fc;padding:0 12px;min-height:44px;font-size:12px;color:#8d3c60}.preview-states button[aria-pressed=true]{background:#a64270;border-color:#a64270;color:#fff}.demo-note{margin-top:18px}.persona-note{font-size:12px;line-height:1.6;color:#806172}.presentation h1{font-size:72px}.live-log{min-height:68px}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline-color:#a64270}@media(max-width:440px){.persona-stage{margin-top:20px;margin-bottom:15px}.live-persona{width:172px;height:172px}}@media(prefers-reduced-motion:reduce){.persona{transition:none}}
</style></head><body>
<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs>
<linearGradient id="opal-fill" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fef7fc" stop-opacity=".75"/><stop offset=".26" stop-color="#eae4ff" stop-opacity=".35"/><stop offset=".57" stop-color="#fff" stop-opacity=".05"/><stop offset=".79" stop-color="#d8edf9" stop-opacity=".45"/><stop offset="1" stop-color="#e4d7f4" stop-opacity=".8"/></linearGradient>
<radialGradient id="pearl-core" cx="40%" cy="30%" r="74%"><stop stop-color="white" stop-opacity=".05"/><stop offset=".54" stop-color="#fff" stop-opacity=".02"/><stop offset=".79" stop-color="#e2daf5" stop-opacity=".3"/><stop offset=".92" stop-color="#e2d1ed" stop-opacity=".65"/><stop offset=".98" stop-color="#faf6fd"/><stop offset="1" stop-color="#bdb2d8" stop-opacity=".6"/></radialGradient>
<linearGradient id="rim" x1="0" y1="1" x2="1" y2="0"><stop stop-color="#e7c9e7"/><stop offset=".24" stop-color="#fff"/><stop offset=".42" stop-color="#c3d4ed"/><stop offset=".68" stop-color="#fff"/><stop offset=".86" stop-color="#dcc6ea"/><stop offset="1" stop-color="#f8e8f7"/></linearGradient>
<linearGradient id="pink-reflection" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#e4c1dd" stop-opacity=".8"/><stop offset=".35" stop-color="#f8e6f1" stop-opacity=".6"/><stop offset=".7" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#bcade1" stop-opacity=".7"/></linearGradient>
<linearGradient id="blue-reflection" x1="1" y1="0" x2="0" y2="1"><stop stop-color="#d0e9f8" stop-opacity=".1"/><stop offset=".5" stop-color="#b7d9f0" stop-opacity=".6"/><stop offset=".78" stop-color="#e2d3f5" stop-opacity=".3"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#fff" stop-opacity=".95"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><filter id="soft"><feGaussianBlur stdDeviation="2.7"/></filter><filter id="soft-light"><feGaussianBlur stdDeviation=".6"/></filter>
<symbol id="pearl-art" viewBox="0 0 280 280"><circle cx="140" cy="136" r="110" fill="url(#opal-fill)"/><circle cx="140" cy="136" r="110" fill="url(#pearl-core)"/>
<path d="M62 61C91 25 142 16 182 37C132 26 106 52 99 93C88 143 74 166 45 175C33 135 36 91 62 61Z" fill="url(#pink-reflection)" filter="url(#soft)"/>
<path d="M205 58C240 91 254 152 229 196C211 226 171 247 128 244C194 227 179 189 201 165C226 138 239 105 205 58Z" fill="url(#blue-reflection)"/>
<path d="M50 92C76 32 156 22 205 64C144 23 106 54 99 104C90 155 59 189 45 166C62 175 86 139 89 99C91 62 117 36 150 36C111 24 68 46 50 92Z" fill="url(#shine)" opacity=".87"/>
<path d="M47 163C70 182 105 134 126 112C152 85 181 84 213 102C194 95 174 100 155 114C120 140 96 189 68 190C58 187 52 178 47 163Z" fill="url(#pink-reflection)" opacity=".62" filter="url(#soft)"/>
<path d="M64 187C110 218 174 121 218 141C173 136 142 222 101 224C82 221 71 207 64 187Z" fill="url(#blue-reflection)" opacity=".63"/>
<path d="M84 223C142 250 196 221 223 181" fill="none" stroke="white" stroke-width="3" opacity=".8" filter="url(#soft-light)"/>
<circle cx="140" cy="136" r="110" fill="none" stroke="url(#rim)" stroke-width="1.6"/><circle cx="140" cy="136" r="106.5" fill="none" stroke="white" stroke-width=".8" opacity=".8"/>
<path d="M40 106C47 75 69 49 95 39" fill="none" stroke="white" stroke-width="4" stroke-linecap="round" opacity=".85"/><path d="M167 31C209 40 239 73 247 111" fill="none" stroke="#f1d9ee" stroke-width="2" opacity=".8"/><path d="M240 173C228 213 194 240 153 245" fill="none" stroke="#d7daf6" stroke-width="2.2"/><ellipse cx="77" cy="69" rx="6" ry="14" transform="rotate(40 77 69)" fill="#fff" opacity=".9" filter="url(#soft)"/>
</symbol>
<linearGradient id="icon-pearl" x1="0" y1="0" x2="1" y2="1"><stop stop-color="white"/><stop offset=".4" stop-color="#f2ddec"/><stop offset=".7" stop-color="#dbe8f8"/><stop offset="1" stop-color="#cbb7eb"/></linearGradient>
<symbol id="app-icon" viewBox="0 0 100 100"><rect width="100" height="100" rx="23" fill="#9b7fbe"/><circle cx="40" cy="42" r="25" fill="url(#icon-pearl)"/><path d="m23 56-3 16 16-9" fill="url(#icon-pearl)"/><circle cx="63" cy="58" r="23" fill="url(#icon-pearl)" stroke="#9b7fbe" stroke-width="2"/><path d="m70 77 13 9-2-17" fill="url(#icon-pearl)"/><path d="M21 39C23 28 31 22 42 21M46 57C46 46 53 39 63 38" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".9" fill="none"/><path d="M55 23C69 34 65 53 52 63" stroke="#b5a2d1" fill="none" opacity=".7"/></symbol>
<symbol id="brand-mark" viewBox="0 0 32 32"><circle cx="13" cy="13" r="9" fill="#d9c9ec"/><path d="m6 18-1 6 6-4" fill="#d9c9ec"/><circle cx="21" cy="21" r="8" fill="#eddef0" stroke="white" stroke-width="1.5"/><path d="m24 26 5 4-1-6" fill="#eddef0"/></symbol>
<symbol id="mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11v2a7 7 0 0 0 14 0v-2M12 20v2M8 22h8"/></symbol><symbol id="mute" viewBox="0 0 24 24"><path d="m3 3 18 18M9 9v3a3 3 0 0 0 5 2M9 5a3 3 0 0 1 6 0v6M5 11v2a7 7 0 0 0 12 5M19 11v2M12 20v2"/></symbol><symbol id="history" viewBox="0 0 24 24"><path d="M4 8a9 9 0 1 1-1 7M4 3v5h5M12 7v5l3 2"/></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3" fill="white"/><circle cx="16" cy="17" r="3" fill="white"/></symbol><symbol id="arrow" viewBox="0 0 24 24"><path d="M12 19V5m-6 6 6-6 6 6"/></symbol><symbol id="back" viewBox="0 0 24 24"><path d="m14 6-6 6 6 6"/></symbol><symbol id="chevron" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol><symbol id="chat" viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 1-8 8 9 9 0 0 1-4-1l-5 3 1-6a8 8 0 1 1 16-4Z"/></symbol><symbol id="end" viewBox="0 0 24 24"><path d="M5 16v-4h14v4M3 12c4-5 14-5 18 0"/></symbol>
</defs></svg>
<main class="layout"><aside class="presentation" aria-label="Design direction"><a class="back-link" href="new-index.html">← Explore the new directions</a><div class="concept">Hope · A new direction</div><h1>OPAL<span style="display:block;font-size:27px;margin-top:15px;letter-spacing:-.02em;color:#a64270">Pink persona</span></h1><p class="lead">A little space to think.<br>The familiar agent, in pink.</p><ul class="notes"><li><strong>The same presence as the web.</strong>A square, 21 × 21 reactive dot matrix replaces the pearl. Soft pink at rest; a brighter bloom when Hope speaks.</li><li><strong>The conversation has room.</strong>A luminous white canvas. A personal greeting. Everyday prompts that don’t compete for attention.</li><li><strong>Always within reach.</strong>One floating composer for a thought in text or a conversation out loud. Voice opens into a focused, uncluttered space.</li></ul><div class="identity"><svg class="icon-large" role="img" aria-label="Hope icon, two overlapping pearlescent circular speech forms on violet"><use href="#app-icon"/></svg><div><svg class="icon-small" role="img" aria-label="Hope icon at 32 pixels"><use href="#app-icon"/></svg><p>Opal’s icon stays separate.<br>This preview changes the in-app persona.</p></div></div><div class="preview-states" aria-label="Preview persona states"><button data-persona-state="idle" aria-pressed="true">Ready</button><button data-persona-state="listening" aria-pressed="false">Listening</button><button data-persona-state="working" aria-pressed="false">Working</button><button data-persona-state="speaking" aria-pressed="false">Speaking</button><button data-persona-state="muted" aria-pressed="false">Muted</button></div><p class="persona-note">Preview a state above, or tap Let’s talk to see the full simulated conversation. Pink intensity and dot size follow the same reactive bloom as the web UI.</p><p class="demo-note">Interactive design study. All conversations are simulated and stay on this page. No microphone, audio, network, or account access.</p></aside>
<section class="device-wrap" aria-label="Interactive Hope phone"><div class="device"><div class="screen" id="screen"><div class="statusbar" aria-hidden="true"><span>9:41</span><span class="island"></span><div class="status-icons"><svg viewBox="0 0 20 20" fill="currentColor"><rect x="1" y="12" width="3" height="5" rx="1"/><rect x="6" y="9" width="3" height="8" rx="1"/><rect x="11" y="6" width="3" height="11" rx="1"/><rect x="16" y="3" width="3" height="14" rx="1"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9a14 14 0 0 1 18 0M6 12a9 9 0 0 1 12 0M9 15a4 4 0 0 1 6 0"/><circle cx="12" cy="18" r="1" fill="currentColor"/></svg><svg class="battery" viewBox="0 0 25 16" fill="none" stroke="currentColor"><rect x="1" y="2" width="20" height="12" rx="3"/><rect x="3" y="4" width="16" height="8" rx="1" fill="currentColor"/><path d="M23 6v4" stroke-width="2"/></svg></div></div>
<header class="app-header"><div class="brand"><svg aria-hidden="true"><use href="#brand-mark"/></svg>hope</div><button class="round-button" id="history-button" aria-label="Open history"><svg class="glyph"><use href="#history"/></svg></button><button class="round-button" id="settings-button" aria-label="Open settings"><svg class="glyph"><use href="#settings"/></svg></button></header>
<div class="content" id="content" tabindex="-1"><section class="home"><h2 class="greeting">A little room for you,<br><em>Shane.</em></h2><p class="welcome-copy">No perfect words needed. Start anywhere.</p><div class="persona-stage"><div class="persona-panel"><canvas class="persona" width="440" height="440" aria-hidden="true"></canvas></div><span class="persona-caption" id="home-persona-caption">Ready when you are</span></div><button class="voice-start" id="start-voice"><span class="mic-halo"><svg class="glyph"><use href="#mic"/></svg></span>Let’s talk</button><div class="prompts"><button class="prompt" data-prompt="day">Talk through my day</button><button class="prompt" data-prompt="idea">Explore an idea</button><button class="prompt" data-prompt="plan">Make a plan</button></div></section></div>
<div class="composer-zone"><form class="composer" id="composer"><label for="message" class="sr-only">Message Hope</label><input id="message" placeholder="What’s on your mind?" maxlength="2000" autocomplete="off"><button type="submit" class="send-button" id="send-message" aria-label="Send message" hidden><svg class="glyph"><use href="#arrow"/></svg></button><button type="button" class="mic-button" id="composer-mic" aria-label="Start voice conversation"><svg class="glyph"><use href="#mic"/></svg></button></form><p class="simulation">Local demo · Your microphone stays off</p></div>
<section class="live" id="live" aria-label="Simulated voice conversation" hidden><div class="live-top"><strong>With Hope</strong><span>Simulated voice</span></div><div class="persona-panel live-persona"><canvas class="persona" width="440" height="440" aria-hidden="true"></canvas></div><h2 id="live-heading">I’m here. Take your time.</h2><div class="live-state" role="status"><span class="state-dot"></span><span id="voice-state">Listening</span></div><div class="elapsed" id="elapsed">00:00</div><div class="live-log" id="live-log" role="log" aria-label="Voice transcript" aria-live="polite"></div><div class="live-controls"><div class="control-group"><button id="mute-button" aria-label="Mute simulated microphone" aria-pressed="false"><svg class="glyph"><use href="#mute"/></svg></button><span id="mute-label">Mute</span></div><div class="control-group"><button class="listen" id="voice-turn" aria-label="Try a simulated voice turn"><svg class="glyph"><use href="#mic"/></svg></button><span>Speak again</span></div><div class="control-group"><button class="end" id="end-call" aria-label="End conversation"><svg class="glyph"><use href="#end"/></svg></button><span>End</span></div></div><p class="live-note">No audio is recorded or played</p></section><div class="home-indicator" aria-hidden="true"></div><div id="announce" class="sr-only" role="status"></div></div></div><p class="device-caption">OPAL · A luminous space for conversation</p></section></main>
<script>
(()=>{'use strict';const $=id=>document.getElementById(id),content=$('content'),homeMarkup=content.innerHTML,live=$('live'),field=$('message');let page='home',voice='Ava',spoken=true,muted=false,interval=null,timers=[],seconds=0,active=null;const history=[{title:'A little breathing room',when:'Today · 9:12 am',messages:[['You','Today feels a little full. I don’t know where to start.'],['Hope','Let’s make it smaller. What’s one thing that needs your attention, and one thing that can wait?']]},{title:'An idea for the weekend',when:'Yesterday · 6:40 pm',messages:[['You','I’d love to spend a day by the sea.'],['Hope','A morning train, a long walk, and lunch somewhere unhurried. Want to start with a place or a date?']]}];const prompts={day:['Talk through my day','What’s been taking up the most space in your day? We can start there.'],idea:['Explore an idea','Tell me the rough version. It doesn’t need to make sense yet.'],plan:['Make a plan','What would you like to make room for? We can find a small first step.']};const icon=name=>'<svg class="glyph" aria-hidden="true"><use href="#'+name+'"/></svg>';const announce=text=>$('announce').textContent=text;function cancel(){timers.forEach(clearTimeout);timers=[]}function schedule(fn,time){timers.push(setTimeout(fn,time))}function show(markup,next){page=next;content.innerHTML=markup;content.scrollTop=0;content.focus({preventScroll:true})}function home(){active=null;show(homeMarkup,'home');field.placeholder='What’s on your mind?'}function back(){return '<button class="back" id="back-home">'+icon('back')+'Back to your space</button>'}function message(target,role,text){const el=document.createElement('div');el.className='message '+(role==='You'?'you':'hope');const label=document.createElement('small');label.textContent=role;const p=document.createElement('p');p.textContent=text;el.append(label,p);target.append(el)}function renderThread(item){active=item;show(back()+'<h2 class="thread-title" id="thread-title"></h2><div class="transcript" id="transcript" role="log" aria-live="polite" aria-label="Conversation transcript"></div>','thread');$('thread-title').textContent=item.title;item.messages.forEach(m=>message($('transcript'),...m));field.placeholder='Keep the thought going…'}function newThread(title,first,reply){const item={title,when:'Just now · Demo',messages:[['You',first],['Hope',reply]]};history.unshift(item);renderThread(item)}function showHistory(){show(back()+'<h2 class="page-heading">Your conversations</h2><p class="page-description">Thoughts you can come back to.<br>Sample threads and your local demo conversations.</p><div class="history-label">Recently</div><div id="history-list"></div>','history');history.forEach((item,index)=>{const button=document.createElement('button');button.className='history-row';button.dataset.thread=index;button.innerHTML='<span class="history-symbol">'+icon('chat')+'</span><span><strong></strong><small></small></span>'+icon('chevron');button.querySelector('strong').textContent=item.title;button.querySelector('small').textContent=item.when;$('history-list').append(button)})}function settings(){show(back()+'<h2 class="page-heading">Make yourself at home.</h2><p class="page-description">A few small choices for your conversations.</p><div class="settings-group"><h3>Conversation</h3><label class="setting"><span>Hope’s voice<small>A preference for this demo</small></span><select id="voice-choice" aria-label="Hope voice"><option>Ava</option><option>Emma</option><option>Andrew</option></select></label><label class="setting"><span>Spoken replies<small>No audio plays in this demo</small></span><input type="checkbox" id="spoken"></label></div><div class="settings-group"><h3>Privacy</h3><div class="setting"><span>Just between us<small>Your text stays on this page.<br>No account or microphone is connected.</small></span>'+icon('chat')+'</div></div><button class="settings-save" id="save-settings">Save preferences</button><p class="saved-note" id="saved-note" role="status"></p><p class="settings-footer">Hope · OPAL design study<br>Preferences and conversations reset when you reload.</p>','settings');$('voice-choice').value=voice;$('spoken').checked=spoken}function state(text,heading){$('voice-state').textContent=text;if(heading)$('live-heading').textContent=heading;live.classList.toggle('listening',text==='Listening');announce(text)}function turn(){cancel();if(muted)return;state('Listening','I’m here. Take your time.');$('voice-turn').disabled=true;schedule(()=>{const text='Help me make a little space in my day.';message($('live-log'),'You',text);active.messages.push(['You',text]);state('Thinking','Let’s find a little room.');},1800);schedule(()=>{const text='Choose one thing that can wait. Then give yourself ten minutes with nothing to solve. What would feel good in that little space?';message($('live-log'),'Hope',text);active.messages.push(['Hope',text]);$('live-log').scrollTop=$('live-log').scrollHeight;state(spoken?'HopLine truncated
$('history-button').onclick=showHistory;$('settings-button').onclick=settings;$('composer-mic').onclick=startVoice;content.onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.id==='back-home')home();if(b.id==='start-voice')startVoice();if(b.dataset.prompt){const p=prompts[b.dataset.prompt];newThread(p[0],p[0],p[1]);field.focus()}if(b.dataset.thread!==undefined)renderThread(history[Number(b.dataset.thread)]);if(b.id==='save-settings'){voice=$('voice-choice').value;spoken=$('spoken').checked;$('saved-note').textContent='Preferences saved for this demo.';announce('Preferences saved')}};field.oninput=()=>{$('send-message').hidden=!field.value.trim()};$('composer').onsubmit=e=>{e.preventDefault();const text=field.value.trim();if(!text)return;const reply='Let’s take it one thought at a time. What feels most important about that to you?';if(page!=='thread')newThread('A thought to talk through',text,reply);else{active.messages.push(['You',text],['Hope',reply]);message($('transcript'),'You',text);message($('transcript'),'Hope',reply);content.scrollTop=content.scrollHeight}field.value='';$('send-message').hidden=true;field.focus();announce('Local message added. Hope’s reply is a prewritten example.')};$('mute-button').onclick=()=>{muted=!muted;cancel();$('mute-button').setAttribute('aria-pressed',String(muted));$('mute-button').setAttribute('aria-label',muted?'Unmute simulated microphone':'Mute simulated microphone');$('mute-label').textContent=muted?'Unmute':'Mute';$('voice-turn').disabled=muted;state(muted?'Microphone muted · simulation paused':'Listening',muted?'A quiet moment.':'Ready when you are.');if(!muted)turn()};$('voice-turn').onclick=turn;$('end-call').onclick=endVoice;document.addEventListener('keydown',e=>{if(e.key==='Escape'){if(!live.hidden)endVoice();else if(page!=='home')home()}});live.addEventListener('keydown',e=>{if(e.key!=='Tab')return;const buttons=[...live.querySelectorAll('button:not(:disabled)')];if(e.shiftKey&&document.activeElement===buttons[0]){e.preventDefault();buttons.at(-1).focus()}else if(!e.shiftKey&&document.activeElement===buttons.at(-1)){e.preventDefault();buttons[0].focus()}});
})();
</script>
<script>
(()=>{const reduced=matchMedia('(prefers-reduced-motion: reduce)');let mode='idle',manual=false,frame=0;const captions={idle:'Ready when you are',listening:'Listening · demo',working:'Working on your thought · demo',speaking:'Hope is speaking · demo',muted:'Microphone muted · demo'};function set(next){mode=next;document.querySelectorAll('[data-persona-state]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.personaState===next)));const caption=document.getElementById('home-persona-caption');if(caption)caption.textContent=captions[next];document.querySelectorAll('.persona-panel').forEach(p=>p.dataset.state=next);draw(0)}function draw(t){const phaseTime=reduced.matches?0:t/55;let level=mode==='listening'?.17+.12*Math.sin(t/450):mode==='speaking'?.55+.25*Math.sin(t/240):0;document.querySelectorAll('canvas.persona').forEach(canvas=>{if(canvas.closest('[hidden]'))return;const c=canvas.getContext('2d');c.clearRect(0,0,440,440);for(let r=0;r<21;r++)for(let col=0;col<21;col++){const dx=(col-10)/10.5,dy=(r-10)/10.5,rad=Math.hypot(dx,dy),phase=(Math.sin(r*12.9898+col*78.233)*43758.5453%1)*Math.PI*2;let vis,size=1;if(mode==='idle'||mode==='muted'){vis=(mode==='muted'?.14:.23)-rad*.085}else if(level>.005){const lvl=Math.min(1,level*1.3),falloff=Math.exp(-rad*rad*(2-lvl*1.1)),ripple=.62+.38*Math.sin(phaseTime*.32+phase);vis=falloff*(.35+lvl)*ripple;size=1+(1-rad)*1.4*lvl;if(mode!=='speaking')vis*=.55}else{const breathe=.5+.5*Math.sin(phaseTime*.12+phase);vis=.2-rad*.06+.09*breathe;size=1+(1-rad)*.3}c.globalAlpha=Math.max(.035,Math.min(1,vis));c.fillStyle=mode==='muted'?'#a98998':'#c54d84';c.beginPath();c.arc(30+col*19,30+r*19,2.05*size,0,Math.PI*2);c.fill()}c.globalAlpha=1})}document.querySelectorAll('[data-persona-state]').forEach(b=>b.onclick=()=>{manual=true;set(b.dataset.personaState)});const state=document.getElementById('voice-state');new MutationObserver(()=>{manual=false;const s=state.textContent.toLowerCase();set(s.includes('muted')?'muted':s.includes('speaking')||s.includes('replying')?'speaking':s.includes('thinking')||s.includes('working')||s.includes('finding')?'working':s.includes('listening')?'listening':'idle')}).observe(state,{childList:true,subtree:true,characterData:true});new MutationObserver(()=>{if(document.getElementById('live').hidden){manual=false;set('idle')}}).observe(document.getElementById('live'),{attributes:true,attributeFilter:['hidden']});set('idle');function loop(t){if(!document.hidden&&t-frame>=50){draw(t);frame=t}requestAnimationFrame(loop)}requestAnimationFrame(loop);window.personaPreview=set;
})();
</script></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light"><title>Hope · OPAL</title><style>
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#302b40;background:#eeeaf7;--ink:#302b40;--muted:#6e657c;--violet:#7453ab;--pink:#f8edf2;--lilac:#f1edfa;--ice:#eaf4fb}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer;color:inherit;border:0;min-width:44px;min-height:44px;background:none}button:hover{background-color:#f2eef8}button:active{transform:scale(.97)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #8763bd;outline-offset:4px}a{color:inherit}button,select{transition:background .18s,transform .18s}[hidden]{display:none!important}.layout{min-height:100vh;max-width:1150px;margin:auto;display:grid;grid-template-columns:1fr 414px;gap:115px;align-items:center;padding:58px 48px}.presentation{max-width:430px}.back-link{display:inline-flex;min-height:44px;align-items:center;gap:10px;text-decoration:none;font-size:13px;color:#675d79;margin-bottom:37px}.concept{font-size:13px;color:#675d79;display:flex;gap:10px;align-items:center}.concept:before{content:"";height:7px;width:7px;border-radius:50%;background:#a281c2}.presentation h1{font-size:82px;font-weight:400;letter-spacing:-.04em;line-height:1;margin:19px 0 23px}.lead{font-size:23px;letter-spacing:-.025em;line-height:1.5;max-width:25ch;margin:0 0 35px}.notes{padding:0;list-style:none;margin:0}.notes li{padding:18px 0;border-top:1px solid #d8d0e6;font-size:14px;line-height:1.6;color:#675d79}.notes strong{display:block;font-weight:550;font-size:15px;color:#302b40;margin-bottom:3px}.identity{display:flex;align-items:center;gap:24px;margin-top:29px}.icon-large{width:100px;height:100px;filter:drop-shadow(0 8px 12px #64508118)}.icon-small{width:32px;height:32px}.identity p{font-size:12px;color:#675d79;line-height:1.6;margin:8px 0 0}.demo-note{font-size:12px;line-height:1.75;color:#675d79;margin-top:28px;max-width:43ch}.device{width:414px;padding:11px;background:#faf9fd;border-radius:53px;box-shadow:0 28px 65px #5e4f7c20,0 0 0 1px #ffffffaa,inset 0 0 0 2px #e8e3ed}.screen{width:390px;height:800px;border-radius:43px;background:#fff;overflow:hidden;position:relative;isolation:isolate;box-shadow:0 0 0 1px #ded9e6}.statusbar{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 27px;font-size:14px;font-weight:600;position:relative}.island{width:110px;height:29px;border-radius:30px;background:#201d26;position:absolute;left:50%;top:10px;transform:translateX(-50%)}.status-icons{display:flex;gap:5px;align-items:center}.status-icons svg{width:15px;height:15px}.status-icons .battery{width:23px}.app-header{height:66px;display:flex;align-items:center;padding:0 21px;gap:8px}.brand{display:flex;align-items:center;gap:8px;margin-right:auto;font-size:21px;font-weight:530;letter-spacing:-.03em}.brand svg{width:28px;height:28px}.round-button{display:grid;place-items:center;border-radius:50%;width:44px;height:44px}.round-button svg,.glyph{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round}.app-header .round-button{color:#7b708b}.content{height:552px;overflow-y:auto;scrollbar-width:thin;padding:0 27px}.home{text-align:center;padding-top:26px}.greeting{font-size:32px;line-height:1.24;letter-spacing:-.035em;font-weight:400;margin:0}.greeting em{font-family:Georgia,serif;font-weight:400}.welcome-copy{color:var(--muted);font-size:13px;margin:15px 0 0;line-height:1.6}.pearl-wrap{width:240px;height:210px;margin:8px auto 0;position:relative}.pearl{width:100%;height:100%;overflow:visible}.pearl-shadow{position:absolute;width:120px;height:8px;bottom:6px;left:calc(50% - 60px);background:#c9c0df;opacity:.22;filter:blur(8px);border-radius:50%}.voice-start{display:flex;gap:10px;align-items:center;justify-content:center;margin:5px auto 0;font-size:14px;font-weight:500;padding:0 17px 0 7px;border-radius:40px;background:#faf7fc;min-height:52px}.mic-halo{width:39px;height:39px;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle at 35% 20%,#fff 0,#eee1f5 60%,#eadff6 100%);box-shadow:0 0 0 5px #f8f2fb;color:#775393}.mic-halo svg{width:19px}.prompts{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:23px}.prompt{font-size:12px;border-radius:30px;padding:0 15px;min-height:44px;color:#63586f}.prompt:nth-child(1){background:var(--pink)}.prompt:nth-child(2){background:var(--lilac)}.prompt:nth-child(3){background:var(--ice)}.composer-zone{position:absolute;bottom:27px;left:19px;right:19px;z-index:2}.composer{display:flex;align-items:center;height:64px;padding:8px 9px 8px 19px;border-radius:34px;background:#fff;box-shadow:0 5px 22px #69587814,0 1px 4px #69587812;gap:5px}.composer input{min-width:0;flex:1;border:0;outline:0;background:none;font-size:14px;color:var(--ink);height:44px}.composer input::placeholder{color:#756b81}.composer input:focus-visible{outline:0}.composer:focus-within{box-Line truncated
@media(max-width:1050px){.layout{gap:55px;padding:40px 30px}.presentation h1{font-size:70px}.lead{font-size:21px}}@media(max-width:780px){.layout{display:flex;flex-direction:column;padding:30px 20px;gap:35px}.presentation{width:100%;max-width:414px}.presentation h1{font-size:56px;margin:12px 0 14px}.back-link{margin-bottom:20px}.lead{font-size:19px;margin-bottom:20px;max-width:33ch}.notes{display:none}.identity{margin-top:20px}.icon-large{width:64px;height:64px}.demo-note{margin-top:18px}.device-wrap{max-width:100%}}@media(max-width:440px){body{background:#fff}.layout{padding:0;gap:0}.presentation,.device-caption{display:none}.device{width:100vw;padding:0;border-radius:0;background:#fff;box-shadow:none}.screen{width:100%;height:100svh;min-height:760px;border-radius:0;box-shadow:none}.content{height:calc(100% - 248px)}.home{padding-top:20px}.composer-zone{bottom:max(27px,env(safe-area-inset-bottom))}.statusbar{padding-left:29px;padding-right:29px}.home-indicator{bottom:8px}.live{bottom:21px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
</style></head><body>
<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs>
<linearGradient id="opal-fill" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fef7fc" stop-opacity=".75"/><stop offset=".26" stop-color="#eae4ff" stop-opacity=".35"/><stop offset=".57" stop-color="#fff" stop-opacity=".05"/><stop offset=".79" stop-color="#d8edf9" stop-opacity=".45"/><stop offset="1" stop-color="#e4d7f4" stop-opacity=".8"/></linearGradient>
<radialGradient id="pearl-core" cx="40%" cy="30%" r="74%"><stop stop-color="white" stop-opacity=".05"/><stop offset=".54" stop-color="#fff" stop-opacity=".02"/><stop offset=".79" stop-color="#e2daf5" stop-opacity=".3"/><stop offset=".92" stop-color="#e2d1ed" stop-opacity=".65"/><stop offset=".98" stop-color="#faf6fd"/><stop offset="1" stop-color="#bdb2d8" stop-opacity=".6"/></radialGradient>
<linearGradient id="rim" x1="0" y1="1" x2="1" y2="0"><stop stop-color="#e7c9e7"/><stop offset=".24" stop-color="#fff"/><stop offset=".42" stop-color="#c3d4ed"/><stop offset=".68" stop-color="#fff"/><stop offset=".86" stop-color="#dcc6ea"/><stop offset="1" stop-color="#f8e8f7"/></linearGradient>
<linearGradient id="pink-reflection" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#e4c1dd" stop-opacity=".8"/><stop offset=".35" stop-color="#f8e6f1" stop-opacity=".6"/><stop offset=".7" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#bcade1" stop-opacity=".7"/></linearGradient>
<linearGradient id="blue-reflection" x1="1" y1="0" x2="0" y2="1"><stop stop-color="#d0e9f8" stop-opacity=".1"/><stop offset=".5" stop-color="#b7d9f0" stop-opacity=".6"/><stop offset=".78" stop-color="#e2d3f5" stop-opacity=".3"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#fff" stop-opacity=".95"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><filter id="soft"><feGaussianBlur stdDeviation="2.7"/></filter><filter id="soft-light"><feGaussianBlur stdDeviation=".6"/></filter>
<symbol id="pearl-art" viewBox="0 0 280 280"><circle cx="140" cy="136" r="110" fill="url(#opal-fill)"/><circle cx="140" cy="136" r="110" fill="url(#pearl-core)"/>
<path d="M62 61C91 25 142 16 182 37C132 26 106 52 99 93C88 143 74 166 45 175C33 135 36 91 62 61Z" fill="url(#pink-reflection)" filter="url(#soft)"/>
<path d="M205 58C240 91 254 152 229 196C211 226 171 247 128 244C194 227 179 189 201 165C226 138 239 105 205 58Z" fill="url(#blue-reflection)"/>
<path d="M50 92C76 32 156 22 205 64C144 23 106 54 99 104C90 155 59 189 45 166C62 175 86 139 89 99C91 62 117 36 150 36C111 24 68 46 50 92Z" fill="url(#shine)" opacity=".87"/>
<path d="M47 163C70 182 105 134 126 112C152 85 181 84 213 102C194 95 174 100 155 114C120 140 96 189 68 190C58 187 52 178 47 163Z" fill="url(#pink-reflection)" opacity=".62" filter="url(#soft)"/>
<path d="M64 187C110 218 174 121 218 141C173 136 142 222 101 224C82 221 71 207 64 187Z" fill="url(#blue-reflection)" opacity=".63"/>
<path d="M84 223C142 250 196 221 223 181" fill="none" stroke="white" stroke-width="3" opacity=".8" filter="url(#soft-light)"/>
<circle cx="140" cy="136" r="110" fill="none" stroke="url(#rim)" stroke-width="1.6"/><circle cx="140" cy="136" r="106.5" fill="none" stroke="white" stroke-width=".8" opacity=".8"/>
<path d="M40 106C47 75 69 49 95 39" fill="none" stroke="white" stroke-width="4" stroke-linecap="round" opacity=".85"/><path d="M167 31C209 40 239 73 247 111" fill="none" stroke="#f1d9ee" stroke-width="2" opacity=".8"/><path d="M240 173C228 213 194 240 153 245" fill="none" stroke="#d7daf6" stroke-width="2.2"/><ellipse cx="77" cy="69" rx="6" ry="14" transform="rotate(40 77 69)" fill="#fff" opacity=".9" filter="url(#soft)"/>
</symbol>
<linearGradient id="icon-pearl" x1="0" y1="0" x2="1" y2="1"><stop stop-color="white"/><stop offset=".4" stop-color="#f2ddec"/><stop offset=".7" stop-color="#dbe8f8"/><stop offset="1" stop-color="#cbb7eb"/></linearGradient>
<symbol id="app-icon" viewBox="0 0 100 100"><rect width="100" height="100" rx="23" fill="#9b7fbe"/><circle cx="40" cy="42" r="25" fill="url(#icon-pearl)"/><path d="m23 56-3 16 16-9" fill="url(#icon-pearl)"/><circle cx="63" cy="58" r="23" fill="url(#icon-pearl)" stroke="#9b7fbe" stroke-width="2"/><path d="m70 77 13 9-2-17" fill="url(#icon-pearl)"/><path d="M21 39C23 28 31 22 42 21M46 57C46 46 53 39 63 38" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".9" fill="none"/><path d="M55 23C69 34 65 53 52 63" stroke="#b5a2d1" fill="none" opacity=".7"/></symbol>
<symbol id="brand-mark" viewBox="0 0 32 32"><circle cx="13" cy="13" r="9" fill="#d9c9ec"/><path d="m6 18-1 6 6-4" fill="#d9c9ec"/><circle cx="21" cy="21" r="8" fill="#eddef0" stroke="white" stroke-width="1.5"/><path d="m24 26 5 4-1-6" fill="#eddef0"/></symbol>
<symbol id="mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11v2a7 7 0 0 0 14 0v-2M12 20v2M8 22h8"/></symbol><symbol id="mute" viewBox="0 0 24 24"><path d="m3 3 18 18M9 9v3a3 3 0 0 0 5 2M9 5a3 3 0 0 1 6 0v6M5 11v2a7 7 0 0 0 12 5M19 11v2M12 20v2"/></symbol><symbol id="history" viewBox="0 0 24 24"><path d="M4 8a9 9 0 1 1-1 7M4 3v5h5M12 7v5l3 2"/></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3" fill="white"/><circle cx="16" cy="17" r="3" fill="white"/></symbol><symbol id="arrow" viewBox="0 0 24 24"><path d="M12 19V5m-6 6 6-6 6 6"/></symbol><symbol id="back" viewBox="0 0 24 24"><path d="m14 6-6 6 6 6"/></symbol><symbol id="chevron" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol><symbol id="chat" viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 1-8 8 9 9 0 0 1-4-1l-5 3 1-6a8 8 0 1 1 16-4Z"/></symbol><symbol id="end" viewBox="0 0 24 24"><path d="M5 16v-4h14v4M3 12c4-5 14-5 18 0"/></symbol>
</defs></svg>
<main class="layout"><aside class="presentation" aria-label="Design direction"><a class="back-link" href="new-index.html">← Explore the new directions</a><div class="concept">Hope · A new direction</div><h1>OPAL</h1><p class="lead">A little space to think.<br>A softer way to be heard.</p><ul class="notes"><li><strong>Light, held in a pearl.</strong>Transparent layers, curved reflections, and an iridescent edge give Hope a quiet physical presence.</li><li><strong>The conversation has room.</strong>A luminous white canvas. A personal greeting. Everyday prompts that don’t compete for attention.</li><li><strong>Always within reach.</strong>One floating composer for a thought in text or a conversation out loud. Voice opens into a focused, uncluttered space.</li></ul><div class="identity"><svg class="icon-large" role="img" aria-label="Hope icon, two overlapping pearlescent circular speech forms on violet"><use href="#app-icon"/></svg><div><svg class="icon-small" role="img" aria-label="Hope icon at 32 pixels"><use href="#app-icon"/></svg><p>Two voices, a shared space.<br>App icon · 100 / 32 px</p></div></div><p class="demo-note">Interactive design study. All conversations are simulated and stay on this page. No microphone, audio, network, or account access.</p></aside>
<section class="device-wrap" aria-label="Interactive Hope phone"><div class="device"><div class="screen" id="screen"><div class="statusbar" aria-hidden="true"><span>9:41</span><span class="island"></span><div class="status-icons"><svg viewBox="0 0 20 20" fill="currentColor"><rect x="1" y="12" width="3" height="5" rx="1"/><rect x="6" y="9" width="3" height="8" rx="1"/><rect x="11" y="6" width="3" height="11" rx="1"/><rect x="16" y="3" width="3" height="14" rx="1"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9a14 14 0 0 1 18 0M6 12a9 9 0 0 1 12 0M9 15a4 4 0 0 1 6 0"/><circle cx="12" cy="18" r="1" fill="currentColor"/></svg><svg class="battery" viewBox="0 0 25 16" fill="none" stroke="currentColor"><rect x="1" y="2" width="20" height="12" rx="3"/><rect x="3" y="4" width="16" height="8" rx="1" fill="currentColor"/><path d="M23 6v4" stroke-width="2"/></svg></div></div>
<header class="app-header"><div class="brand"><svg aria-hidden="true"><use href="#brand-mark"/></svg>hope</div><button class="round-button" id="history-button" aria-label="Open history"><svg class="glyph"><use href="#history"/></svg></button><button class="round-button" id="settings-button" aria-label="Open settings"><svg class="glyph"><use href="#settings"/></svg></button></header>
<div class="content" id="content" tabindex="-1"><section class="home"><h2 class="greeting">A little room for you,<br><em>Shane.</em></h2><p class="welcome-copy">No perfect words needed. Start anywhere.</p><div class="pearl-wrap"><span class="pearl-shadow"></span><svg class="pearl" role="img" aria-label="A translucent pearl with pink, lilac and ice-blue reflections"><use href="#pearl-art"/></svg></div><button class="voice-start" id="start-voice"><span class="mic-halo"><svg class="glyph"><use href="#mic"/></svg></span>Let’s talk</button><div class="prompts"><button class="prompt" data-prompt="day">Talk through my day</button><button class="prompt" data-prompt="idea">Explore an idea</button><button class="prompt" data-prompt="plan">Make a plan</button></div></section></div>
<div class="composer-zone"><form class="composer" id="composer"><label for="message" class="sr-only">Message Hope</label><input id="message" placeholder="What’s on your mind?" maxlength="2000" autocomplete="off"><button type="submit" class="send-button" id="send-message" aria-label="Send message" hidden><svg class="glyph"><use href="#arrow"/></svg></button><button type="button" class="mic-button" id="composer-mic" aria-label="Start voice conversation"><svg class="glyph"><use href="#mic"/></svg></button></form><p class="simulation">Local demo · Your microphone stays off</p></div>
<section class="live" id="live" aria-label="Simulated voice conversation" hidden><div class="live-top"><strong>With Hope</strong><span>Simulated voice</span></div><svg class="live-pearl" aria-hidden="true"><use href="#pearl-art"/></svg><h2 id="live-heading">I’m here. Take your time.</h2><div class="live-state" role="status"><span class="state-dot"></span><span id="voice-state">Listening</span></div><div class="elapsed" id="elapsed">00:00</div><svg class="ribbon" viewBox="0 0 320 65" aria-hidden="true"><defs><linearGradient id="ribbon-color"><stop stop-color="#eacbde"/><stop offset=".5" stop-color="#d8c7ed"/><stop offset="1" stop-color="#c4e1f0"/></linearGradient></defs><path d="M0 34C35 34 42 10 76 17S114 57 147 36 190 7 217 27 258 54 284 37 309 29 320 33" fill="none" stroke="url(#ribbon-color)" stroke-width="1.3"/><path d="M0 34C32 34 53 29 77 28S116 44 147 34 189 17 219 31 261 41 284 35 309 32 320 33" fill="none" stroke="url(#ribbon-color)" stroke-width="6" opacity=".4"/><path d="M0 34C34 33 49 44 78 41S115 14 145 31 183 56 217 38 257 20 284 31 309 35 320 33" fill="none" stroke="url(#ribbon-color)" stroke-width="1" opacity=".8"/></svg><div class="live-log" id="live-log" role="log" aria-label="Voice transcript" aria-live="polite"></div><div class="live-controls"><div class="control-group"><button id="mute-button" aria-label="Mute simulated microphone" aria-pressed="false"><svg class="glyph"><use href="#mute"/></svg></button><span id="mute-label">Mute</span></div><div class="control-group"><button class="listen" id="voice-turn" aria-label="Try a simulated voice turn"><svg class="glyph"><use href="#mic"/></svg></button><span>Speak again</span></div><div class="control-group"><button class="end" id="end-call" aria-label="End conversation"><svg class="glyph"><use href="#end"/></svg></button><span>End</span></div></div><p class="live-note">No audio is recorded or played</p></section><div class="home-indicator" aria-hidden="true"></div><div id="announce" class="sr-only" role="status"></div></div></div><p class="device-caption">OPAL · A luminous space for conversation</p></section></main>
<script>
(()=>{'use strict';const $=id=>document.getElementById(id),content=$('content'),homeMarkup=content.innerHTML,live=$('live'),field=$('message');let page='home',voice='Ava',spoken=true,muted=false,interval=null,timers=[],seconds=0,active=null;const history=[{title:'A little breathing room',when:'Today · 9:12 am',messages:[['You','Today feels a little full. I don’t know where to start.'],['Hope','Let’s make it smaller. What’s one thing that needs your attention, and one thing that can wait?']]},{title:'An idea for the weekend',when:'Yesterday · 6:40 pm',messages:[['You','I’d love to spend a day by the sea.'],['Hope','A morning train, a long walk, and lunch somewhere unhurried. Want to start with a place or a date?']]}];const prompts={day:['Talk through my day','What’s been taking up the most space in your day? We can start there.'],idea:['Explore an idea','Tell me the rough version. It doesn’t need to make sense yet.'],plan:['Make a plan','What would you like to make room for? We can find a small first step.']};const icon=name=>'<svg class="glyph" aria-hidden="true"><use href="#'+name+'"/></svg>';const announce=text=>$('announce').textContent=text;function cancel(){timers.forEach(clearTimeout);timers=[]}function schedule(fn,time){timers.push(setTimeout(fn,time))}function show(markup,next){page=next;content.innerHTML=markup;content.scrollTop=0;content.focus({preventScroll:true})}function home(){active=null;show(homeMarkup,'home');field.placeholder='What’s on your mind?'}function back(){return '<button class="back" id="back-home">'+icon('back')+'Back to your space</button>'}function message(target,role,text){const el=document.createElement('div');el.className='message '+(role==='You'?'you':'hope');const label=document.createElement('small');label.textContent=role;const p=document.createElement('p');p.textContent=text;el.append(label,p);target.append(el)}function renderThread(item){active=item;show(back()+'<h2 class="thread-title" id="thread-title"></h2><div class="transcript" id="transcript" role="log" aria-live="polite" aria-label="Conversation transcript"></div>','thread');$('thread-title').textContent=item.title;item.messages.forEach(m=>message($('transcript'),...m));field.placeholder='Keep the thought going…'}function newThread(title,first,reply){const item={title,when:'Just now · Demo',messages:[['You',first],['Hope',reply]]};history.unshift(item);renderThread(item)}function showHistory(){show(back()+'<h2 class="page-heading">Your conversations</h2><p class="page-description">Thoughts you can come back to.<br>Sample threads and your local demo conversations.</p><div class="history-label">Recently</div><div id="history-list"></div>','history');history.forEach((item,index)=>{const button=document.createElement('button');button.className='history-row';button.dataset.thread=index;button.innerHTML='<span class="history-symbol">'+icon('chat')+'</span><span><strong></strong><small></small></span>'+icon('chevron');button.querySelector('strong').textContent=item.title;button.querySelector('small').textContent=item.when;$('history-list').append(button)})}function settings(){show(back()+'<h2 class="page-heading">Make yourself at home.</h2><p class="page-description">A few small choices for your conversations.</p><div class="settings-group"><h3>Conversation</h3><label class="setting"><span>Hope’s voice<small>A preference for this demo</small></span><select id="voice-choice" aria-label="Hope voice"><option>Ava</option><option>Emma</option><option>Andrew</option></select></label><label class="setting"><span>Spoken replies<small>No audio plays in this demo</small></span><input type="checkbox" id="spoken"></label></div><div class="settings-group"><h3>Privacy</h3><div class="setting"><span>Just between us<small>Your text stays on this page.<br>No account or microphone is connected.</small></span>'+icon('chat')+'</div></div><button class="settings-save" id="save-settings">Save preferences</button><p class="saved-note" id="saved-note" role="status"></p><p class="settings-footer">Hope · OPAL design study<br>Preferences and conversations reset when you reload.</p>','settings');$('voice-choice').value=voice;$('spoken').checked=spoken}function state(text,heading){$('voice-state').textContent=text;if(heading)$('live-heading').textContent=heading;live.classList.toggle('listening',text==='Listening');announce(text)}function turn(){cancel();if(muted)return;state('Listening','I’m here. Take your time.');$('voice-turn').disabled=true;schedule(()=>{const text='Help me make a little space in my day.';message($('live-log'),'You',text);active.messages.push(['You',text]);state('Thinking','Let’s find a little room.');},1800);schedule(()=>{const text='Choose one thing that can wait. Then give yourself ten minutes with nothing to solve. What would feel good in that little space?';message($('live-log'),'Hope',text);active.messages.push(['Hope',text]);$('live-log').scrollTop=$('live-log').scrollHeight;state(spoken?'HopLine truncated
$('history-button').onclick=showHistory;$('settings-button').onclick=settings;$('composer-mic').onclick=startVoice;content.onclick=e=>{const b=e.target.closest('button');if(!b)return;if(b.id==='back-home')home();if(b.id==='start-voice')startVoice();if(b.dataset.prompt){const p=prompts[b.dataset.prompt];newThread(p[0],p[0],p[1]);field.focus()}if(b.dataset.thread!==undefined)renderThread(history[Number(b.dataset.thread)]);if(b.id==='save-settings'){voice=$('voice-choice').value;spoken=$('spoken').checked;$('saved-note').textContent='Preferences saved for this demo.';announce('Preferences saved')}};field.oninput=()=>{$('send-message').hidden=!field.value.trim()};$('composer').onsubmit=e=>{e.preventDefault();const text=field.value.trim();if(!text)return;const reply='Let’s take it one thought at a time. What feels most important about that to you?';if(page!=='thread')newThread('A thought to talk through',text,reply);else{active.messages.push(['You',text],['Hope',reply]);message($('transcript'),'You',text);message($('transcript'),'Hope',reply);content.scrollTop=content.scrollHeight}field.value='';$('send-message').hidden=true;field.focus();announce('Local message added. Hope’s reply is a prewritten example.')};$('mute-button').onclick=()=>{muted=!muted;cancel();$('mute-button').setAttribute('aria-pressed',String(muted));$('mute-button').setAttribute('aria-label',muted?'Unmute simulated microphone':'Mute simulated microphone');$('mute-label').textContent=muted?'Unmute':'Mute';$('voice-turn').disabled=muted;state(muted?'Microphone muted · simulation paused':'Listening',muted?'A quiet moment.':'Ready when you are.');if(!muted)turn()};$('voice-turn').onclick=turn;$('end-call').onclick=endVoice;document.addEventListener('keydown',e=>{if(e.key==='Escape'){if(!live.hidden)endVoice();else if(page!=='home')home()}});live.addEventListener('keydown',e=>{if(e.key!=='Tab')return;const buttons=[...live.querySelectorAll('button:not(:disabled)')];if(e.shiftKey&&document.activeElement===buttons[0]){e.preventDefault();buttons.at(-1).focus()}else if(!e.shiftKey&&document.activeElement===buttons.at(-1)){e.preventDefault();buttons[0].focus()}});
})();
</script></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><linearGradient id="a" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#fff5ef"/><stop offset=".35" stop-color="#def3fb"/><stop offset=".65" stop-color="#e9c9f0"/><stop offset="1" stop-color="#f4afcf"/></linearGradient><linearGradient id="b"><stop stop-color="#dfc2f1"/><stop offset=".5" stop-color="#f8e2e5"/><stop offset="1" stop-color="#e1f2ff"/></linearGradient></defs><rect width="1024" height="1024" fill="#8e59a9"/><g transform="translate(112 112) scale(8)"><path d="M26 16C10 28 11 57 28 73C36 80 45 77 48 66L57 36C62 18 42 4 26 16Z" fill="url(#a)" stroke="#fff7"/><path d="M69 21C54 11 42 29 47 46L58 77C63 91 85 78 85 59C85 39 81 29 69 21Z" fill="url(#b)" stroke="#fff8"/><path d="M29 19C18 26 17 42 20 50M68 24C77 31 79 39 80 48" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/></g></svg>

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hope / Prism</title><style>
*{box-sizing:border-box}body{margin:0;color:#302639;background:#eee9f3;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}button,input,select{font:inherit}button{border:0;background:none;color:inherit;min-height:44px;cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #8b52ba;outline-offset:4px}a{color:inherit}h1,h2,p{margin:0}[hidden]{display:none!important}.stage{min-height:100vh;max-width:1230px;display:grid;grid-template-columns:1fr 400px 180px;align-items:center;gap:48px;padding:54px 42px;margin:auto}.intro{align-self:start;padding-top:55px}.back{display:inline-flex;align-items:center;min-height:44px;font-size:13px;text-decoration:none}.name{font-size:15px;margin:30px 0 20px;color:#674878}.intro h1{font-size:66px;line-height:1.07;letter-spacing:-.035em;font-weight:450}.intro h1 em{font-family:Georgia,serif;font-weight:400;color:#9251ad}.lede{font-size:17px;line-height:1.7;margin-top:26px;max-width:34ch;color:#675c6e}.features{margin-top:38px}.feature{padding:17px 0;border-top:1px solid #d7ccde;font-size:14px;line-height:1.6;color:#675c6e}.feature strong{display:block;font-size:15px;color:#302639;margin-bottom:4px}.phone{height:824px;width:390px;border:7px solid #fcfbfd;border-radius:48px;overflow:hidden;position:relative;background:linear-gradient(160deg,#fff 0%,#fdfbff 55%,#f0edf8 100%);box-shadow:0 6px 8px #5340640c;display:flex;flex-direction:column}.statusbar{height:48px;display:flex;align-items:center;justify-content:space-between;padding:12px 25px 0;font-size:14px;font-weight:600;flex-shrink:0}.island{position:absolute;top:9px;left:50%;transform:translateX(-50%);width:103px;height:27px;border-radius:20px;background:#292331}.header{display:flex;align-items:center;justify-content:space-between;padding:14px 22px 9px;flex-shrink:0}.header button{width:44px;background:#fff;border-radius:50%;font-size:20px}.wordmark{font-size:20px;font-weight:600;letter-spacing:-.03em}.wordmark span{font-weight:400;color:#9c80b0}.view{flex:1;overflow:auto;min-height:0;padding:0 22px 16px}.hero{text-align:center;padding-top:9px}.sculpture{height:108px;width:140px;margin:0 auto 10px;position:relative;filter:drop-shadow(0 9px 8px #b298ce22)}.sculpture .piece{position:absolute;width:67px;height:84px;border-radius:48% 50% 44% 52%;top:11px;left:31px;transform:rotate(-24deg);background:radial-gradient(ellipse at 22% 13%,#fff 0%,#fff9 14%,transparent 39%),conic-gradient(from 130deg,#cee9f6,#eed9f7,#e6a7d8,#fee0ca,#d9eaf5,#c7b1f0,#cee9f6);box-shadow:inset 2px 1px 2px #fff,inset -3px -2px 5px #baa2d455;border:1px solid #fff9}.sculpture .piece:nth-child(2){left:65px;top:12px;transform:rotate(24deg);width:51px;height:77px;background:radial-gradient(ellipse at 75% 17%,#fff 0%,#fff8 17%,transparent 44%),conic-gradient(from 30deg,#cceafb,#d5c0f5,#f3d2e8,#fee5dd,#d3eaf9)}.sculpture:after{content:'';position:absolute;bottom:2px;left:27px;width:90px;height:8px;background:#bb9dda35;filter:blur(8px);border-radius:50%}.hero h2{font-size:29px;line-height:1.14;font-weight:450;letter-spacing:-.03em}.hero h2 em{font-family:Georgia,serif;color:#9251ad;font-weight:400}.hero p{font-size:13px;color:#74627f;line-height:1.5;margin-top:10px}.topic{display:flex;align-items:center;justify-content:space-between;margin:24px 0 18px;font-size:12px;color:#766a7d}.topic span:first-child{padding:7px 11px;border:1px solid #e8dfed;border-radius:20px;background:#fff}.messages{display:flex;flex-direction:column;gap:20px}.message{font-size:15px;line-height:1.6}.message.user{padding:11px 14px;border-radius:14px;background:#f2eaf7;margin-left:28px}.message .who{font-size:11px;font-weight:600;color:#7c6688;margin-bottom:5px}.message p{white-space:pre-wrap;overflow-wrap:anywhere}.reply-title{font-size:20px;letter-spacing:-.02em;font-weight:500;margin:8px 0}.idea{margin-top:16px;display:flex;gap:12px;align-items:center}.idea-art{width:70px;height:76px;flex-shrink:0;overflow:hidden;border-radius:12px;position:relative;background:linear-gradient(150deg,#e3c1e8,#e5e3ff,#c5d9f2)}.idea-art:before{content:'';position:absolute;width:72px;height:72px;border-radius:50%;left:20px;top:25px;background:linear-gradient(140deg,#e7dbff,#a8bfe2);box-shadow:inset 1px 1px 2px #fff8}.idea-art:after{content:'';position:absolute;width:30px;height:30px;left:13px;top:10px;border-radius:50%;background:radial-gradient(circle at 30% 25%,#fff9,#f5c7dc 35%,#d397c4);box-shadow:0 4px 5px #ae90b333}.idea-text{font-size:13px;line-height:1.6;color:#71607c}.idea-text strong{display:block;color:#45334e;font-size:14px;margin-bottom:3px}.actions{display:flex;gap:8px;margin-top:15px}.actions button{padding:0 12px;border-radius:24px;border:1px solid #e1d7e8;background:#fff;font-size:12px}.composer-shell{padding:10px 14px 12px;flex-shrink:0}.composer{border-radius:16px;background:#fff;padding:13px 14px 10px;box-shadow:0 4px 8px #8c76a313}.composer input{border:0;color:#45334e;background:transparent;wLine truncated
</style></head><body><svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><linearGradient id="gem" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#fff5ef"/><stop offset=".35" stop-color="#def3fb"/><stop offset=".65" stop-color="#e9c9f0"/><stop offset="1" stop-color="#f4afcf"/></linearGradient><linearGradient id="gem2"><stop stop-color="#dfc2f1"/><stop offset=".5" stop-color="#f8e2e5"/><stop offset="1" stop-color="#e1f2ff"/></linearGradient><symbol id="prism-icon" viewBox="0 0 100 100"><path d="M26 16C10 28 11 57 28 73C36 80 45 77 48 66L57 36C62 18 42 4 26 16Z" fill="url(#gem)" stroke="#fff7" stroke-width="1"/><path d="M69 21C54 11 42 29 47 46L58 77C63 91 85 78 85 59C85 39 81 29 69 21Z" fill="url(#gem2)" stroke="#fff8" stroke-width="1"/><path d="M29 19C18 26 17 42 20 50M68 24C77 31 79 39 80 48" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/></symbol><symbol id="mic-symbol" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></symbol></defs></svg><main class="stage"><section class="intro"><a class="back" href="new-index.html">← Compare the new directions</a><div class="name">Prism / a conversation that unfolds</div><h1>From a thought<br>to <em>something<br>you can use.</em></h1><p class="lede">A softer place to talk things through. Pearlescent details, quiet lavender, and useful answers with room to breathe.</p><div class="features"><div class="feature"><strong>Your answer is the main event.</strong>A compact sculptural identity leaves space for readable replies and the next step.</div><div class="feature"><strong>Write or speak from one place.</strong>A floating composer makes switching between voice and text effortless.</div><div class="feature"><strong>A little color, a lighter touch.</strong>Translucent pastel forms carry the personality. Controls stay simple.</div></div></section><section class="phone-wrap" aria-label="Interactive Prism demonstration"><div class="phone"><div class="statusbar"><span>9:41</span><div class="island" aria-hidden="true"></div><span aria-hidden="true">▮▮▮ ▰</span></div><header class="header"><button id="history" aria-label="Conversation history">☰</button><div class="wordmark">hope <span>/ prism</span></div><button id="settings" aria-label="Settings">···</button></header><section class="view" id="chat-view"><div class="hero"><div class="sculpture" aria-hidden="true"><div class="piece"></div><div class="piece"></div></div><h2>A little possibility,<br><em>Shane.</em></h2><p>Let’s see where your thoughts take us.</p></div><div class="topic"><span>A slower weekend</span><span>Sample conversation</span></div><div class="messages" id="messages" role="log" aria-label="Conversation" aria-live="polite"><article class="message user"><div class="who">You</div><p>I’d like a weekend that feels less busy.</p></article><article class="message"><div class="who">Hope</div><h2 class="reply-title">Leave a little room.</h2><p>Choose one thing you’ll look forward to, and leave the rest of the morning open. A walk with no destination is a good place to start.</p><div class="idea"><div class="idea-art" aria-hidden="true"></div><div class="idea-text"><strong>A morning, unplanned.</strong>Take a short walk. Stop somewhere new. Let the day find its own pace.</div></div><div class="actions"><button id="continue">Plan a quiet morning ↗</button><button id="copy">Copy reply</button></div><p class="toast" id="copy-status" role="status"></p></article></div><p class="light-status" id="chat-status" role="status"></p></section><section class="view" id="history-view" hidden><h2 class="nav-title">Your conversations</h2><p class="sub">Pick up a thought where you left it.</p><div class="list"><button id="open-thread"><strong>A slower weekend</strong><small>Today · Sample conversation</small></button><button id="open-idea"><strong>An idea taking shape</strong><small>Yesterday · Sample conversation</small></button></div><button class="voice-back" id="history-back">← Back to conversation</button><p class="notice">Example history only. Nothing is saved or loaded from your account.</p></section><section class="view" id="settings-view" hidden><h2 class="nav-title">Make it yours.</h2><p class="sub">The voice that meets you here.</p><div class="list"><label class="setting"><span>Hope’s voice</span><select id="voice-choice"><option>Ava</option><option>Andrew</option><option>Emma</option></select></label><label class="setting"><span>Read replies aloud</span><input id="spoken" type="checkbox" checked style="width:24px;height:24px;accent-color:#a36cba"></label><div class="setting"><span>Account</span><span>Shane · demo</span></div></div><p class="notice">Preferences apply to this sample only. No audio is played and no real account is connected.</p><button class="voice-back" id="settings-back">← Back to conversation</button><button class="voice-back" id="signout" style="display:block">SiLine truncated
const $=id=>document.getElementById(id);let timeouts=[],clock=null,seconds=0,active=false,muted=false,busy=false;function clear(){timeouts.forEach(clearTimeout);timeouts=[];clearInterval(clock);clock=null;busy=false}function view(name){document.querySelectorAll('.view').forEach(v=>v.hidden=v.id!==name+'-view');$('composer-shell').hidden=name!=='chat';$('history').textContent=name==='chat'?'☰':'‹'}function add(role,text){const article=document.createElement('article');article.className='message'+(role==='You'?' user':'');const who=document.createElement('div');who.className='who';who.textContent=role;const p=document.createElement('p');p.textContent=text;article.append(who,p);$('messages').append(article);$('chat-view').scrollTop=$('chat-view').scrollHeight}function reply(text){if(busy)return;busy=true;add('You',text);$('chat-status').textContent='Hope is finding a starting point… · demo';$('voice-state').textContent='Putting a thought together · demo';$('voice-heading').textContent='One moment.';timeouts.push(setTimeout(()=>{add('Hope','Leave the first hour open. A slow breakfast, a nearby walk, and one small thing you’re looking forward to. You can decide what comes next when you get there. (Sample reply.)');$('chat-status').textContent='Sample reply ready';$('voice-state').textContent=muted?'Microphone muted · demo':'Hope’s reply is ready · demo';$('voice-heading').textContent='A little room to breathe.';busy=false},1600))}function start(){clear();active=true;muted=false;seconds=0;$('timer').textContent='00:00';$('pause').textContent='Mute';$('pause').setAttribute('aria-pressed','false');$('voice-state').textContent='Listening · demo';$('voice-heading').innerHTML='Take your time.<br>I’m here.';view('voice');clock=setInterval(()=>{$('timer').textContent=String(Math.floor(++seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0')},1000);timeouts.push(setTimeout(()=>{if(active&&!muted)reply('Help me plan a quiet morning.')},2400))}$('start-voice').onclick=start;$('demo-turn').onclick=()=>{if(!muted)reply('Help me plan a quiet morning.')};$('pause').onclick=()=>{muted=!muted;$('pause').textContent=muted?'Unmute':'Mute';$('pause').setAttribute('aria-pressed',String(muted));$('voice-state').textContent=muted?'Microphone muted · demo':'Listening · demo'};$('end').onclick=()=>{clear();active=false;view('chat');$('chat-status').textContent='Voice demo ended. Your conversation stays here.'};$('transcript').onclick=()=>view('chat');$('history').onclick=()=>{if(!$('chat-view').hidden)view('history');else view('chat')};$('settings').onclick=()=>view('settings');$('history-back').onclick=$('settings-back').onclick=()=>view('chat');$('open-thread').onclick=()=>view('chat');$('open-idea').onclick=()=>{add('Hope','Tell me the idea in its simplest form. We can work out the next step together. (Sample history.)');view('chat')};$('composer').onsubmit=e=>{e.preventDefault();const text=$('text-input').value.trim();if(text&&!busy){$('text-input').value='';reply(text)}};$('continue').onclick=()=>reply('Help me plan a quiet morning.');$('copy').onclick=async()=>{try{await navigator.clipboard.writeText('Choose one thing you’ll look forward to, and leave the rest of the morning open.');$('copy-status').textContent='Reply copied.'}catch{$('copy-status').textContent='Copy isn’t available here. Select the reply text to copy it.'}};$('voice-choice').onchange=e=>$('settings-status').textContent=e.target.value+' selected for this demo.';$('spoken').onchange=e=>$('settings-status').textContent='Spoken replies '+(e.target.checked?'enabled':'disabled')+' as a demo preference. No audio plays.';$('signout').onclick=()=>{clear();active=false;$('settings-status').textContent='Simulated sign out complete. Your real account was not accessed.'};
</script></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="a" x2="1" y2="1"><stop stop-color="#f5b6d2"/><stop offset="1" stop-color="#a79aef"/></linearGradient><linearGradient id="b" x2="1" y2="1"><stop stop-color="#d2baf5"/><stop offset="1" stop-color="#b8d8f4"/></linearGradient></defs><rect width="100" height="100" rx="23" fill="#302438"/><path d="M24 76V31c0-12 9-17 22-17h9c15 0 22 8 22 18 0 17-16 20-31 26l-9 5v13H24Zm13-28 18-7c6-2 9-4 9-9 0-4-3-6-9-6H44c-5 0-7 2-7 7v15Z" fill="url(#a)"/><path d="m43 53 13-6 24 29H63L43 53Z" fill="url(#b)"/><path d="m43 53 13-6 7 8-13 6Z" fill="#776593" opacity=".45"/></svg>

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light"><title>Hope · Ribbon</title><link rel="icon" href="ribbon-icon.svg"><style>
:root{--white:#fff;--ink:#302438;--muted:#73677d;--pink:#edb1ce;--lavender:#b8a6e8;--blue:#adcef3;--surface:#f6f4f9;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:#faf9fc;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{color:inherit;cursor:pointer;min-width:44px;min-height:44px;border:0;background:none}button:hover{background-color:#f1edf6}button:disabled{opacity:.38;cursor:default}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #8060a6;outline-offset:4px}a{color:inherit}svg{display:block}[hidden]{display:none!important}.showcase{max-width:1150px;margin:auto;min-height:100vh;display:grid;grid-template-columns:1fr 430px;gap:120px;align-items:center;padding:45px 60px}.intro{max-width:450px}.back{display:inline-flex;align-items:center;min-height:44px;font-size:14px;text-decoration:none;color:var(--muted)}.direction{font-size:14px;margin-top:50px}.intro h1{font-size:76px;font-weight:450;letter-spacing:-.035em;line-height:1;margin:20px 0 25px}.intro .lead{font-size:23px;line-height:1.5;letter-spacing:-.02em;margin:0;max-width:26ch}.description{font-size:15px;line-height:1.7;color:var(--muted);max-width:38ch;margin:25px 0 0}.identity{display:flex;align-items:center;gap:28px;margin:40px 0}.large-icon{width:104px;height:104px;filter:drop-shadow(0 8px 12px #30243816)}.small-icon{width:32px;height:32px;margin-bottom:12px}.identity span{font-size:12px;color:var(--muted)}.note{font-size:12px;line-height:1.7;color:var(--muted);max-width:42ch}.phone{width:410px;padding:9px;background:linear-gradient(120deg,#fefefe,#e5e3e8 45%,#fff 75%);border-radius:55px;box-shadow:0 25px 70px #43335016,0 2px 3px #fff inset}.screen{width:390px;height:800px;position:relative;overflow:hidden;background:white;border-radius:46px;display:flex;flex-direction:column}.statusbar{height:56px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 29px;font-size:14px;font-weight:650}.island{position:absolute;left:50%;top:14px;transform:translateX(-50%);width:108px;height:29px;background:#211e25;border-radius:24px}.signals{font-size:11px;letter-spacing:2px}.topbar{display:flex;align-items:center;justify-content:space-between;padding:9px 25px 0;height:67px}.wordmark{font-size:25px;font-weight:550;letter-spacing:-.04em}.top-actions{display:flex;gap:3px}.icon{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.icon-button{display:grid;place-items:center;border-radius:50%}.voice-home{position:relative;flex:1;min-height:0;display:flex;flex-direction:column}.welcome{padding:27px 29px 0}.welcome h2{font-size:45px;line-height:1.09;font-weight:450;letter-spacing:-.035em;margin:0}.welcome p{font-size:14px;line-height:1.6;color:var(--muted);margin:14px 0 0}.sculpture{position:relative;height:180px;flex:none;margin-top:0;display:grid;place-items:center}.mesh{width:460px;height:190px;position:absolute;left:50%;transform:translateX(-50%)}.mesh path{fill:none;stroke:url(#ribbon-gradient);stroke-width:.7}.sculpture:before{content:"";position:absolute;width:310px;height:100px;background:radial-gradient(ellipse,#e8dcf54a,transparent 70%);filter:blur(15px)}.voice-status{text-align:center;min-height:56px;margin-top:-6px}.voice-status strong{font-weight:500;font-size:15px;display:block}.timer{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);display:block;margin-top:7px;letter-spacing:.08em}.controls{display:flex;align-items:center;justify-content:center;gap:31px;height:143px;flex:none}.minor{width:46px;height:46px;border-radius:50%;background:#f7f5fa;display:grid;place-items:center}.mic{width:83px;height:83px;border-radius:50%;position:relative;background:linear-gradient(130deg,#fff 25%,#f1e9f8 85%);box-shadow:0 6px 18px #ad91bf20,inset 0 0 0 1px #fff;display:grid;place-items:center;isolation:isolate}.mic:before,.mic:after{content:"";position:absolute;z-index:-1;border-radius:50%;inset:-14px;background:conic-gradient(from 50deg,#d9e6fa77,#eee2fb55,#f6d8e977,#d9e6fa77)}.mic:after{inset:-27px;background:conic-gradient(from 220deg,#f7e4ed55,#e8e4f944,#e0edfa55,#f7e4ed55)}.mic .icon{width:30px;height:30px;stroke-width:1.45}.state-listening .mic:before{animation:breathe 2.8s ease-in-out infinite}.state-working .mic:before{animation:breathe 1.6s ease-in-out infinite}.transcript-toggle{align-self:center;font-size:12px;color:var(--muted);margin-top:-5px}.composer{margin:9px 23px 0;padding:6px 7px 6px 18px;display:flex;align-items:center;background:#fbfafd;box-shadow:0 5px 24px #6d4c9010;border-radius:50px;gap:7px}.composer input{width:100%;min-width:0;border:0;background:none;font-size:13px;color:var(--ink);height:40px;outline-offset:2px}.composer input::placeholder{color:var(--muted)}.composer button{border-radius:50%;display:grid;place-items:center;background:#f1ecf7}.demo-note{tLine truncated
@media(max-width:850px){.showcase{gap:45px;padding:35px;grid-template-columns:1fr 410px}.intro h1{font-size:60px}.intro .lead{font-size:19px}}
@media(max-width:700px){body{background:white}.showcase{display:block;padding:0;min-height:100dvh}.intro,.caption{display:none}.device-wrap{width:100%;display:flex;justify-content:center}.phone{width:100%;padding:0;border-radius:0;background:white;box-shadow:none}.screen{width:100%;max-width:430px;height:max(800px,100dvh);border-radius:0;margin:auto}.mobile-back{display:inline-flex!important;position:absolute;top:7px;left:16px;z-index:3;font-size:12px;min-height:44px;align-items:center;text-decoration:none}.statusbar{visibility:hidden}.island{display:none}.welcome{padding-top:26px}.sculpture{margin-top:20px}.voice-home{justify-content:flex-start}.demo-note{margin-top:auto;padding-top:13px}.home-indicator{margin-bottom:max(9px,env(safe-area-inset-bottom))}}
.welcome{padding-top:17px}.welcome h2{font-size:42px;line-height:1.05}.welcome p{margin-top:11px;font-size:13px}.sculpture{height:137px;margin-top:0}.voice-status{min-height:49px}.controls{height:111px}.transcript-toggle{min-height:44px;margin-top:0}.composer{margin-top:2px}.demo-note{font-size:9px;margin-top:12px}.text-mode .sculpture{height:150px}.text-mode .voice-status{margin-bottom:15px}@media(max-width:700px){.statusbar{height:44px}.topbar{height:60px}.welcome{padding-top:16px}.sculpture{height:142px;margin-top:0}.controls{height:108px}.demo-note{margin-top:auto;padding-top:8px}.transcript-toggle{min-height:44px}.home-indicator{margin-top:12px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
</style></head><body>
<svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><symbol id="mic-icon" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="13" rx="3"/><path d="M5 10v3a7 7 0 0 0 14 0v-3M12 20v2M8 22h8"/></symbol><symbol id="activity-icon" viewBox="0 0 24 24"><path d="M4 5h16M4 12h16M4 19h10"/></symbol><symbol id="settings-icon" viewBox="0 0 24 24"><path d="M4 6h16M4 18h16"/><circle cx="9" cy="6" r="3" fill="white"/><circle cx="16" cy="18" r="3" fill="white"/></symbol><symbol id="send-icon" viewBox="0 0 24 24"><path d="M12 19V5m-6 6 6-6 6 6"/></symbol></defs></svg>
<main class="showcase"><aside class="intro"><a class="back" href="new-index.html">← Explore the new directions</a><p class="direction">Hope / Ribbon</p><h1>A voice,<br>given shape.</h1><p class="lead">Room for your thoughts.<br>A little less interface.</p><p class="description">A diaphanous ribbon follows the conversation. Pink folds into lavender, then blue. Your voice is the centre; the transcript is a quiet companion.</p><div class="identity"><img class="large-icon" src="ribbon-icon.svg" alt="Ribbon R app icon"><div><img class="small-icon" src="ribbon-icon.svg" alt="Ribbon icon at 32 pixels"><span>A folded R. Two strands, one voice.</span></div></div><p class="note">Interactive design study. All conversations are simulated sample data. No network requests, microphone permissions, audio playback, or account connection.</p></aside><section class="device-wrap" aria-label="Interactive Ribbon phone"><div><div class="phone"><div class="screen" id="screen"><a class="mobile-back" href="new-index.html" hidden>← New directions</a><div class="statusbar" aria-hidden="true"><span>9:41</span><span class="island"></span><span class="signals">▮▮▮ ◒ ▰</span></div><header class="topbar"><span class="wordmark">hope</span><div class="top-actions"><button class="icon-button" id="activity" aria-label="Open Activity"><svg class="icon"><use href="#activity-icon"/></svg></button><button class="icon-button" id="settings" aria-label="Open Settings"><svg class="icon"><use href="#settings-icon"/></svg></button></div></header>
<section class="voice-home" id="home"><div class="welcome"><h2 id="headline">Say it<br>your way.</h2><p id="subline">A thought, a question, a little of everything.<br>Start wherever you are.</p></div><div class="sculpture" aria-hidden="true"><svg class="mesh" viewBox="0 0 440 230"><defs><linearGradient id="ribbon-gradient" x1="0" x2="1"><stop stop-color="#eeb5cf"/><stop offset=".35" stop-color="#e8b4db"/><stop offset=".62" stop-color="#bfa7e4"/><stop offset="1" stop-color="#a6c9ec"/></linearGradient></defs><g id="mesh-lines"></g></svg></div><div class="voice-status" role="status" aria-live="polite"><strong id="status">A little space to speak</strong><span class="timer" id="timer" aria-label="Elapsed recording time">00:00</span></div><div class="controls"><button class="minor" id="pause" aria-label="Pause simulated recording" disabled><svg class="icon" viewBox="0 0 24 24"><path d="M9 6v12M15 6v12"/></svg></button><button class="mic" id="mic" aria-label="Start simulated listening"><svg class="icon"><use href="#mic-icon"/></svg></button><button class="minor" id="end" aria-label="End simulated recording" disabled><svg class="icon" viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="1"/></svg></button></div><button id="transcript-toggle" class="transcript-toggle" aria-expanded="false" aria-controls="transcript-sheet">Show transcript ↗</button><form class="composer" id="composer"><label class="sr-only" for="message">Type a message to Hope</label><input id="message" placeholder="Or let a few words out…" maxlength="1000" autocomplete="off"><button id="text-button" type="button" aria-label="Switch to text mode"><svg class="icon" viewBox="0 0 24 24"><path d="M5 7h14M12 7v11M8 18h8"/></svg></button><button id="send" type="submit" aria-label="Send sample text message" hidden><svg class="icon"><use href="#send-icon"/></svg></button></form><p class="mode-label" id="mode-label" hidden>Text mode · <button id="voice-button" type="button">Back to voice</button></p><p class="demo-note">SIMULATED DEMO · Your microphone stays off<br>Sample replies only. Nothing leaves this page.</p></section>
<section class="panel" id="activity-panel" hidden><button class="back" id="activity-back">← Back to voice</button><h2>Activity</h2><p>A few thoughts you’ve shared.<br>Local sample data, not a real history.</p><div id="events"></div><button class="row" id="sample">A little room in the day<small>Today · Sample conversation · View transcript ↗</small></button><div class="row">A train to the coast<small>Yesterday · Sample conversation</small></div></section><section class="panel" id="settings-panel" hidden><button class="back" id="settings-back">← Back to voice</button><h2>Make it yours.</h2><p>Preferences for this prototype only.<br>No audio is played.</p><label class="setting">Sample voice<select id="voice"><option>Ava</option><option>Emma</option><option>Andrew</option></select></label><label class="setting">Spoken replies<input id="spoken" type="checkbox" checked></label><p id="preference-note" role="status">Your preferences stay here until you reload.</p><h3>Private by design</h3><p>This is a local, simulated design study. It does not request your microphone, contact a server, or connect to an account.</p></section><section class="sheet" id="transcript-sheet" aria-label="Sample transcript" hidden><div class="sheet-head"><h3>A few words, kept here.</h3><button class="close" id="close-sheet" aria-label="Close transcript">×</button></div><p id="empty-transcript">Your transcript will appear here after a simulated voice turn or text message.</p><div id="transcript" role="log" aria-live="polite"></div><small>Sample transcript · No audio was recorded</small></section><div class="home-indicator" aria-hidden="true"></div></div></div><p class="caption">RIBBON · Voice-first, sculptural, quietly alive.</p></div></section></main>
<script>
(()=>{'use strict';const $=id=>document.getElementById(id),reduced=matchMedia('(prefers-reduced-motion: reduce)');let state='idle',elapsed=0,last=0,phase=0,mode='voice',tick=null,replyTimer=null,focusReturn=null;const sampleQuestion='Help me find a little room in my day.',sampleReply='Let one thing wait. Take ten minutes for a walk, a slow cup of tea, or simply nothing. You don’t have to fill every little space.';const paths=[];for(let i=0;i<72;i++){const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('opacity',String(.2+.35*Math.sin(Math.PI*i/71)));$('mesh-lines').append(p);paths.push(p)}
function draw(t=0){paths.forEach((p,i)=>{const v=(i/71-.5)*2;let d='';for(let x=0;x<=440;x+=4){const u=x/440,envelope=Math.pow(Math.sin(Math.PI*u),.75);const center=115+32*Math.sin(u*Math.PI*2-1.15+t*.13)*envelope;const spread=(48+27*Math.cos(u*Math.PI*3+.3))*envelope;const twist=Math.sin(u*Math.PI*2.4+v*1.3+t*.25)*25*envelope;const y=center+v*spread*Math.cos(u*Math.PI*1.7+t*.08)+twist;d+=(x?'L':'M')+x.toFixed(1)+','+y.toFixed(2)}p.setAttribute('d',d)})}draw();function frame(time){if(!reduced.matches&&['listening','working','reply'].includes(state)&&time-last>65){phase+=.035;draw(phase);last=time}requestAnimationFrame(frame)}requestAnimationFrame(frame);
function update(next){state=next;$('home').classList.remove('state-listening','state-working');if(['listening','working'].includes(next))$('home').classList.add('state-'+next);const labels={idle:'A little space to speak',listening:'Listening to you…',paused:'Take your time. Paused.',working:'Finding the words…',reply:'A little room, just for you.'};$('status').textContent=labels[next];$('mic').disabled=next==='working';$('mic').setAttribute('aria-label',next==='listening'?'Finish simulated listening':'Start simulated listening');$('pause').disabled=!['listening','paused'].includes(next);$('pause').setAttribute('aria-label',next==='paused'?'Resume simulated recording':'Pause simulated recording');$('pause').setAttribute('aria-pressed',String(next==='paused'));$('end').disabled=!['listening','paused','working'].includes(next)}
function clock(){const s=Math.floor(elapsed);$('timer').textContent=String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0')}
function clear(){clearInterval(tick);clearTimeout(replyTimer);tick=null;replyTimer=null}
function add(role,text){$('empty-transcript').hidden=true;const section=document.createElement('div'),label=document.createElement('small'),p=document.createElement('p');label.textContent=role;p.textContent=text;section.append(label,p);$('transcript').append(section)}
function event(text){const row=document.createElement('div');row.className='row';row.textContent=text;const small=document.createElement('small');small.textContent='Just now · Local demo';row.append(small);$('events').prepend(row)}
function finish(text=sampleQuestion){clear();add('You',text);update('working');replyTimer=setTimeout(()=>{add('Hope',sampleReply);update('reply');$('headline').textContent='A little room.\nJust for you.';$('headline').style.whiteSpace='pre-line';$('subline').textContent='A sample reply, ready when you are.';event(mode==='text'?'A written thought':'A little room in the day');},1500)}
function start(){clear();elapsed=0;clock();$('headline').textContent='Say it\nyour way.';$('headline').style.whiteSpace='pre-line';$('subline').textContent='This is simulated listening. Your mic is off.';update('listening');tick=setInterval(()=>{if(state==='listening'){elapsed+=.25;clock();if(elapsed>=5)finish()}},250)}
function page(which){clear();update('idle');closeSheet();$('home').hidden=which!=='home';$('activity-panel').hidden=which!=='activity';$('settings-panel').hidden=which!=='settings';if(which!=='home')$(which+'-back').focus()}
function openSheet(){focusReturn=document.activeElement;$('transcript-sheet').hidden=false;$('transcript-toggle').setAttribute('aria-expanded','true');$('close-sheet').focus()}
function closeSheet(){if($('transcript-sheet').hidden)return;$('transcript-sheet').hidden=true;$('transcript-toggle').setAttribute('aria-expanded','false');if(focusReturn&&focusReturn.isConnected)focusReturn.focus()}
function setMode(next){clear();update('idle');mode=next;$('home').classList.toggle('text-mode',next==='text');$('text-button').hidden=next==='text';$('send').hidden=next!=='text';$('mode-label').hidden=next!=='text';$('status').textContent=next==='text'?'Some things are easier to write.':'A little space to speak';$('message').placeholder=next==='text'?'Write what’s on your mind…':'Or let a few words out…';if(next==='text')$('message').focus()}
$('mic').onclick=()=>state==='listening'?finish():start();$('pause').onclick=()=>update(state==='paused'?'listening':'paused');$('end').onclick=()=>{clear();update('idle');$('subline').textContent='The simulated recording has ended. Start again anytime.'};$('activity').onclick=()=>page('activity');$('settings').onclick=()=>page('settings');$('activity-back').onclick=$('settings-back').onclick=()=>page('home');$('transcript-toggle').onclick=openSheet;$('close-sheet').onclick=closeSheet;document.addEventListener('keydown',e=>{if(e.key==='Escape')closeSheet()});$('text-button').onclick=()=>setMode('text');$('voice-button').onclick=()=>setMode('voice');$('message').addEventListener('focus',()=>{if(mode==='voice')setMode('text')});$('composer').onsubmit=e=>{e.preventDefault();const text=$('message').value.trim();if(!text){$('message').focus();return}if(mode!=='text')setMode('text');$('message').value='';finish(text)};$('sample').onclick=()=>{if(!$('transcript').children.length){add('You',sampleQuestion);add('Hope',sampleReply)}openSheet()};$('voice').onchange=()=>{$('preference-note').textContent=$('voice').value+' selected for the simulated voice. No audio plays.'};$('spoken').onchange=()=>{$('preference-note').textContent='Spoken replies '+($('spoken').checked?'enabled':'disabled')+' as a demo preference. No audio plays.'};
})();
</script></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title"><title id="title">Hope SIGNAL — three audio bars forming an h</title><rect width="256" height="256" rx="56" fill="#10272d"/><g fill="#9ee3e9"><rect x="66" y="54" width="28" height="148" rx="3"/><rect x="114" y="108" width="28" height="94" rx="3"/><rect x="162" y="88" width="28" height="114" rx="3"/><rect x="80" y="108" width="96" height="26"/></g></svg>

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Hope — SIGNAL design study</title>
<style>
:root{--petroleum:#10272d;--cyan:#9ee3e9;--ink:#eff7f7;--muted:#b3c7ca;--line:#345057;--panel:#173239;--page:#0c1c21;--mono:ui-monospace,"SFMono-Regular",Menlo,monospace;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif;-webkit-font-smoothing:antialiased}button,input,select{font:inherit}button{cursor:pointer;color:inherit;background:none;border:0;min-height:44px}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--cyan);outline-offset:4px}button:disabled{cursor:default;opacity:.5}button:hover:not(:disabled){background-color:#25464d}a{color:var(--cyan)}[hidden]{display:none!important}.presentation{max-width:1120px;margin:auto;min-height:100vh;display:grid;grid-template-columns:1fr 390px;gap:100px;align-items:center;padding:64px 72px}.intro{max-width:390px}.brand{display:flex;align-items:center;gap:12px;font-size:19px;font-weight:600}.brand svg{width:32px;height:32px}.direction{font-family:var(--mono);font-size:12px;color:var(--cyan);margin:52px 0 16px}.intro h1{font-size:64px;line-height:1.03;font-weight:450;letter-spacing:-.035em;margin:0 0 26px}.intro>p{font-size:17px;line-height:1.65;color:var(--muted);max-width:34ch}.principles{margin:32px 0 40px;padding:0;list-style:none}.principles li{padding:12px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:20px;font-size:14px}.principles span:last-child{color:var(--muted);text-align:right}.icon-study{display:flex;align-items:flex-end;gap:24px}.icon-study svg:first-child{width:104px;height:104px}.icon-study svg.small{width:32px;height:32px}.icon-caption{font-size:12px;color:var(--muted);line-height:1.5;margin-top:12px}.demo-note{font-size:12px!important;margin-top:32px;color:var(--muted)}.phone{width:390px;height:800px;padding:9px;border:1px solid #456067;border-radius:49px;background:#08171c;position:relative}.screen{height:100%;background:var(--petroleum);border-radius:40px;overflow:hidden;display:flex;flex-direction:column;position:relative}.statusbar{height:45px;flex-shrink:0;padding:15px 25px 0;display:flex;justify-content:space-between;font-size:13px;font-weight:600}.island{position:absolute;top:11px;left:50%;transform:translateX(-50%);height:25px;width:104px;background:#08171c;border-radius:20px}.status-icons{display:flex;align-items:center;gap:5px}.status-icons svg{width:17px;height:12px}.app-header{padding:17px 24px 10px;display:flex;align-items:center;justify-content:space-between}.wordmark{font-size:25px;font-weight:550;letter-spacing:-.03em}.sim-label{font-family:var(--mono);font-size:10px;color:var(--muted);border:1px solid var(--line);padding:5px 7px;border-radius:4px}.view{flex:1;min-height:0;overflow-y:auto}.talk{display:flex;flex-direction:column;height:100%;padding:0 24px}.state-line{display:flex;justify-content:space-between;align-items:center;color:var(--cyan);font:11px var(--mono);margin-top:23px}.voice-zone{padding:40px 0 12px;text-align:center}.voice-zone h2{margin:0;font-size:39px;letter-spacing:-.035em;line-height:1.13;font-weight:450;min-height:89px}.voice-zone h2 span{display:block}.wave{width:100%;height:85px;margin:12px 0 2px;display:block;color:var(--cyan)}.wave line{stroke:var(--line);stroke-width:1}.wave path{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.voice-hint{margin:0 0 16px;color:var(--muted);font-size:12px}.primary{background:var(--cyan);color:var(--petroleum);border:1px solid var(--cyan);border-radius:12px;font-weight:600;width:100%;height:57px;display:flex;align-items:center;justify-content:center;gap:10px;font-size:15px}.primary:hover:not(:disabled){background:#b8eff2!important}.primary svg,.secondary svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7}.session-controls{display:flex;gap:10px}.session-controls .primary{flex:1}.secondary{border:1px solid var(--line);border-radius:12px;display:flex;gap:7px;align-items:center;justify-content:center;min-width:92px;font-size:13px}.transcript-area{margin-top:25px;padding-top:8px;border-top:1px solid var(--line);flex:1;min-height:115px;display:flex;flex-direction:column}.transcript-header{display:flex;align-items:center;justify-content:space-between}.transcript-header p{font:10px var(--mono);color:var(--muted);margin:0}.mode-toggle{display:flex;gap:2px}.mode-toggle button{min-width:48px;font-size:12px;color:var(--muted);padding:0 8px}.mode-toggle button[aria-pressed=true]{color:var(--cyan)}.messages{overflow:auto;max-height:142px;scroll-behavior:smooth;padding-bottom:12px}.message{margin:9px 0 16px}.message .who{color:var(--muted);font:10px var(--mono);display:flex;gap:10px;margin-bottom:6px}.message p{font-size:14px;line-height:1.5;margin:0}.message.answer p{color:var(--cyan)}.composer{display:flex;gap:8px;margin:8px 0 15px}.composer input{width:100%;min-width:0;height:48px;background:var(--panel);border:1px solid var(--linLine truncated
@media(max-width:980px){.presentation{gap:40px;padding:40px;grid-template-columns:1fr 390px}.intro h1{font-size:52px}}@media(max-width:740px){.presentation{display:flex;flex-direction:column;padding:30px 16px;gap:30px}.intro{max-width:390px;width:100%}.brand{font-size:17px}.direction{margin-top:26px}.intro h1{font-size:44px}.intro>p{font-size:15px;max-width:40ch}.principles{margin:22px 0}.icon-study{display:none}.icon-caption{display:none}.demo-note{margin-top:18px}.phone{max-width:100%;width:390px}.presentation{padding-bottom:30px}}@media(max-width:410px){.presentation{padding-left:8px;padding-right:8px}.phone{border-radius:37px;padding:5px;height:800px}.screen{border-radius:32px}.intro{padding:0 12px}.talk{padding:0 20px}.voice-zone h2{font-size:36px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
</style></head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="hope-icon" viewBox="0 0 256 256"><rect width="256" height="256" rx="56" fill="#10272d"/><g fill="#9ee3e9"><rect x="66" y="54" width="28" height="148" rx="3"/><rect x="114" y="108" width="28" height="94" rx="3"/><rect x="162" y="88" width="28" height="114" rx="3"/><rect x="80" y="108" width="96" height="26"/></g></symbol><symbol id="mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></symbol></defs></svg>
<main class="presentation">
<aside class="intro"><a href="index.html" style="display:inline-flex;align-items:center;min-height:44px;font-size:14px;text-decoration:none;margin-bottom:18px">← All three directions</a><div class="brand"><svg aria-hidden="true"><use href="#hope-icon"/></svg>hope</div><div class="direction">Design direction / SIGNAL</div><h1>A little space<br>to think<br>out loud.</h1><p>A voice-first companion with the calm precision of an audio instrument. Less to look at. More room to be heard.</p><ul class="principles"><li><span>Petroleum + soft cyan</span><span>Low-light, clear contrast</span></li><li><span>A line with a purpose</span><span>Voice state, not decoration</span></li><li><span>Your words stay close</span><span>A readable conversation</span></li></ul><div class="icon-study"><svg role="img" aria-label="Hope audio bar h icon, large"><use href="#hope-icon"/></svg><svg class="small" role="img" aria-label="Hope icon at 32 pixels"><use href="#hope-icon"/></svg></div><div class="icon-caption">Three audio bars. One unmistakable h.<br>104 px / 32 px · solid, never glowing.</div><p class="demo-note">Interactive design study · Sample data only.<br>No microphone, network, or account access.</p></aside>
<section class="phone" aria-label="Interactive Hope iOS design demo"><div class="screen"><div class="island" aria-hidden="true"></div><div class="statusbar" aria-hidden="true"><span>9:41</span><span class="status-icons"><svg viewBox="0 0 20 14" fill="currentColor"><path d="M1 10h3v4H1zm5-3h3v7H6zm5-3h3v10h-3zm5-3h3v13h-3z"/></svg><svg viewBox="0 0 22 12" fill="none" stroke="currentColor"><rect x="1" y="1" width="17" height="10" rx="2"/><path d="M20 4v4"/><path fill="currentColor" d="M3 3h13v6H3z"/></svg></span></div><header class="app-header"><span class="wordmark">hope</span><span class="sim-label">LOCAL SIMULATION</span></header>
<div class="view" id="talk-view"><div class="talk"><div class="state-line"><span id="state-label" role="status">READY WHEN YOU ARE</span><span id="timer">00:00</span></div><div class="voice-zone"><h2 id="voice-title"><span>Go ahead.</span><span>I’m listening.</span></h2><svg class="wave" viewBox="0 0 320 85" role="img" aria-label="Voice signal: ready"><line x1="0" x2="320" y1="43" y2="43"/><path id="wave-path" d="M0 43 L320 43"/></svg><p class="voice-hint" id="voice-hint">Tap to begin a simulated conversation</p></div><div class="session-controls"><button class="primary" id="start"><svg aria-hidden="true"><use href="#mic"/></svg><span>Start talking</span></button><button class="secondary" id="mute" hidden aria-pressed="false">Mute</button><button class="primary" id="end" hidden>End conversation</button></div><div class="transcript-area"><div class="transcript-header"><p>SAMPLE CONVERSATION</p><div class="mode-toggle" aria-label="Conversation input"><button id="voice-mode" aria-pressed="true">Voice</button><button id="text-mode" aria-pressed="false">Text</button></div></div><div class="messages" id="messages" role="log" aria-label="Conversation transcript" aria-live="polite"><article class="message"><div class="who">YOU <span>09:40</span></div><p>Help me make a little room in my day.</p></article><article class="message answer"><div class="who">HOPE <span>09:40</span></div><p>Let’s start with one thing. What can wait until tomorrow?</p></article></div><form class="composer" id="composer" hidden><input id="text-input" aria-label="Message Hope" placeholder="Think out loud, in text…" maxlength="1200" autocomplete="off"><button type="submit" aria-label="Send message">↑</button></form></div></div></div>
<div class="view inner-view" id="activity-view" hidden><h2>Activity</h2><p class="view-description">A quiet place for the things you’ve asked Hope to work on.</p><article class="task"><div class="task-top"><span>A little room in your day</span><span class="task-tag" id="task-status">READY</span></div><p id="task-description">A sample task. Start a conversation to watch its progress.</p><progress id="task-progress" max="100" value="0" aria-label="Sample task progress"></progress><button id="task-toggle" aria-expanded="false">View steps</button><div class="task-detail" id="task-detail" hidden><p>1. Listen to what needs your attention<br>2. Find one thing that can wait<br>3. Write a simple next step</p></div></article><article class="task"><div class="task-top"><span>Weekend walking ideas</span><span class="task-tag">DONE</span></div><p>Three easy ways to get outside.<br>Yesterday · 4:12 PM · Sample data</p><button id="sample-result" aria-expanded="false">Read result</button><div id="sample-detail" class="task-detail" hidden><p>Take a short park loop, walk to a nearby café, or try a new neighborhood. Pick the easiest one and leave the rest for another day.</p></div></article><p class="sample-caption">Demo tasks only. Nothing is scheduled or saved.</p></div>
<div class="view inner-view" id="settings-view" hidden><h2>Settings</h2><p class="view-description">Make the conversation feel like yours.</p><div class="setting"><label for="voice-choice">Hope’s voice</label><select id="voice-choice"><option>Ava · warm & clear</option><option>Andrew · calm & grounded</option><option>Emma · bright & natural</option></select><button id="preview" class="secondary" style="margin-top:12px;width:100%">Preview voice · simulation</button><p class="feedback" id="voice-feedback" role="status"></p></div><div class="setting"><label for="speed">Speaking pace <span id="speed-label" style="float:right;font-family:var(--mono)">1.0×</span></label><input type="range" id="speed" min="0.8" max="1.2" step="0.1" value="1"><small>Give each thought a little breathing room.</small></div><div class="setting setting-row"><span class="setting-name">Keep transcript visible</span><button id="transcript-switch" class="switch" role="switch" aria-checked="true" aria-label="Keep transcript visible"></button></div><div class="setting"><span class="setting-name">Demo account</span><small>[email protected] · Sample data<br>Settings apply only to this open demo.</small><button class="signout" id="signout">Sign out · simulated</button><div class="account-confirm" id="account-confirm" hidden>Simulate signing out? Your real account is never accessed.<br><button id="confirm-signout">Simulate sign out</button> <button id="cancel-signout">Cancel</button></div><p id="account-feedback" class="feedback" role="status"></p></div></div>
<nav class="nav" aria-label="Main sections"><button data-view="talk" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h2l2-5 3 10 3-13 3 15 2-7h3"/></svg>Talk</button><button data-view="activity"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5h14M5 12h14M5 19h9"/><circle cx="19" cy="19" r="2"/></svg>Activity</button><button data-view="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/><path d="M8 3v6m8 0v6M10 15v6"/></svg>Settings</button></nav><div class="home-indicator" aria-hidden="true"></div></div></section></main>
<script>
'use strict';
const $=id=>document.getElementById(id);let state='idle',muted=false,seconds=0,timers=[],clock=null,phase=0;const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
function clearTimers(){timers.forEach(clearTimeout);timers=[];clearInterval(clock);clock=null;}
function later(fn,delay){timers.push(setTimeout(fn,delay));}
function message(role,text){const article=document.createElement('article');article.className='message'+(role==='HOPE'?' answer':'');const who=document.createElement('div');who.className='who';who.textContent=role+' · DEMO';const p=document.createElement('p');p.textContent=text;article.append(who,p);$('messages').append(article);$('messages').scrollTop=$('messages').scrollHeight;}
function title(a,b){$('voice-title').replaceChildren();[a,b].forEach(text=>{if(text){const span=document.createElement('span');span.textContent=text;$('voice-title').append(span);}});}
function task(value,status,description){$('task-progress').value=value;$('task-status').textContent=status;$('task-description').textContent=description;}
function render(){const active=state!=='idle';$('start').hidden=active;$('mute').hidden=!active;$('end').hidden=!active;$('mute').textContent=muted?'Unmute':'Mute';$('mute').setAttribute('aria-pressed',String(muted));const descriptions={idle:'ready',listening:'listening',working:'working',answer:'answering'};document.querySelector('.wave').setAttribute('aria-label','Voice signal: '+(muted?'muted':descriptions[state]));if(muted){$('state-label').textContent='MICROPHONE MUTED · DEMO';title('Take your time.','I’m here.');$('voice-hint').textContent='Unmute to continue the simulation';}else if(state==='idle'){$('state-label').textContent='READY WHEN YOU ARE';title('Go ahead.','I’m listening.');$('voice-hint').textContent='Tap to begin a simulated conversation';}else if(state==='listening'){$('state-label').textContent='LISTENING · DEMO';title('Go ahead.','I’m listening.');$('voice-hint').textContent='Sample voice input · no microphone access';}else if(state==='working'){$('state-label').textContent='WORKING · DEMO';title('One thought','at a time.');$('voice-hint').textContent='Finding a little room in your day';}else{$('state-label').textContent='HOPE IS SPEAKING · DEMO';title('Make room','for what matters.');$('voice-hint').textContent='Simulated answer · no audio is played';}draw();}
function draw(){let d='';for(let x=0;x<=320;x+=2){const envelope=Math.pow(Math.sin(Math.PI*x/320),4);let y=43;if(state!=='idle'&&!muted){const amp=state==='listening'?23:state==='working'?6:17;y+=Math.sin(x*.18+phase)*Math.sin(x*.035+phase*.4)*envelope*amp;}d+=(x?' L':'M')+x+' '+y.toFixed(2);}$('wave-path').setAttribute('d',d);}
function stages(){later(()=>{if(state!=='listening'||muted)return;message('YOU','Help me make a little room in my day.');state='working';task(45,'WORKING','Finding one thing you can move to tomorrow.');render();later(()=>{if(state!=='working'||muted)return;state='answer';task(100,'DONE','One next step, ready to read in your transcript.');message('HOPE','You don’t have to do it all today. Move one non-urgent task to tomorrow, and keep ten minutes just for yourself.');render();later(()=>{if(state==='answer'&&!muted){state='listening';render();$('voice-hint').textContent='Try text, or end when you’re ready';}},4500);},2300);},3000);}
function start(){clearTimers();muted=false;state='listening';seconds=0;$('timer').textContent='00:00';task(15,'LISTENING','Listening to the sample request.');clock=setInterval(()=>{seconds++;$('timer').textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0');},1000);render();stages();}
$('start').onclick=start;$('end').onclick=()=>{clearTimers();state='idle';muted=false;render();$('state-label').textContent='CONVERSATION ENDED · DEMO';$('voice-hint').textContent='Your sample transcript is still here';};
$('mute').onclick=()=>{muted=!muted;timers.forEach(clearTimeout);timers=[];render();if(!muted){state='listening';render();stages();}};
document.querySelectorAll('[data-view]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-view]').forEach(b=>b.removeAttribute('aria-current'));button.setAttribute('aria-current','page');['talk','activity','settings'].forEach(v=>$(v+'-view').hidden=v!==button.dataset.view);});
function mode(text){$('composer').hidden=!text;$('voice-mode').setAttribute('aria-pressed',String(!text));$('text-mode').setAttribute('aria-pressed',String(text));$('messages').hidden=false;if(text)$('text-input').focus();}
$('voice-mode').onclick=()=>mode(false);$('text-mode').onclick=()=>mode(true);
$('composer').onsubmit=event=>{event.preventDefault();const input=$('text-input').value.trim();if(!input)return;message('YOU',input);$('text-input').value='';timers.forEach(clearTimeout);timers=[];state='working';muted=false;task(50,'WORKING','Preparing a local sample reply to your text.');render();later(()=>{state='answer';task(100,'DONE','Sample text reply added to the transcript.');message('HOPE','This is a local sample reply. Take one small next step, and let the rest wait for a moment.');render();later(()=>{clearInterval(clock);clock=null;state='idle';render();},3500);},1500);};
$('task-toggle').onclick=()=>{const show=$('task-detail').hidden;$('task-detail').hidden=!show;$('task-toggle').setAttribute('aria-expanded',String(show));$('task-toggle').textContent=show?'Hide steps':'View steps';};$('sample-result').onclick=()=>{const show=$('sample-detail').hidden;$('sample-detail').hidden=!show;$('sample-result').setAttribute('aria-expanded',String(show));$('sample-result').textContent=show?'Hide result':'Read result';};
$('voice-choice').onchange=()=>{$('voice-feedback').textContent='Demo voice changed to '+$('voice-choice').value.split(' · ')[0]+'.';};$('preview').onclick=()=>{$('voice-feedback').textContent='“Take your time. I’m here.” — '+$('voice-choice').value.split(' · ')[0]+'. Text preview only; no audio is played.';};$('speed').oninput=()=>{$('speed-label').textContent=Number($('speed').value).toFixed(1)+'×';};$('transcript-switch').onclick=()=>{const on=$('transcript-switch').getAttribute('aria-checked')!=='true';$('transcript-switch').setAttribute('aria-checked',String(on));$('messages').hidden=!on;};$('signout').onclick=()=>{$('account-confirm').hidden=false;};$('cancel-signout').onclick=()=>{$('account-confirm').hidden=true;};$('confirm-signout').onclick=()=>{clearTimers();state='idle';muted=false;render();$('account-confirm').hidden=true;$('account-feedback').textContent='Simulated sign out complete. No real account was changed.';};
if(!reduced){setInterval(()=>{if(state!=='idle'&&!muted){phase+=.3;draw();}},90);}render();
</script></body></html>
+11
View File
@@ -0,0 +1,11 @@
FROM nousresearch/hermes-agent@sha256:d4da4a40cd7a28aba983775d9fd31d94cbf153eeb0cb9e844d6d0f612b7c24db
USER root
ARG WEBUI_REVISION=62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12
RUN git clone https://github.com/nesquena/hermes-webui.git /opt/hermes-webui \
&& git -C /opt/hermes-webui checkout "$WEBUI_REVISION" \
&& uv pip install --python /opt/hermes/.venv/bin/python -r /opt/hermes-webui/requirements.txt \
&& chmod -R a+rX /opt/hermes-webui
ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \
HERMES_WEBUI_PYTHON=/opt/hermes/.venv/bin/python \
HERMES_WEBUI_DISABLE_LOCAL_VENV=1
USER 10000:10000
+14
View File
@@ -7,6 +7,20 @@ server {
client_max_body_size 16k; client_max_body_size 16k;
# Public certificate-only onboarding profile lives in the persistent cert
# mount. Never expose that directory: it also contains the private key.
location = /hope-server.mobileconfig {
alias /etc/voice/certs/hope-server.mobileconfig;
default_type application/x-apple-aspen-config;
add_header Cache-Control "no-store";
}
location = /voices {
proxy_pass http://127.0.0.1:8091/voices;
proxy_set_header Host $host;
proxy_read_timeout 30s;
}
location = /token { location = /token {
proxy_pass http://127.0.0.1:8091/token; proxy_pass http://127.0.0.1:8091/token;
proxy_set_header Host $host; proxy_set_header Host $host;
+14 -1
View File
@@ -4,13 +4,26 @@
```sh ```sh
python -m venv .venv-tests python -m venv .venv-tests
.venv-tests/bin/python -m pip install pytest fastapi httpx .venv-tests/bin/python -m pip install pytest pytest-asyncio -r control/requirements.txt
.venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py tests/unit/test_deploy_rollback.py -q .venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py tests/unit/test_deploy_rollback.py -q
agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v
``` ```
Covers authorization, revocation, CSRF, lifecycle/restart failures, Docker ownership, authenticated server-derived room routing, SSE framing, content-only output, cancellation, redacted errors, and wait/final-answer callbacks. Covers authorization, revocation, CSRF, lifecycle/restart failures, Docker ownership, authenticated server-derived room routing, SSE framing, content-only output, cancellation, redacted errors, and wait/final-answer callbacks.
## Azure English voice regression
```sh
.venv-tests/bin/python -m pytest --noconftest tests/unit/test_voice_catalog.py tests/unit/test_voice_settings_ui.py -q
```
Catalog tests mock Azure and the authenticated account boundary. Browser tests
require Playwright Chromium and use local assets with mocked APIs, never the NAS.
They cover the Ava Omni default, legacy HD preference migration, English accents,
search/selection, cache/failure recovery, and anonymous/revoked-account denial.
Derived Omni personas follow Microsoft's documented naming convention; individual
voice synthesis and regional availability still require live Azure qualification.
## Legacy tests ## Legacy tests
Top-level `test_*.py` and `conftest.py` describe the former OpenWebUI/MCP architecture and default to live Unraid. **Do not run them as the migrated regression suite.** Retained only as historical references: weather/dispatch/local memory and anonymous-token assumptions no longer apply. Hope Music remains an independent deployment. Top-level `test_*.py` and `conftest.py` describe the former OpenWebUI/MCP architecture and default to live Unraid. **Do not run them as the migrated regression suite.** Retained only as historical references: weather/dispatch/local memory and anonymous-token assumptions no longer apply. Hope Music remains an independent deployment.
+147 -6
View File
@@ -6,6 +6,8 @@ import os
import httpx import httpx
import pytest import pytest
import yaml
import socket
from control.provisioner import OwnershipError, Provisioner, ProvisioningError, container_name from control.provisioner import OwnershipError, Provisioner, ProvisioningError, container_name
@@ -39,6 +41,7 @@ class DockerBoundary:
spec = json.loads(request.content) spec = json.loads(request.content)
self.created.append(spec) self.created.append(spec)
self.containers[name] = {'Id': 'id-' + name, 'Config': copy.deepcopy(spec), self.containers[name] = {'Id': 'id-' + name, 'Config': copy.deepcopy(spec),
'HostConfig': copy.deepcopy(spec['HostConfig']),
'State': {'Running': False}, 'NetworkSettings': {'Ports': {}}} 'State': {'Running': False}, 'NetworkSettings': {'Ports': {}}}
return httpx.Response(201, json={'Id': 'id-' + name}) return httpx.Response(201, json={'Id': 'id-' + name})
key = path.split('/')[2] key = path.split('/')[2]
@@ -99,11 +102,12 @@ def test_account_isolation_and_runtime_configuration(boundary):
assert env['HERMES_UPSTREAM_API_KEY'] == 'runtime-provider-secret' assert env['HERMES_UPSTREAM_API_KEY'] == 'runtime-provider-secret'
assert env['API_SERVER_KEY'] == connection['hermes_api_key'] assert env['API_SERVER_KEY'] == connection['hermes_api_key']
assert env['API_SERVER_ENABLED'] == 'true' assert env['API_SERVER_ENABLED'] == 'true'
assert env['API_SERVER_HOST'] == '0.0.0.0' assert env['API_SERVER_HOST'] == '127.0.0.1'
assert env['API_SERVER_PORT'] == '8642' assert int(env['API_SERVER_PORT']) == int(connection['hermes_url'].rsplit(':', 1)[1])
host = spec['HostConfig'] host = spec['HostConfig']
assert host['NetworkMode'] == 'hope-hermes-net-' + user['id'] assert host['NetworkMode'] == 'host'
assert host['PortBindings']['8642/tcp'] == [{'HostIp': '127.0.0.1', 'HostPort': ''}] assert 'PortBindings' not in host
assert 'ExposedPorts' not in spec
assert host['Memory'] > 0 and host['NanoCpus'] > 0 and host['PidsLimit'] > 0 assert host['Memory'] > 0 and host['NanoCpus'] > 0 and host['PidsLimit'] > 0
assert host['Privileged'] is False and host['Init'] is True assert host['Privileged'] is False and host['Init'] is True
assert host['CapDrop'] == ['ALL'] assert host['CapDrop'] == ['ALL']
@@ -143,7 +147,8 @@ def test_retries_restart_reuse_key_and_preserve_home(boundary):
restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health, restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health,
upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid) upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid)
assert asyncio.run(restarted.ensure(ALICE)) == result assert asyncio.run(restarted.ensure(ALICE)) == result
assert json.loads((home / 'config.yaml').read_text()) == {'retained': True} assert yaml.safe_load((home / 'config.yaml').read_text()) == {
'retained': True, 'gateway': {'api_server': {'host': '127.0.0.1', 'port': p._ports()[ALICE['id']]}}}
asyncio.run(restarted.remove(ALICE)) asyncio.run(restarted.remove(ALICE))
asyncio.run(restarted.remove(ALICE)) asyncio.run(restarted.remove(ALICE))
assert docker.deleted == [('id-' + container_name(ALICE), {'force': 'true', 'v': 'false'})] assert docker.deleted == [('id-' + container_name(ALICE), {'force': 'true', 'v': 'false'})]
@@ -185,7 +190,8 @@ def test_untrusted_paths_and_public_bind_fail_closed(boundary):
asyncio.run(p.ensure(user)) asyncio.run(p.ensure(user))
asyncio.run(p.ensure(ALICE)) asyncio.run(p.ensure(ALICE))
info = docker.containers[container_name(ALICE)] info = docker.containers[container_name(ALICE)]
info['NetworkSettings']['Ports']['8642/tcp'][0]['HostIp'] = '0.0.0.0' info['Config']['Env'] = [e.replace('API_SERVER_HOST=127.0.0.1', 'API_SERVER_HOST=0.0.0.0')
for e in info['Config']['Env']]
assert not asyncio.run(p.ready(ALICE)) assert not asyncio.run(p.ready(ALICE))
with pytest.raises(ProvisioningError, match='loopback'): with pytest.raises(ProvisioningError, match='loopback'):
asyncio.run(p.ensure(ALICE)) asyncio.run(p.ensure(ALICE))
@@ -212,3 +218,138 @@ def test_upstream_runtime_overrides(monkeypatch):
assert provider['api'] == 'http://127.0.0.1:9999/v1' assert provider['api'] == 'http://127.0.0.1:9999/v1'
assert provider['models'] == ['served-model'] assert provider['models'] == ['served-model']
assert provider['default_model'] == 'served-model' assert provider['default_model'] == 'served-model'
def test_migrate_legacy_container_preserves_state_and_key(boundary):
p, docker, _ = boundary
old = asyncio.run(p.ensure(ALICE))
_, home, workspace = p._paths(ALICE)
(workspace / 'retained.txt').write_text('account work')
(home / 'config.yaml').write_text('custom: retained\ngateway:\n api_server:\n host: 0.0.0.0\n port: 8642\n')
info = docker.containers[container_name(ALICE)]
info['HostConfig']['NetworkMode'] = 'hope-hermes-net-' + ALICE['id']
net = info['HostConfig']['NetworkMode']
docker.networks[net] = {'Id': net, 'Name': net, 'Labels': info['Config']['Labels']}
assert not asyncio.run(p.ready(ALICE))
result = asyncio.run(p.ensure(ALICE))
assert result == old
assert len(docker.created) == 2 and len(docker.deleted) == 1
assert not docker.networks
assert (workspace / 'retained.txt').read_text() == 'account work'
config = yaml.safe_load((home / 'config.yaml').read_text())
assert config['custom'] == 'retained'
assert config['gateway']['api_server'] == {'host': '127.0.0.1', 'port': p._ports()[ALICE['id']]}
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 2
def test_ports_unique_concurrent_persisted_and_skip_busy_port(boundary):
p, docker, _ = boundary
with socket.socket() as occupied:
occupied.bind(('127.0.0.1', 0))
start = occupied.getsockname()[1]
p.api_port_range = (start, min(start + 10, 65535))
async def create():
return await asyncio.gather(p.ensure(ALICE), p.ensure(BOB))
alice, bob = asyncio.run(create())
ports = p._ports()
assert ports[ALICE['id']] != start
assert ports[ALICE['id']] != ports[BOB['id']]
assert (p.state_dir / 'api-ports.json').stat().st_mode & 0o777 == 0o600
restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health,
upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid)
assert asyncio.run(restarted.ensure(ALICE)) == alice
def test_port_exhaustion_and_corrupt_registry_fail_closed(boundary):
p, docker, _ = boundary
with socket.socket() as occupied:
occupied.bind(('127.0.0.1', 0))
p.api_port_range = (occupied.getsockname()[1], occupied.getsockname()[1])
with pytest.raises(ProvisioningError, match='No available'):
asyncio.run(p.ensure(ALICE))
p._directory(p.state_dir)
p._write_private(p.state_dir / 'api-ports.json', json.dumps({ALICE['id']: 20000, BOB['id']: 20000}))
with pytest.raises(ProvisioningError, match='Duplicate'):
asyncio.run(p.ensure(ALICE))
assert not docker.created
def test_migration_start_failure_retries_without_losing_data(boundary):
p, docker, _ = boundary
result = asyncio.run(p.ensure(ALICE))
docker.containers[container_name(ALICE)]['HostConfig']['NetworkMode'] = 'legacy'
docker.fail_start = True
with pytest.raises(ProvisioningError, match='HTTP 500'):
asyncio.run(p.ensure(ALICE))
assert asyncio.run(p.ensure(ALICE)) == result
assert len(docker.created) == 2
def test_webui_migration_unique_ports_passwords_and_cookies(boundary, monkeypatch):
p, docker, _ = boundary
original = asyncio.run(p.ensure(ALICE))
p.webui_enabled = True
p.webui_host = '127.0.0.1'
p.image = 'hope-hermes-webui:62e4d7b'
async def healthy(connection):
return True
monkeypatch.setattr(p, '_healthy', healthy)
alice, bob = asyncio.run(p.ensure(ALICE)), asyncio.run(p.ensure(BOB))
assert alice == original
assert len(docker.created) == 3
a = dict(e.split('=', 1) for e in docker.created[1]['Env'])
b = dict(e.split('=', 1) for e in docker.created[2]['Env'])
assert a['HERMES_WEBUI_PORT'] != b['HERMES_WEBUI_PORT']
assert a['HERMES_WEBUI_PASSWORD'] != b['HERMES_WEBUI_PASSWORD']
assert a['HERMES_WEBUI_COOKIE_NAME'] != b['HERMES_WEBUI_COOKIE_NAME']
assert a['HERMES_WEBUI_PASSWORD'] != alice['hermes_api_key']
assert a['HERMES_WEBUI_GATEWAY_API_KEY'] == alice['hermes_api_key']
assert a['HERMES_WEBUI_GATEWAY_BASE_URL'] == alice['hermes_url']
assert a['HERMES_WEBUI_SECURE'] == '1'
assert a['HERMES_WEBUI_CHAT_BACKEND'] == 'gateway'
password = (p.state_dir / ALICE['id'] / 'webui-password')
assert password.stat().st_mode & 0o777 == 0o600
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 3
def test_webui_password_symlink_fails_closed(boundary, monkeypatch):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
(private / 'webui-password').symlink_to(private / 'api-key')
p.webui_enabled = True
p.webui_host = '127.0.0.1'
with pytest.raises(ProvisioningError, match='symlink'):
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 1
@pytest.mark.parametrize('password', ['x', 'short passphrase'])
def test_webui_accepts_user_chosen_short_password(boundary, monkeypatch, password):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
(private / 'webui-password').write_text(password + '\n')
p.webui_enabled = True
p.webui_host = '127.0.0.1'
async def healthy(connection):
return True
monkeypatch.setattr(p, '_healthy', healthy)
asyncio.run(p.ensure(ALICE))
env = dict(entry.split('=', 1) for entry in docker.created[-1]['Env'])
assert env['HERMES_WEBUI_PASSWORD'] == password
@pytest.mark.parametrize('password', ['', ' ', 'first\nsecond'])
def test_webui_rejects_blank_or_multiline_password_before_recreation(boundary, password):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
(private / 'webui-password').write_text(password)
p.webui_enabled = True
p.webui_host = '127.0.0.1'
with pytest.raises(ProvisioningError, match='nonempty and single-line'):
asyncio.run(p.ensure(ALICE))
assert not docker.deleted
+77
View File
@@ -0,0 +1,77 @@
"""Offline Azure catalog tests: no Azure credentials or live services needed."""
import asyncio
from unittest.mock import AsyncMock, patch
import httpx
from functools import wraps
def async_test(fn):
@wraps(fn)
def run(*args, **kwargs):
return asyncio.run(fn(*args, **kwargs))
return run
from web.voice_catalog import DEFAULT_VOICE, VoiceCatalog, english_voices, normalize_default_voice
from web import token_server
def test_english_catalog_and_omni_variants():
voices = english_voices([
{'ShortName': 'en-GB-SoniaNeural', 'DisplayName': 'Sonia', 'Locale': 'en-GB', 'Gender': 'Female'},
{'ShortName': 'en-US-Ava:DragonHDOmniLatestNeural', 'DisplayName': 'Ava', 'Locale': 'en-US', 'Gender': 'Female'},
{'ShortName': 'fr-FR-RemyMultilingualNeural', 'Locale': 'fr-FR', 'SecondaryLocaleList': ['en-US']},
{'ShortName': 'fr-FR-DeniseNeural', 'Locale': 'fr-FR'},
{'ShortName': 'en-US-MultiTalker-Ava-Andrew:DragonHDLatestNeural', 'Locale': 'en-US'},
])
ids = [v['id'] for v in voices]
assert ids[0] == DEFAULT_VOICE
assert 'en-GB-Sonia:DragonHDOmniLatestNeural' in ids
assert 'en-GB-SoniaNeural' in ids
assert 'fr-FR-RemyMultilingualNeural' in ids
assert not any('Denise' in v or 'MultiTalker' in v for v in ids)
assert len(ids) == len(set(ids))
def test_default_migration():
assert normalize_default_voice('') == DEFAULT_VOICE
assert normalize_default_voice('en-US-Ava:DragonHDLatestNeural') == DEFAULT_VOICE
assert normalize_default_voice('en-GB-SoniaNeural') == 'en-GB-SoniaNeural'
@async_test
async def test_cache_and_safe_fallback():
catalog = VoiceCatalog('eastus', 'private-key')
response = httpx.Response(200, json=[{'ShortName': 'en-US-AvaNeural', 'Locale': 'en-US', 'Gender': 'Female'}], request=httpx.Request('GET', 'https://example.test'))
with patch('httpx.AsyncClient.get', new=AsyncMock(return_value=response)) as get:
a, b = await asyncio.gather(catalog.get(), catalog.get())
assert a == b and a['catalog_available']
assert get.await_count == 1
a['voices'].clear()
assert (await catalog.get())['voices']
catalog._expires = 0
with patch('httpx.AsyncClient.get', new=AsyncMock(side_effect=httpx.ConnectError('private-key'))):
result = await catalog.get()
assert not result['catalog_available'] and len(result['voices']) > 1
assert 'private-key' not in str(result)
invalid = VoiceCatalog('evil.test/path', 'private-key')
with patch('httpx.AsyncClient.get', new=AsyncMock()) as get:
assert not (await invalid.get())['catalog_available']
get.assert_not_awaited()
@async_test
async def test_catalog_requires_authenticated_account(monkeypatch):
monkeypatch.setattr(token_server, 'CONTROL_SECRET', 'control-secret')
transport = httpx.ASGITransport(app=token_server.app)
async with httpx.AsyncClient(transport=transport, base_url='https://hope.test') as client:
assert (await client.get('/voices')).status_code == 401
response = httpx.Response(200, json={'user': {'id': 'alice'}}, request=httpx.Request('POST', 'https://example.test'))
with patch('httpx.AsyncClient.post', new=AsyncMock(return_value=response)), patch.object(token_server.catalog, 'get', new=AsyncMock(return_value={'voices': [], 'catalog_available': True})):
result = await client.get('/voices', headers={'Authorization': 'Bearer session'})
assert result.status_code == 200
assert result.headers['cache-control'] == 'no-store'
assert 'control-secret' not in result.text
response = httpx.Response(401, request=httpx.Request('POST', 'https://example.test'))
with patch('httpx.AsyncClient.post', new=AsyncMock(return_value=response)), patch.object(token_server.catalog, 'get', new=AsyncMock()) as get:
assert (await client.get('/voices', headers={'Authorization': 'Bearer revoked'})).status_code == 401
get.assert_not_awaited()
+63
View File
@@ -0,0 +1,63 @@
"""Offline browser coverage of the English voice picker using local assets."""
from pathlib import Path
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
def test_english_voice_search_selection_and_retry():
available = True
account = {'id': 'alice', 'username': 'alice', 'display_name': 'Alice', 'role': 'user', 'status': 'ready'}
voices = [
{'id': 'en-US-Ava:DragonHDOmniLatestNeural', 'name': 'Ava', 'locale': 'en-US', 'gender': 'Female', 'tag': 'Omni'},
{'id': 'en-GB-Sonia:DragonHDOmniLatestNeural', 'name': 'Sonia', 'locale': 'en-GB', 'gender': 'Female', 'tag': 'Omni'},
{'id': 'en-AU-WilliamNeural', 'name': 'William', 'locale': 'en-AU', 'gender': 'Male', 'tag': 'Neural'},
]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 390, 'height': 844})
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
def route(req):
url = req.request.url
if '/api/auth/me' in url:
req.fulfill(json={'user': account})
elif url.endswith('/voices'):
if available:
req.fulfill(json={'voices': voices, 'default_voice': voices[0]['id'], 'catalog_available': True})
else:
req.fulfill(status=503, json={'error': 'Unavailable'})
elif '/api/settings/model' in url:
req.fulfill(json={'settings': {'model': 'auto', 'thinking_level': 'none'}, 'models': [], 'catalog_available': False})
else:
asset = ROOT / 'web' / (url.split('/', 3)[-1].split('?')[0] or 'index.html')
types = {'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
if asset.is_file():
req.fulfill(body=asset.read_bytes(), content_type=types.get(asset.suffix, 'application/octet-stream'))
else:
req.fulfill(status=404, body='')
page.route('https://hope.test/**', route)
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.locator('#settingsBtn').click()
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
page.locator('#voiceSearch').fill('en-GB')
expect(page.locator('#voiceList [role=radio]')).to_have_count(1)
page.locator('#voiceList [role=radio]').press('Enter')
assert page.evaluate("localStorage.getItem('preferred_voice')") == voices[1]['id']
page.locator('#voiceSearch').fill('no-matching-voice')
expect(page.locator('#voiceList')).to_contain_text('No matching voices')
page.locator('#voiceSearch').fill('')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.screenshot(path='/tmp/hope-english-voices-mobile.png', full_page=True)
page.locator('#closeSheetBtn').click()
available = False
page.locator('#settingsBtn').click()
expect(page.locator('#retryVoices')).to_be_visible()
expect(page.locator('#voiceList [role=radio]')).to_have_count(3)
available = True
page.locator('#retryVoices').click()
expect(page.locator('#retryVoices')).to_be_hidden()
assert errors == []
browser.close()
+68 -19
View File
@@ -8,20 +8,11 @@ const { Room, RoomEvent } = LivekitClient;
const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`; const LIVEKIT_URL = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/livekit`;
const AGENT_NAME = "voice-assistant"; const AGENT_NAME = "voice-assistant";
const VOICES = [ const OMNI_DEFAULT = "en-US-Ava:DragonHDOmniLatestNeural";
{ id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni" }, let defaultVoice = OMNI_DEFAULT;
{ id: "en-US-JennyNeural", name: "Jenny" }, let VOICES = [{ id: OMNI_DEFAULT, name: "Ava", locale: "en-US", gender: "Female", tag: "Dragon HD Omni" }];
{ id: "en-US-GuyNeural", name: "Guy" }, let voiceCatalogRequest = null;
{ id: "en-US-AndrewNeural", name: "Andrew" }, let voiceCatalogGeneration = 0;
{ id: "en-US-AriaNeural", name: "Aria" },
{ id: "en-US-EmmaNeural", name: "Emma" },
{ id: "en-US-EricNeural", name: "Eric" },
{ id: "en-US-BrianNeural", name: "Brian" },
{ id: "en-US-AshleyNeural", name: "Ashley" },
{ id: "en-US-RichardNeural", name: "Richard" },
{ id: "en-US-TinaNeural", name: "Tina" },
{ id: "en-US-SteffanNeural", name: "Steffan" },
];
let historyKey = null; let historyKey = null;
const HISTORY_MAX = 50; const HISTORY_MAX = 50;
@@ -574,6 +565,7 @@ function handleDataPacket(payload, participant, topic) {
if (!HopeAuth.user || !participant || !isAgent(participant)) return; if (!HopeAuth.user || !participant || !isAgent(participant)) return;
if (msg.type === "agent_ready" && topic === "conversation-control") { if (msg.type === "agent_ready" && topic === "conversation-control") {
agentReady = true; agentReady = true;
sendVoiceMessage(currentVoiceId());
} else if (msg.type === "transcript" && topic === "transcript") { } else if (msg.type === "transcript" && topic === "transcript") {
if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return; if (!["agent", "user"].includes(msg.role) || typeof msg.text !== "string") return;
addMessage(msg.role, msg.text); addMessage(msg.role, msg.text);
@@ -745,21 +737,71 @@ modelSettingsForm.addEventListener("submit", async event => {
// ── Voice settings sheet ──────────────────────────────────────────────────── // ── Voice settings sheet ────────────────────────────────────────────────────
function currentVoiceId() { function currentVoiceId() {
const saved = localStorage.getItem("preferred_voice"); let saved = localStorage.getItem("preferred_voice");
return VOICES.some((v) => v.id === saved) ? saved : VOICES[0].id; // Migrate older Dragon HD selections to the equivalent Omni persona.
if (saved?.endsWith(":DragonHDLatestNeural")) {
saved = saved.replace(":DragonHDLatestNeural", ":DragonHDOmniLatestNeural");
localStorage.setItem("preferred_voice", saved);
}
return VOICES.some((v) => v.id === saved) ? saved : defaultVoice;
}
const voiceSearchEl = document.getElementById("voiceSearch");
const voiceStatusEl = document.getElementById("voiceCatalogStatus");
const retryVoicesBtn = document.getElementById("retryVoices");
voiceSearchEl.addEventListener("input", renderVoiceList);
retryVoicesBtn.addEventListener("click", loadVoiceCatalog);
async function loadVoiceCatalog() {
if (!HopeAuth.user) return;
if (voiceCatalogRequest) return voiceCatalogRequest;
const generation = voiceCatalogGeneration;
voiceStatusEl.textContent = "Loading English voices from Azure…";
retryVoicesBtn.hidden = true;
voiceCatalogRequest = (async () => {
try {
const data = await HopeAuth.request("/voices");
if (generation !== voiceCatalogGeneration) return;
const voices = Array.isArray(data.voices) ? data.voices.filter(v =>
v && typeof v.id === "string" && /^[A-Za-z0-9:_-]{1,120}$/.test(v.id) && typeof v.name === "string") : [];
if (!voices.length) throw new Error("Empty voice catalog");
VOICES = voices;
defaultVoice = VOICES.some(v => v.id === data.default_voice) ? data.default_voice : OMNI_DEFAULT;
voiceStatusEl.textContent = data.catalog_available
? `${VOICES.length} English voices · Dragon HD Omni first. Ava Omni is the recommended female default.`
: (data.message || "Azure’s full catalog is unavailable. Ava Omni remains available; retry to load all English voices.");
retryVoicesBtn.hidden = data.catalog_available === true;
renderVoiceList();
} catch (_) {
if (generation !== voiceCatalogGeneration) return;
voiceStatusEl.textContent = "Couldn’t load Azure’s full catalog. Your current choices remain available. Retry to load all English voices.";
retryVoicesBtn.hidden = false;
} finally {
if (generation === voiceCatalogGeneration) voiceCatalogRequest = null;
}
})();
return voiceCatalogRequest;
} }
function renderVoiceList() { function renderVoiceList() {
voiceListEl.innerHTML = ""; voiceListEl.replaceChildren();
const active = currentVoiceId(); const active = currentVoiceId();
for (const v of VOICES) { const query = voiceSearchEl.value.trim().toLowerCase();
const visible = VOICES.filter(v => `${v.name} ${v.locale || ""} ${v.gender || ""} ${v.tag || ""}`.toLowerCase().includes(query));
if (!visible.length) {
const empty = document.createElement("p");
empty.className = "model-settings-note";
empty.textContent = "No matching voices. Try a name, accent locale, gender, or model.";
voiceListEl.appendChild(empty);
}
for (const v of visible) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "voice-card" + (v.id === active ? " active" : ""); card.className = "voice-card" + (v.id === active ? " active" : "");
card.dataset.voice = v.id; card.dataset.voice = v.id;
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "voice-name"; name.className = "voice-name";
name.textContent = v.name + (v.tag ? ` (${v.tag})` : ""); name.textContent = `${v.name} · ${v.locale || "English"} · ${v.gender || ""}` + (v.tag ? ` (${v.tag})` : "");
const selected = v.id === active; const selected = v.id === active;
card.setAttribute("role", "radio"); card.setAttribute("role", "radio");
@@ -790,6 +832,7 @@ function selectVoice(voiceId) {
function openSheet() { function openSheet() {
if (!HopeAuth.user) return; if (!HopeAuth.user) return;
renderVoiceList(); renderVoiceList();
loadVoiceCatalog();
settingsSheet.hidden = false; settingsSheet.inert = false; settingsSheet.hidden = false; settingsSheet.inert = false;
document.getElementById("authenticatedApp").inert = true; document.getElementById("authenticatedApp").inert = true;
sheetBackdrop.hidden = false; sheetBackdrop.hidden = false;
@@ -1130,6 +1173,12 @@ HopeAuth.onChange((user, changed) => {
if (changed || !user || user.status !== "ready") stopConversation(); if (changed || !user || user.status !== "ready") stopConversation();
if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user); if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user);
if (changed) { if (changed) {
voiceCatalogGeneration++;
voiceCatalogRequest = null;
VOICES = [{ id: OMNI_DEFAULT, name: "Ava", locale: "en-US", gender: "Female", tag: "Dragon HD Omni" }];
defaultVoice = OMNI_DEFAULT;
voiceSearchEl.value = "";
if (user) loadVoiceCatalog();
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null; historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true; history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
chatInput.value = ""; sendChatBtn.disabled = true; chatInput.value = ""; sendChatBtn.disabled = true;
+5 -1
View File
@@ -172,7 +172,11 @@
</div> </div>
</form> </form>
<h3 class="sheet-section-title">Voice</h3> <h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="Voice"></div> <label for="voiceSearch">Search English voices</label>
<input id="voiceSearch" type="search" placeholder="Name, locale, gender, or Omni" autocomplete="off" aria-controls="voiceList">
<p id="voiceCatalogStatus" class="model-settings-note" role="status" aria-live="polite">Ava Dragon HD Omni is the recommended female default.</p>
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
</div> </div>
</div> </div>
+10
View File
@@ -1003,6 +1003,16 @@ body[data-mode="text"] .mic-meter {
} }
.settings-sheet .sheet-header { flex-shrink: 0; } .settings-sheet .sheet-header { flex-shrink: 0; }
.settings-sheet .voice-list { overflow-y: visible; } .settings-sheet .voice-list { overflow-y: visible; }
#voiceSearch {
width: 100%; min-width: 0; min-height: 44px; margin-top: 0.4rem;
padding: 0.65rem 0.75rem; border: 1px solid var(--line); border-radius: 10px;
background: var(--surface-soft); color: var(--ink); font: inherit;
}
#voiceSearch::placeholder { color: var(--ink-soft); opacity: 1; }
#voiceSearch:focus-visible { outline: 2px solid var(--iris); outline-offset: 2px; }
#voiceCatalogStatus { margin: 0.6rem 0; }
#retryVoices { margin-bottom: 0.6rem; }
.voice-name { overflow-wrap: anywhere; }
.model-settings { .model-settings {
min-width: 0; min-width: 0;
padding-bottom: 1.25rem; padding-bottom: 1.25rem;
+41
View File
@@ -12,6 +12,11 @@ import httpx
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
try:
from web.voice_catalog import catalog
except ModuleNotFoundError: # Direct execution: python /app/web/token_server.py
from voice_catalog import catalog
API_KEY = os.getenv("LIVEKIT_API_KEY", "") API_KEY = os.getenv("LIVEKIT_API_KEY", "")
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "") API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant") AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
@@ -75,6 +80,42 @@ async def token(request: Request):
except (httpx.HTTPError, ValueError, KeyError): except (httpx.HTTPError, ValueError, KeyError):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers) return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
@app.get("/voices")
async def voices(request: Request):
"""Resolve the caller at the trusted control boundary before reading the cache."""
headers = {"Cache-Control": "no-store"}
if not CONTROL_SECRET:
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
auth = request.headers.get("authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else ""
credential = bearer or request.cookies.get(COOKIE_NAME, "")
if not credential or len(credential) > 512:
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
# Safe GET requests normally have no Origin. If supplied with cookie auth,
# enforce the same configured origin allowlist as the bootstrap boundary.
origin = request.headers.get("origin", "")
if not bearer and origin:
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
if origin not in allowed:
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(f"{CONTROL_URL}/internal/resolve-session",
headers={"X-Control-Secret": CONTROL_SECRET},
json={"token": credential})
if response.status_code != 200:
status = response.status_code if response.status_code in (401, 403, 503) else 503
return JSONResponse({"error": "Sign in required" if status == 401 else
"Your private agent is not ready"}, status, headers=headers)
# Verify the expected control response without exposing private metadata.
account = response.json()
if not isinstance(account, dict) or not isinstance(account.get("user"), dict) or not account["user"].get("id"):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
except (httpx.HTTPError, ValueError, TypeError):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
return JSONResponse(await catalog.get(), headers=headers)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False) uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)
+133
View File
@@ -0,0 +1,133 @@
"""Azure English voice catalog, independent of the speech agent.
Omni names follow Microsoft's persona naming rule (remove final ``Neural``
and append ``:DragonHDOmniLatestNeural``):
https://learn.microsoft.com/en-us/azure/ai-services/speech-service/high-definition-voices
Only the final Neural suffix is removed: Multilingual is retained because the
rule does not document removing it. Advertised Omni identifiers take precedence
for a matching persona. Derived variants follow the documented convention, not
an individual synthesis/region availability probe. MultiTalker composites are
excluded because the single-voice picker cannot supply their dialogue SSML.
"""
import asyncio
import copy
import os
import re
import time
import httpx
DEFAULT_VOICE = "en-US-Ava:DragonHDOmniLatestNeural"
OMNI_SUFFIX = ":DragonHDOmniLatestNeural"
CACHE_TTL = 3600
FAILURE_RETRY_TTL = 60
_REGION = re.compile(r"[a-z][a-z0-9]{0,62}\Z")
def normalize_default_voice(voice: str | None = None) -> str:
"""Read optional AZURE_TTS_VOICE and migrate a legacy DragonHD default.
Explicit regular Neural choices and already-Omni choices are preserved.
This helper has no dependency on, or side effects in, the agent.
"""
value = (os.getenv("AZURE_TTS_VOICE", "") if voice is None else voice).strip()
if not value:
return DEFAULT_VOICE
if value.endswith(":DragonHDLatestNeural"):
return value.removesuffix(":DragonHDLatestNeural") + OMNI_SUFFIX
return value
def _english(locale) -> bool:
return isinstance(locale, str) and (locale.lower() == "en" or locale.lower().startswith("en-"))
def _entry(voice_id, name, locale, gender):
tag = "Omni" if "dragonhdomni" in voice_id.lower() else (
"HD" if "dragonhd" in voice_id.lower() else (
"Neural" if voice_id.endswith("Neural") else "Standard"))
return {"id": voice_id, "name": name, "locale": locale, "gender": gender, "tag": tag}
def english_voices(payload: list) -> list[dict]:
"""Keep advertised English-capable voices and add ordinary Neural Omni variants."""
if not isinstance(payload, list):
raise ValueError("Invalid catalog")
voices = {}
for row in payload:
if not isinstance(row, dict):
continue
voice_id, locale = row.get("ShortName"), row.get("Locale")
secondary = row.get("SecondaryLocaleList", [])
if not isinstance(secondary, list):
secondary = []
if not isinstance(voice_id, str) or not voice_id or "multitalker" in voice_id.lower():
continue
if not isinstance(locale, str) or not (_english(locale) or any(_english(s) for s in secondary)):
continue
name = row.get("DisplayName")
gender = row.get("Gender")
voices[voice_id.lower()] = _entry(voice_id, name if isinstance(name, str) else voice_id,
locale, gender if isinstance(gender, str) else "Unknown")
if not voices:
raise ValueError("No English voices in catalog")
# Advertised entries win (including identifier spelling and display metadata).
advertised_personas = {v["id"].split(":", 1)[0].lower() for v in voices.values() if v["tag"] == "Omni"}
for voice in list(voices.values()):
voice_id = voice["id"]
if ":" not in voice_id and voice_id.endswith("Neural"):
persona = voice_id.removesuffix("Neural")
if persona.lower() not in advertised_personas:
omni_id = persona + OMNI_SUFFIX
voices.setdefault(omni_id.lower(), _entry(omni_id, voice["name"], voice["locale"], voice["gender"]))
voices.setdefault(DEFAULT_VOICE.lower(), _entry(DEFAULT_VOICE, "Ava", "en-US", "Female"))
return sorted(voices.values(), key=lambda v: (v["id"].lower() != DEFAULT_VOICE.lower(),
v["tag"] != "Omni", v["locale"].lower(), v["name"].lower(), v["id"].lower()))
class VoiceCatalog:
"""Process-local 1h cache; coalesces refreshes and retains last good data.
Failed refreshes are retried after 60s, not by every concurrent caller.
Failures always report catalog_available=False even when returning stale data.
No upstream exception, response body, header, or key is logged/returned.
"""
def __init__(self, region: str, speech_key: str, default_voice: str | None = None):
self.region = region
self.speech_key = speech_key
self.default_voice = normalize_default_voice(default_voice)
self._lock = asyncio.Lock()
self._expires = 0.0
self._good = None
self._result = None
async def get(self) -> dict:
if self._result is not None and time.monotonic() < self._expires:
return copy.deepcopy(self._result)
async with self._lock:
if self._result is not None and time.monotonic() < self._expires:
return copy.deepcopy(self._result)
try:
# A single DNS label, never a URL, port, userinfo or path. Redirects
# are disabled so the subscription header cannot leave Azure.
if not self.speech_key or not isinstance(self.region, str) or not _REGION.fullmatch(self.region):
raise ValueError("Invalid speech configuration")
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
response = await client.get(
f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/voices/list",
headers={"Ocp-Apim-Subscription-Key": self.speech_key})
response.raise_for_status()
voices = english_voices(response.json())
self._good = voices
self._result = {"default_voice": self.default_voice, "voices": voices, "catalog_available": True}
self._expires = time.monotonic() + CACHE_TTL
except (httpx.HTTPError, ValueError, TypeError):
voices = self._good or [_entry(DEFAULT_VOICE, "Ava", "en-US", "Female")]
self._result = {"default_voice": self.default_voice, "voices": voices, "catalog_available": False,
"message": "Azure voice catalog unavailable. Using last known voices." if self._good else
"Azure voice catalog unavailable. Using the default voice."}
self._expires = time.monotonic() + FAILURE_RETRY_TTL
return copy.deepcopy(self._result)
catalog = VoiceCatalog(os.getenv("AZURE_SPEECH_REGION", "eastus"), os.getenv("AZURE_SPEECH_KEY", ""))