Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/control-plane/internal/catalog/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ func buildCatalogSnapshot(aliases []ModelAlias) CatalogSnapshot {
Object: "model",
Created: alias.CreatedAt.UTC().Unix(),
OwnedBy: ownedBy,
// Trimmed, and empty when the column is empty, so an alias with no
// curated copy contributes no key at all rather than an empty
// string the picker would render as a blank subtitle line.
Name: strings.TrimSpace(alias.DisplayName),
Description: strings.TrimSpace(alias.Summary),
})
snapshot.Catalog = append(snapshot.Catalog, PublicCatalogModel{
ID: alias.AliasID,
Expand Down
26 changes: 22 additions & 4 deletions apps/control-plane/internal/catalog/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,29 @@ type AliasPolicySnapshot struct {
FallbackOrder []string
}

// PublicModel is one entry of the OpenAI-shaped model list served by
// edge-api's GET /v1/models.
//
// Name and Description are additive fields outside the OpenAI contract, and
// they are here for one reason: the chat model picker had no source of
// human-readable copy and therefore listed raw alias slugs with no explanation
// of what any of them is for. The same two strings have always existed in
// public.model_aliases (display_name, summary) and have always been rendered by
// the developer console's catalog table; they simply never travelled on the
// listing the picker reads.
//
// Both are `omitempty`, so an alias with neither serialises exactly the payload
// this endpoint served before, and a strict OpenAI client sees no new keys at
// all. Open WebUI merges unknown fields from an upstream model listing straight
// through (backend/open_webui/routers/openai.py builds `{**model, ...}`), which
// is what carries them to the picker without a second endpoint or a proxy.
type PublicModel struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}

// Pricing modes, mirroring public.model_aliases.pricing_mode's CHECK
Expand Down
19 changes: 15 additions & 4 deletions apps/edge-api/internal/catalog/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,22 @@ import (
"github.com/sakibsadmanshajib/hive/apps/edge-api/internal/cpauth"
)

// Model mirrors control-plane's catalog.PublicModel across the HTTP boundary
// and is re-serialised verbatim as one entry of GET /v1/models.
//
// Name and Description are the alias's display name and one-sentence summary.
// They are additive fields outside the OpenAI contract, carried so the chat
// model picker can show what each alias is for instead of a bare slug; see the
// control-plane type for the full reasoning. Both are `omitempty` in both
// directions, so an alias with neither produces the exact payload this endpoint
// served before they existed.
type Model struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}

// CatalogPricing mirrors control-plane's catalog.CatalogPricing across the
Expand Down
2 changes: 2 additions & 0 deletions deploy/docker/Dockerfile.open-webui
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,8 @@ RUN set -eu; \
echo "the built bundle carries no Hive navigation" >&2; exit 1; }; \
grep -rqF 'HankenGrotesk-Variable' /app/build/_app/immutable || { \
echo "the built bundle does not load the brand typeface" >&2; exit 1; }; \
grep -rqF 'SourceSerif4-Variable' /app/build/_app/immutable || { \
echo "the built bundle does not load the document typeface" >&2; exit 1; }; \
! grep -rqF '"/workspace/models"' /app/build/_app/immutable || { \
echo "Workspace > Models came back in the built bundle" >&2; exit 1; }; \
! grep -rqF 'discord.gg/5rJgQTnV4s' /app/build/_app/immutable || { \
Expand Down
32 changes: 32 additions & 0 deletions docs/proof/claude-parity-uplift-1161/capture-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Visual proof capture log, PR #1161

- Date: 2026-08-25
- Branch: feat/claude-visual-parity at 4e0779d17
- Image: hive-open-webui:v0.10.2-branded (sha256:efe32c9df69a332c4ee40a24771d5493cd6c891f8d763f5084c468daf52f85f6), built locally from this exact tree and verified before capture.
- Stack: standalone branded Open WebUI container (`parity1161-open-webui`, host port 127.0.0.1:13161) on a throwaway Docker network `parity1161` with a local stub LLM serving an OpenAI compatible `/v1/models` (two aliases carrying `name` and `description` copy, the same passthrough shape edge-api's catalog response uses) plus `/v1/chat/completions` streaming prose. Ephemeral in-container data directory; no shared account, shared database, or running demo stack was touched.
- Session: fresh first-run signup created a throwaway admin user on the ephemeral store, then one chat turn sent through the composer with the default model selected.

## Surfaces captured

1. `parity1161-01-transcript-light.png` (sha256 78c6102ae5d48260747d762ca80490511f70c0eee72222f654c34660b14ad87a): light theme full window after one assistant reply. Assistant prose renders in Source Serif 4 at the shared reading measure; sidebar, composer chrome, buttons and quick chips stay Hanken Grotesk; the model chip sits in the composer control row.
2. `parity1161-02-model-picker.png` (sha256 6242cbded521976af9cd65d9b1b17de22d18724ecfef86e984a1171c360bfc57): model picker open from the composer chip. Each row shows the alias name plus its one sentence purpose line rendered under it ("Everyday reasoning for drafting, analysis and careful long-form writing." / "Quick short answers for lookups, rewrites and small edits."), which is the catalog display_name/summary copy arriving through the models payload.
3. `parity1161-03-serif-closeup.png` (sha256 5dc0c6ac8bcb76313f069607e1539a7627e91ca0d13064bb45ebb27fda980397): element close-up of the assistant message body. Serif body text with a true italic emphasis phrase and a code span that stays mono.
4. `parity1161-04-dark-fullwindow.png` (sha256 17d62f1648e7e6af286b4900860be19d43f10028088429d61f14183fa0d87c37): dark theme full window of the same conversation via `localStorage.theme = dark` reload, exercising the boot-script branch that now also writes `data-theme="dark"` alongside the class. Dark register coherent: warm charcoal surfaces, serif transcript, grotesk chrome.

## Credential hygiene

No URL in any captured surface carries a credential: all navigation stayed on `http://127.0.0.1:13161` with plain path URLs (`/auth?redirect=%2F` carries only a redirect target, no token parameter). Signup used a throwaway account on an ephemeral in-container store, so nothing to rotate. No redaction required; verified against the lint:proof-tokens pattern set (no token/code query parameters, no bare JWT).

## Reproduce

```bash
docker network create parity1161
docker run -d --name parity1161-stub-llm --network parity1161 --network-alias stub-llm \
-v /tmp/parity1161/stub_llm.py:/srv/stub.py:ro python:3.11-alpine python /srv/stub.py
docker run -d --name parity1161-open-webui --network parity1161 -p 127.0.0.1:13161:8080 \
-e WEBUI_SECRET_KEY=<local-only> -e ENABLE_OPENAI_API=true \
-e OPENAI_API_BASE_URL=http://stub-llm:9000/v1 -e OPENAI_API_KEY=<local-only> \
-e ENABLE_OLLAMA_API=false hive-open-webui:v0.10.2-branded bash start.sh
```

Then sign up on first run, send one message, capture with Playwright at 1912x964 CSS pixels viewport.
49 changes: 43 additions & 6 deletions packages/hive-tokens/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,53 @@
--hv-container-pad: 48px;

/* ---------------------------------------------------------------- *
* Type. One family, Hanken Grotesk, carrying display and interface
* both, separated by weight. Geist Mono is a product only addition
* the brand does not have, because a marketing page has no credit
* balances, key fragments, model identifiers or log output to set.
* Both are SIL Open Font License 1.1.
* Type. Two families doing two jobs.
*
* Hanken Grotesk carries the interface: navigation, buttons, labels,
* tables, settings, every control surface. Source Serif 4 carries the
* document: the chat greeting, page titles, and assistant prose in the
* transcript. Geist Mono is a product only addition the brand does not
* have, because a marketing page has no credit balances, key fragments,
* model identifiers or log output to set. All three are SIL Open Font
* License 1.1.
*
* The serif was added on 2026-08-25. Until then this file declared one
* family for display and interface both, separated only by weight, and
* the consequence was measurable rather than theoretical: a chat home
* set entirely in a grotesque reads as a control panel, and the same
* screen with a serif greeting and serif transcript reads as a document.
* Hierarchy carried by weight alone is a legitimate system; it is simply
* not the one this product is being measured against.
*
* Source Serif 4 specifically, over the other open serifs, because it is
* a transitional text face with a real italic and a genuine weight axis,
* so it can set 19px running prose and a 40px greeting from one file.
* A display-only serif (Instrument Serif, already vendored for upstream's
* own use) has one weight and no italic and would have forced synthetic
* bold and synthetic oblique into every emphasised span of a reply.
* ---------------------------------------------------------------- */
--hv-font-sans: 'Hanken Grotesk', 'Noto Sans Bengali', ui-sans-serif, system-ui, sans-serif;
--hv-font-display: var(--hv-font-sans);
--hv-font-serif: 'Source Serif 4', 'Noto Serif Bengali', ui-serif, Georgia, serif;
--hv-font-display: var(--hv-font-serif);
--hv-font-mono: 'Geist Mono', 'Noto Sans Bengali', ui-monospace, 'SFMono-Regular', monospace;

/*
* Running prose in the transcript. Larger and looser than interface body
* text on purpose: this is the one text on the screen a person reads
* rather than scans, and it is set in the serif, which needs more room
* than the grotesque at the same nominal size.
*/
--hv-text-prose: 19px;
--hv-leading-prose: 30px;

/*
* The reading measure. Roughly 80 characters at --hv-text-prose, which is
* the width the transcript, the composer and the turn separators all
* share, so the conversation reads as one column rather than as prose
* floating inside a wider pane.
*/
--hv-measure: 52rem;

/* Body stays at 14px. This is a control surface with dense lists and
* tables; 16px body turns it into a marketing page. */
--hv-text-2xs: 11px;
Expand Down
31 changes: 20 additions & 11 deletions vendor/open-webui/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,31 @@
localStorage.theme = 'system';
}

// Three themes and no more: system, light, dark. The retired
// oled-dark branch used to overwrite four --color-gray-* values
// with raw hex, which is the half of the brand ramp the dark
// surfaces are drawn from; the retired `her` branch set a maroon
// that appears nowhere in the token layer. A stored value of
// either now falls through to the final else and resolves to
// dark, so an account that picked one before this change still
// boots into a real theme.
//
// data-theme travels with the class because the Hive token layer
// defines its dark register under [data-theme="dark"] while
// upstream's utilities read the class. Set here, in the same
// inline head script that sets the class, so the two never
// disagree during first paint.
if (localStorage.theme === 'system') {
document.documentElement.classList.add(prefersDarkTheme ? 'dark' : 'light');
document.documentElement.dataset.theme = prefersDarkTheme ? 'dark' : 'light';
metaThemeColorTag.setAttribute('content', prefersDarkTheme ? '#171717' : '#ffffff');
} else if (localStorage.theme === 'oled-dark') {
document.documentElement.style.setProperty('--color-gray-800', '#101010');
document.documentElement.style.setProperty('--color-gray-850', '#050505');
document.documentElement.style.setProperty('--color-gray-900', '#000000');
document.documentElement.style.setProperty('--color-gray-950', '#000000');
document.documentElement.classList.add('dark');
metaThemeColorTag.setAttribute('content', '#000000');
} else if (localStorage.theme === 'light') {
} else if (localStorage.theme === 'light' || localStorage.theme === 'her') {
document.documentElement.classList.add('light');
document.documentElement.dataset.theme = 'light';
metaThemeColorTag.setAttribute('content', '#ffffff');
} else if (localStorage.theme === 'her') {
document.documentElement.classList.add('her');
metaThemeColorTag.setAttribute('content', '#983724');
} else {
document.documentElement.classList.add('dark');
document.documentElement.dataset.theme = 'dark';
metaThemeColorTag.setAttribute('content', '#171717');
}

Expand All @@ -185,10 +192,12 @@
if (e.matches) {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
document.documentElement.dataset.theme = 'dark';
metaThemeColorTag.setAttribute('content', '#171717');
} else {
document.documentElement.classList.add('light');
document.documentElement.classList.remove('dark');
document.documentElement.dataset.theme = 'light';
metaThemeColorTag.setAttribute('content', '#ffffff');
}
}
Expand Down
4 changes: 2 additions & 2 deletions vendor/open-webui/src/lib/components/chat/Chat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3294,7 +3294,7 @@
bind:this={messageInput}
{history}
{taskIds}
{selectedModels}
bind:selectedModels
bind:files
bind:prompt
bind:autoScroll
Expand Down Expand Up @@ -3380,7 +3380,7 @@
<CreditsBanner />
<Placeholder
{history}
{selectedModels}
bind:selectedModels
bind:messageInput
bind:files
bind:prompt
Expand Down
21 changes: 19 additions & 2 deletions vendor/open-webui/src/lib/components/chat/MessageInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
// each other. Presentational only; nothing below this line changed.
import ComposerShell from '$lib/hive/ComposerShell.svelte';
import ComposerSendButton from '$lib/hive/ComposerSendButton.svelte';
import ModelSelector from './ModelSelector.svelte';

import XMark from '../icons/XMark.svelte';
import GlobeAlt from '../icons/GlobeAlt.svelte';
Expand Down Expand Up @@ -1257,7 +1258,7 @@
<div
class="flex flex-col px-3 {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} w-full"
: 'hv-composer-column'} w-full"
>
<div class="relative">
{#if autoScroll === false && history?.currentId}
Expand Down Expand Up @@ -1294,7 +1295,7 @@
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
: 'hv-composer-column'} px-2.5 mx-auto inset-x-0"
>
<div class="">
<input
Expand Down Expand Up @@ -2019,6 +2020,22 @@
</div>
</div>

<!--
The model chip lives in the composer's control row, not in the page
header. Two reasons, and neither is decoration. It is a property of
the message about to be sent, so it belongs on the thing that sends
it; and while it was a page header on the chat home it was ALSO the
headline of the empty state, which is why the first frame of every
demo opened with a model slug instead of a greeting.

`bind:selectedModels` runs all the way up through Placeholder and
Chat, which is what makes a selection made here stick to the
conversation rather than to this component.
-->
<div class="self-end flex items-center shrink-0 max-w-[45%] mr-1">
<ModelSelector bind:selectedModels showSetDefault={false} />
</div>

<div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]">
{#if isActive && prompt === '' && files.length === 0}
<div class=" flex items-center">
Expand Down
Loading
Loading