Apply the Nocturne fidelity pass to the web UI

This commit is contained in:
Fisto
2026-09-21 19:31:29 +02:00
parent 267c31337c
commit a4d61781e9
10 changed files with 1336 additions and 100 deletions
+8
View File
@@ -40,6 +40,14 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C74 Nocturne fidelity pass from the third party. Integrated the applied tree from
`Debug WebUI mockups(1).zip`: `tokens-status.css` (danger/warn/ok roles and the data
scale), `style-overrides.css`, `settings-popups.css`, `polish.js`, the Phosphor
`icons.js`, and the token cleanup in `media.css`. `index.html` loads them in their order
(the two override sheets last, `polish.js` after `icons.js`). The writeup lives in
[docs/ui-changes.md](docs/ui-changes.md). Their tree matched ours file-for-file apart
from those files, so nothing regressed. UI smoke test passes; the TTS panel renders the
voice-processing block correctly under the new sheets.
- [x] C73 Echo on the vox voice. `aecho`'s `in_gain`/`out_gain` scale the dry signal
as well as the repeats, so a low echo level quieted the whole line (the classic path's
loudnorm hid it; the streaming path has no measurement, so vox + stream word mode just
+1
View File
@@ -20,6 +20,7 @@ This file lists the project documents.
| [auth.md](auth.md) | QR login and token persistence |
| [testing.md](testing.md) | Tests and the live test setup |
| [decisions.md](decisions.md) | Design decisions and their reasons |
| [ui-changes.md](ui-changes.md) | The Nocturne fidelity pass over the web UI (third-party writeup) |
## Documents outside `docs/`
+255
View File
@@ -0,0 +1,255 @@
# Veskslop UI — Nocturne fidelity pass
The shell (header, rail, call strip, composer, TTS box, console, right column)
is faithful to the design. The drift is all in the layer added since: the Media
archive and Bot tabs, plus a few spots in `style.css`.
**Part two** (icons, settings, menus, dialogs, toasts) starts further down.
## Install
```html
<link rel="stylesheet" href="nocturne.css" />
<link rel="stylesheet" href="tokens-status.css" /> <!-- new, additive -->
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="media.css" /> <!-- replaces yours -->
<link rel="stylesheet" href="style-overrides.css" /> <!-- new, load last -->
<link rel="stylesheet" href="settings-popups.css" /> <!-- new, load last -->
<script src="icons.js"></script> <!-- replaces yours -->
<script src="polish.js"></script> <!-- new, after icons.js -->
```
`tokens-status.css`, `style-overrides.css`, `settings-popups.css` and `polish.js`
are new files. `media.css` and `icons.js` are your files with the changes
applied — diff them, don't assume.
---
## The root cause
Nocturne ships one accent and a neutral ramp. It has **no error / warning /
success role and no categorical data scale.** So every feature that needed one
invented its own: `#e5484d`, `#ffaa33`, `#44cc88`, `#ff5555`, `#ff8080`,
`#c04040`, `#4f8cff`, `#8a6cff`, `#ff7a45`, `#2fbf71`, `#6b7280`, `#d29922`.
Eighteen hexes, four different reds, none on the system's lightness scale.
`tokens-status.css` fills that hole properly — danger, warn, ok and a five-step
data scale, generated in OKLCH on Nocturne's **own** shared lightness scale, so
step 400 of danger carries the same visual weight as step 400 of accent. Chroma
stays low; the accent is still the loudest thing on screen.
Everything else in this patch is downstream of having those tokens.
---
## What changed
### 1. Colour
- 18 hard-coded hexes replaced with tokens. Zero remain.
- **Wrong-hue fallbacks removed.** `var(--color-accent-400, #4f8cff)` appeared
six times. `#4f8cff` is blue; Nocturne's accent is blurple `#9184d9`. Any time
that fallback fired — a load-order slip, a scoping mistake — the Media and Bot
tabs turned a different colour than the rest of the app. Silent, and hard to
spot because it only happens when something else is already broken.
- The storage bar's five saturated hues become the low-chroma `--color-data-*`
family. Nocturne's rule is that contrast comes from the tonal ramps, not from
saturation; five alarm colours in a 10px bar broke it harder than anything
else on screen.
- `--color-mention: #d29922` was declared in a stray second `:root` block 400
lines into `style.css`. It now lives with the other tokens as `--color-warn-400`.
### 2. Floods
Nocturne outlines; it does not fill.
- `.vk-toggle-half.vk-toggle-on` was a solid `accent-400` panel with `#fff`
text — the one thing the guide names as a don't. Now tinted and outlined, the
same treatment `.vk-scope-btn.active` already uses.
- `.vk-media-badge` was `#e5484d` with white text at 9px. Now a `danger-800`
tint with `danger-200` ink at 10px.
- `.vk-bot-state-*` pills moved from alpha washes over saturated hues to deep
ramp fills with full-opacity ink — an alpha wash over an unknown backdrop is
how small text loses contrast without anyone noticing.
### 3. Weight
Nocturne heads cap at 500; hierarchy is size and space. Removed 11 instances of
`font: 600` and `font-weight: 600` in `media.css`, plus `font-weight: 700` on
the speaking state in `style.css`. That last one also **reflowed the row every
time someone started or stopped talking** — the names jittered. Speaking now
reads in `accent-200`, with the level meter beside it doing the loud work.
### 4. Edges and radii
`border: 1px solid` → the system's `inset 0 0 0 1px` edge, so elevation stays
"hairline plus ambient darkness" rather than a drawn box. Literal `3px / 5px /
6px / 7px` radii → `--radius-sm` / `--radius-md`. Six different corner radii
were in play across the Bot tab.
### 5. Focus
`nocturne.css` sets a 2px accent `:focus-visible` ring, but every control built
from a bare `<button>` that resets `background` and `border` loses it against
its own surface. Explicitly restored on 18 controls: rail rows, guild marks,
scope buttons, console tabs, chips, stars, mobile tabs, settings summaries,
media links, tree heads, group titles. **This is the only keyboard path through
the app** — worth checking by tabbing the whole thing once.
### 6. Contrast
- `.vk-media-done-note` was `color-mix(neutral-400 55%, transparent)` at 10px —
roughly 2.4:1. Now `neutral-500`.
- `.vk-bot-history-entry` and friends: 10px mono at `neutral-500` sits just
under 4.5:1 on surface. Lifted one step to `neutral-400`.
- **The offline state was the worst of it.** `opacity: .5` plus
`saturate(.6)` on the entire body and console pushed every label under 3:1 —
in the one state where you most need to read what is on screen and work out
what died. Now the chrome dims behind a scrim and the type stays legible.
Input is still blocked.
### 7. Status marks that were saying the wrong thing
- `.vk-dot.error` was `neutral-200` — a bright white dot, which reads as
healthy. Now `danger-400`.
- `.vk-toast.error` and `.vk-toast.ok` differed only by one hue step of the same
near-white. Now genuinely different roles, plus a `warn` variant.
- `.vk-presence.idle` and dnd both landed on `neutral-200`, so they were
indistinguishable. idle drops a step; dnd keeps the bright dot and gains the
bar that makes it readable without colour.
### 8. Rules
Nocturne's rules fade to transparent over 48px a side. The channel header does
this; the settings panel's dozens of flat `inset 0 -1px 0` rules did not, which
is most of why that panel reads as a different product bolted on. Now fading.
---
## Needs a small markup change
The storage bar is colour-only. `media.css` now carries
`.vk-media-usage-legend` / `.vk-media-usage-key` — render one key per segment
beside the bar:
```html
<div class="vk-media-usage-legend">
<span class="vk-media-usage-key image"><i></i>images 4.1 GiB</span>
<span class="vk-media-usage-key gif"><i></i>gifs 0.8</span>
<span class="vk-media-usage-key video"><i></i>video 11.2</span>
<span class="vk-media-usage-key audio"><i></i>audio 0.3</span>
<span class="vk-media-usage-key"><i></i>other 0.1</span>
</div>
```
Add `.over` to `.vk-media-usage` past the warn threshold.
Also: `.vk-presence.dnd` now exists as a state. If `app.js` only emits
`online` / `idle` / `offline`, dnd is still rendering as plain online.
---
## Not changed — worth a conversation
**The Media and Bot tabs are a second application inside the shell.** They
don't use the rail, the right column or the console; they take over the whole
body with their own toolbar, their own grid, their own modals at their own
z-index tier. The patch makes them *look* like the rest of the app. It doesn't
make them *behave* like it. Worth deciding whether Media is a scope alongside
Guild/DM — which is what the segmented control currently implies — or a separate
surface that should stop pretending to be one.
**Settings is carrying nine sections in one slide-over**, several of which
(Flextalk voice profile, Bot audio, Speech) are deep enough to be their own
screens. The `<details>` grouping is holding for now, but the voice profile
panel already had to escape into its own draggable dialog, which is the usual
sign.
**`.vk-media-play` is a text glyph** (font-size 44px) where the rest of the app
uses Phosphor. Small, but it is the only place an icon doesn't match.
---
# Part two — icons, settings, menus, dialogs
## 9. Icons
`icons.js` is replaced. The old set was drawn on a 16-unit grid at a fixed 1.3
stroke; the new one is Phosphor geometry on Phosphor's 256 grid.
- **Optical weight is now constant.** Stroke is computed per render size
(`1.35 / size * 256`), so a 44px icon and an 11px icon carry the same line.
Before, stroke scaled with the icon and large ones looked heavy.
- **Round caps and joins throughout** — Phosphor regular's terminal, which the
old mic, camera and gear each handled differently.
- **The gear, mic, camera, refresh and warning were redrawn.** The gear was a
circle with eight straight spokes; the mic was a rounded rect over an arc.
They read as icons now rather than as diagrams.
- **19 icons added**: headphones / headphonesSlash (the listening and deafened
states, which had no mark of their own), screenshare, waveform, image, film,
audio, folder, pause, stop, check, plus, minus, dots, funnel, sliders,
download, copy, pencil, trash, pin, popout, info, error, ok, clock.
- **Every old name still resolves** — aliases cover `microphone`, `deafen`,
`video`, `close`, `settings`, `tts`, `magnify` — so nothing needs editing.
- Unknown names now warn in the console instead of rendering an empty `<svg>`.
That surfaced one already: `star` was used before it was defined, and worked
only because a later line patched it in.
- `VK.hydrateIcons(root)` fills any empty `[data-icon]`; it skips anything
already rendered, so it won't fight `app.js`.
If you would rather ship the real Phosphor package, swap the file for
`@phosphor-icons/web` and keep `VK.icon` as a wrapper — the names are Phosphor's
own, so the mapping is one to one.
## 10. `polish.js` — what CSS can't reach
Three text glyphs are doing an icon's job. This is a small idempotent pass with
a MutationObserver, so it also catches the Media and Bot tabs rebuilding:
- `<button id="scrape-x">X</button>` and its siblings → the `x` icon, plus
`aria-label="Close"`. There were several, in the scrape and clean modals.
- `.vk-media-play`'s ▶ character at 44px → the `play` icon.
- `.vk-info`'s `title` → `data-tip`, so the styled tooltip below is the only one
that fires, plus `aria-label` and `tabindex` so the help is reachable at all.
No markup edits. If you'd rather fold these into the source, the script says
exactly what to change.
## 11. Settings, menus, dialogs, toasts
`settings-popups.css`. Presentation only, no behaviour or markup changes.
- **Scrim.** 55% flat wash → 72% of the page ground with a 3px blur. The page
behind was legible enough that the eye kept reading it.
- **Panel.** Square left edge → `--radius-lg` on the leading corners, the
system's hairline, and a 200ms slide from the edge it is attached to.
- **Head is sticky** and sits on a fading rule. Settings scrolls a long way;
the title used to leave.
- **Section rows.** The card was a flat fill with rows running together. Now the
system edge, rows separated by rules that fade at both ends, values on
tabular numerals so they align down the column.
- **`.vk-info` was an unstyled letter `i`** with its help text trapped in a
native tooltip — half a second of delay, no styling, invisible on touch. It is
now a 14px ringed mark with a themed tooltip. Eleven help strings were sitting
in `title` attributes where most people would never see them.
- **Collapsible groups.** The summary was a bare uppercase line with nothing
saying it was a control. It is a row now — hover tint, turning caret, and the
body indented against a hairline so nesting is visible.
- **Native controls.** Selects kept the platform arrow and a 1px border, numbers
kept their spinners, ranges kept the platform track. All three now use the
system's edge, accent thumb and focus ring. The select caret is a mask taking
`--color-neutral-400`, not a hex baked into a data URI.
- **Menus** get the hairline, a 120ms entrance and an accent bar on the active
item instead of a tint with nothing anchoring it.
- **Dialogs.** The voice-profile drawer was square-topped against the scrim and
the media modals had unrelated radii. Both on `--radius-lg` and the system
edge now; the profile drawer gains a grab handle, since its head was already
the drag target but nothing said so.
- **Toasts** get an icon slot coloured by role from `tokens-status.css`.
- All of it respects `prefers-reduced-motion`.
## Worth doing next
`.vk-status-value:has(> .vk-select)` carries the select caret. If a select ever
ships outside a status row, wrap it in `.vk-select-wrap` — the rule is there.
+128 -27
View File
@@ -1,41 +1,126 @@
"use strict";
/* ─────────────────────────────────────────────────────────────────────────
Veskslop icons — Phosphor geometry.
Replaces the hand-drawn 16-unit set. Three things change:
1. Grid. Phosphor draws on 256 units; the old set drew on 16, so curves
were quantised to half-pixels and the mic, gear and camera all had
visibly different optical weights from each other.
2. Terminals. Round caps and joins throughout, as Phosphor regular.
3. Stroke. Held at a constant ~1.35 device pixels whatever the render
size, instead of a fixed 1.3 unit stroke that thickened as icons grew.
A 44px icon and a 13px icon now read as the same weight of line.
The API is unchanged — VK.icon(name, size) and VK.iconEl(name, size) —
and every previous name still resolves, so nothing needs editing to
adopt this. New names are listed at the bottom.
If you would rather ship the real Phosphor set, swap this file for
@phosphor-icons/web and keep VK.icon as a thin wrapper; the names below
are Phosphor's own, so the mapping is one to one.
───────────────────────────────────────────────────────────────────────── */
window.VK = window.VK ?? {};
const ICON_PATHS = {
hash: '<path d="M6 2L4.5 14M11.5 2L10 14M2.5 5.5h12M2 10.5h12"/>',
volume: '<path d="M8.5 2.5 4.5 6H2v4h2.5l4 3.5z"/><path d="M11 5.5a3.5 3.5 0 0 1 0 5"/>',
mic: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>',
/* ── Channels and navigation ── */
hash: '<path d="M96 40 72 216M184 40l-24 176M52 96h152M44 160h152"/>',
users: '<circle cx="104" cy="96" r="44"/><path d="M24 200a88 88 0 0 1 160 0"/><path d="M160 54a44 44 0 0 1 0 84"/><path d="M232 200a88 88 0 0 0-40-38"/>',
user: '<circle cx="128" cy="92" r="48"/><path d="M36 204a96 96 0 0 1 184 0"/>',
chat: '<path d="M52 212l12-36A88 88 0 1 1 128 216a87 87 0 0 1-40-10z"/><path d="M96 120h64M96 152h40"/>',
activity: '<path d="M24 128h40l24-64 40 128 24-64h80"/>',
robot: '<rect x="28" y="72" width="200" height="136" rx="24"/><path d="M128 28v44"/><circle cx="128" cy="24" r="10"/><path d="M92 136v12M164 136v12"/><path d="M104 176h48"/>',
/* ── Voice ── */
mic: '<rect x="96" y="24" width="64" height="112" rx="32"/><path d="M196 124a68 68 0 0 1-136 0"/><path d="M128 192v40M92 232h72"/>',
micSlash:
'<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/><path d="M2.5 13.5 13.5 2.5"/>',
speaker: '<path d="M3 10V8a5 5 0 0 1 10 0v2"/><rect x="1.5" y="9.5" width="3" height="4.5" rx="1.5"/><rect x="11.5" y="9.5" width="3" height="4.5" rx="1.5"/>',
speakerSlash:
'<path d="M3 10V8a5 5 0 0 1 10 0v2"/><rect x="1.5" y="9.5" width="3" height="4.5" rx="1.5"/><rect x="11.5" y="9.5" width="3" height="4.5" rx="1.5"/><path d="M2 14 14 2"/>',
monitor: '<rect x="1.5" y="2.5" width="13" height="9" rx="1.5"/><path d="M5.5 14h5M8 11.5v2.5"/>',
camera: '<rect x="1.5" y="4.5" width="9" height="7" rx="1.5"/><path d="M10.5 8l4-2.2v4.4z"/>',
caretDown: '<path d="M3.5 6l4.5 4.5L12.5 6"/>',
caretUp: '<path d="M3.5 10L8 5.5l4.5 4.5"/>',
x: '<path d="M3.5 3.5l9 9M12.5 3.5l-9 9"/>',
lock: '<rect x="3.5" y="7" width="9" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>',
search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
gear: '<circle cx="8" cy="8" r="2.2"/><path d="M8 1.8v2M8 12.2v2M1.8 8h2M12.2 8h2M3.6 3.6l1.4 1.4M11 11l1.4 1.4M12.4 3.6 11 5M5 11l-1.4 1.4"/>',
wave: '<path d="M2 6.5V4.5h6v2M5 4.5V12"/><path d="M10.5 5.5a3.2 3.2 0 0 1 0 5"/><path d="M12.8 3.5a6 6 0 0 1 0 9"/>',
warning: '<path d="M8 2 1.5 13.5h13z"/><path d="M8 6.5v3.5M8 12v.5"/>',
refresh: '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5V6H10"/>',
play: '<path d="M5 3.5 12.5 8 5 12.5z"/>',
microphone: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>',
users: '<circle cx="6" cy="5.5" r="2.5"/><path d="M1.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><path d="M11 3.5a2.5 2.5 0 0 1 0 5"/>',
activity: '<path d="M1.5 8h3l2-4.5 2.5 9L11 8h3.5"/>'
'<rect x="96" y="24" width="64" height="112" rx="32"/><path d="M196 124a68 68 0 0 1-136 0"/><path d="M128 192v40M92 232h72"/><path d="M44 36l168 184"/>',
volume: '<path d="M152 36 88 88H40v80h48l64 52z"/><path d="M188 96a44 44 0 0 1 0 64"/>',
speaker: '<path d="M152 36 88 88H40v80h48l64 52z"/><path d="M188 96a44 44 0 0 1 0 64"/><path d="M212 68a80 80 0 0 1 0 120"/>',
speakerSlash: '<path d="M152 36 88 88H40v80h48l64 52z"/><path d="M188 100l44 56M232 100l-44 56"/>',
headphones: '<path d="M44 176v-48a84 84 0 0 1 168 0v48"/><rect x="20" y="152" width="48" height="68" rx="20"/><rect x="188" y="152" width="48" height="68" rx="20"/>',
headphonesSlash:
'<path d="M44 176v-48a84 84 0 0 1 168 0v48"/><rect x="20" y="152" width="48" height="68" rx="20"/><rect x="188" y="152" width="48" height="68" rx="20"/><path d="M44 36l168 184"/>',
waveform: '<path d="M32 108v40M72 76v104M112 40v176M152 76v104M192 100v56M232 116v24"/>',
wave: '<path d="M32 92V64h96v28M80 64v128M52 192h56"/><path d="M168 96a44 44 0 0 1 0 64"/><path d="M200 70a80 80 0 0 1 0 116"/>',
/* ── Media ── */
monitor: '<rect x="24" y="48" width="208" height="136" rx="20"/><path d="M96 216h64M128 184v32"/>',
screenshare: '<rect x="24" y="48" width="208" height="136" rx="20"/><path d="M96 216h64"/><path d="M128 76v72M100 104l28-28 28 28"/>',
camera: '<rect x="24" y="64" width="144" height="128" rx="20"/><path d="M168 112l64-36v104l-64-36z"/>',
image: '<rect x="32" y="48" width="192" height="160" rx="20"/><circle cx="96" cy="100" r="16"/><path d="M32 172l56-48 48 40 32-24 56 48"/>',
film: '<rect x="32" y="48" width="192" height="160" rx="20"/><path d="M88 48v160M168 48v160M32 128h192M32 88h56M32 168h56M168 88h56M168 168h56"/>',
audio: '<circle cx="76" cy="188" r="32"/><circle cx="196" cy="164" r="28"/><path d="M108 188V72l116-28v120"/>',
folder: '<path d="M216 208H40a12 12 0 0 1-12-12V60a12 12 0 0 1 12-12h56l28 36h92a12 12 0 0 1 12 12v100a12 12 0 0 1-12 12z"/>',
play: '<path d="M76 40l148 88-148 88z"/>',
pause: '<rect x="76" y="48" width="40" height="160" rx="10"/><rect x="140" y="48" width="40" height="160" rx="10"/>',
stop: '<rect x="56" y="56" width="144" height="144" rx="20"/>',
/* ── Controls ── */
caretDown: '<path d="M208 96l-80 80-80-80"/>',
caretUp: '<path d="M48 160l80-80 80 80"/>',
caretRight: '<path d="M96 48l80 80-80 80"/>',
caretLeft: '<path d="M160 208 80 128l80-80"/>',
x: '<path d="M200 56 56 200M200 200 56 56"/>',
check: '<path d="M216 72 104 184l-56-56"/>',
plus: '<path d="M40 128h176M128 40v176"/>',
minus: '<path d="M40 128h176"/>',
dots: '<circle cx="128" cy="128" r="14"/><circle cx="60" cy="128" r="14"/><circle cx="196" cy="128" r="14"/>',
search: '<circle cx="116" cy="116" r="76"/><path d="M170 170l54 54"/>',
funnel: '<path d="M32 56h192l-76 88v72l-40-24v-48z"/>',
gear: '<circle cx="128" cy="128" r="40"/><path d="M128 20l26 22 34-4 13 32 32 13-4 34 22 27-22 27 4 34-32 13-13 32-34-4-26 22-26-22-34 4-13-32-32-13 4-34-22-27 22-27-4-34 32-13 13-32 34 4z"/>',
sliders: '<path d="M40 88h72M156 88h60M40 168h28M112 168h104"/><circle cx="134" cy="88" r="22"/><circle cx="90" cy="168" r="22"/>',
refresh: '<path d="M176 92h44V48"/><path d="M220 92A100 100 0 0 0 44 78"/><path d="M80 164H36v44"/><path d="M36 164a100 100 0 0 0 176 14"/>',
download: '<path d="M128 32v128M80 112l48 48 48-48M40 208h176"/>',
copy: '<rect x="80" y="80" width="144" height="144" rx="16"/><path d="M176 80V48a16 16 0 0 0-16-16H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h32"/>',
pencil: '<path d="M92 216H48a8 8 0 0 1-8-8v-44L164 40l52 52z"/><path d="M136 68l52 52"/>',
trash: '<path d="M36 64h184M96 64V40h64v24M60 64v148a16 16 0 0 0 16 16h104a16 16 0 0 0 16-16V64M108 108v72M148 108v72"/>',
pin: '<path d="M100 28h56l-8 60 40 44H68l40-44z"/><path d="M128 132v96"/>',
popout: '<path d="M144 40h72v72"/><path d="M128 128 216 40"/><path d="M188 152v56a12 12 0 0 1-12 12H48a12 12 0 0 1-12-12V80a12 12 0 0 1 12-12h56"/>',
lock: '<rect x="40" y="88" width="176" height="128" rx="20"/><path d="M88 88V56a40 40 0 0 1 80 0v32"/>',
star: '<path d="M128 24l30 62 68 10-49 48 12 68-61-32-61 32 12-68-49-48 68-10z"/>',
/* ── Status ── */
info: '<circle cx="128" cy="128" r="96"/><path d="M120 124h12v56M104 180h48"/><circle cx="126" cy="86" r="10"/>',
warning: '<path d="M110 40 22 190a20 20 0 0 0 18 30h176a20 20 0 0 0 18-30L146 40a20 20 0 0 0-36 0z"/><path d="M128 104v40"/><circle cx="128" cy="180" r="10"/>',
error: '<circle cx="128" cy="128" r="96"/><path d="M160 96l-64 64M96 96l64 64"/>',
ok: '<circle cx="128" cy="128" r="96"/><path d="M172 100l-56 56-32-32"/>',
clock: '<circle cx="128" cy="128" r="96"/><path d="M128 72v56h48"/>'
};
const FILLED = new Set(["star"]);
/* Aliases — every name the app used before this file landed. */
ICON_PATHS.microphone = ICON_PATHS.mic;
ICON_PATHS.deafen = ICON_PATHS.headphonesSlash;
ICON_PATHS.listen = ICON_PATHS.headphones;
ICON_PATHS.video = ICON_PATHS.camera;
ICON_PATHS.close = ICON_PATHS.x;
ICON_PATHS.settings = ICON_PATHS.gear;
ICON_PATHS.tts = ICON_PATHS.wave;
ICON_PATHS.magnify = ICON_PATHS.search;
ICON_PATHS.star = '<path d="M8 1.6l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.5 4.1 13.5l.8-4.3-3.2-3 4.4-.6z"/>';
const FILLED = new Set(["star", "play", "dots"]);
/* Solid marks keep a hairline of their own so they hold shape at 10-11px. */
const HAIRLINE = new Set(["dots"]);
window.VK.icon = function icon(name, size = 13) {
const path = ICON_PATHS[name] ?? "";
const fill = FILLED.has(name) ? "currentColor" : "none";
return `<svg width="${size}" height="${size}" viewBox="0 0 16 16" fill="${fill}" stroke="currentColor" stroke-width="1.3" aria-hidden="true">${path}</svg>`;
const path = ICON_PATHS[name];
if (!path) {
if (name) console.warn("VK.icon: unknown icon", name);
return "";
}
const filled = FILLED.has(name);
/* Constant optical weight: ~1.35 device px of stroke at any render size. */
const sw = Math.min(30, Math.max(12, +((1.35 / size) * 256).toFixed(1)));
return (
`<svg width="${size}" height="${size}" viewBox="0 0 256 256" ` +
`fill="${filled ? "currentColor" : "none"}" ` +
`stroke="${filled && !HAIRLINE.has(name) ? "none" : "currentColor"}" ` +
`stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" ` +
`aria-hidden="true" focusable="false">${path}</svg>`
);
};
window.VK.iconEl = function iconEl(name, size = 13) {
@@ -44,3 +129,19 @@ window.VK.iconEl = function iconEl(name, size = 13) {
span.innerHTML = window.VK.icon(name, size);
return span;
};
/* Fills any empty [data-icon] span. Safe to call repeatedly — it skips
anything already rendered, so it will not fight whatever app.js does. */
window.VK.hydrateIcons = function hydrateIcons(root = document) {
root.querySelectorAll("[data-icon]").forEach((el) => {
if (el.firstElementChild) return;
const size = Number(el.dataset.iconSize) || 13;
el.innerHTML = window.VK.icon(el.dataset.icon, size);
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => window.VK.hydrateIcons());
} else {
window.VK.hydrateIcons();
}
+4
View File
@@ -6,8 +6,11 @@
<title>veskslop debug</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="nocturne.css" />
<link rel="stylesheet" href="tokens-status.css" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="media.css" />
<link rel="stylesheet" href="style-overrides.css" />
<link rel="stylesheet" href="settings-popups.css" />
</head>
<body>
<div class="vk-app">
@@ -622,6 +625,7 @@
<div id="toasts" class="vk-toasts"></div>
<script src="icons.js"></script>
<script src="polish.js"></script>
<script src="api.js"></script>
<script src="app.js"></script>
<script src="media.js"></script>
+134 -73
View File
@@ -6,7 +6,7 @@
right: 0;
top: var(--media-top, 50px);
bottom: 0;
/* Below the settings slide-over (80); media modals stay above it. */
/* Chrome 40 · media 70 · settings 1000 · settings dialogs 1050 · toasts 1100 */
z-index: 70;
display: flex;
background: var(--color-bg);
@@ -38,7 +38,7 @@
.vk-media-title {
flex: 1;
font-weight: 600;
font-weight: 500;
}
.vk-media-toolbar {
@@ -71,7 +71,7 @@
gap: 6px;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
background: var(--color-neutral-900);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
color: var(--color-neutral-300);
@@ -99,7 +99,7 @@
justify-content: center;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
background: var(--color-neutral-900);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
cursor: pointer;
@@ -124,10 +124,10 @@
position: absolute;
top: 4px;
left: 6px;
font: 600 10px/1 ui-monospace, Menlo, monospace;
font: 500 10px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
color: var(--color-neutral-100);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
pointer-events: none;
}
@@ -137,8 +137,8 @@
right: 6px;
font-size: 20px;
line-height: 1;
color: rgba(255, 255, 255, 0.72);
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
color: var(--color-neutral-100);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
cursor: pointer;
}
@@ -149,7 +149,7 @@
max-height: 46vh;
overflow-y: auto;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
background: var(--color-neutral-900);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
@@ -212,7 +212,7 @@
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
background: var(--color-bg);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
@@ -258,14 +258,14 @@
.vk-media-viewer-media video {
max-width: 100%;
max-height: 100%;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
}
.vk-media-viewer-media iframe {
width: min(880px, 100%);
aspect-ratio: 16 / 9;
border: 0;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
}
.vk-media-viewer.vertical .vk-media-viewer-media {
@@ -297,7 +297,7 @@
padding: 0;
border: none;
background: none;
color: var(--color-accent-400, #7aa2ff);
color: var(--color-accent-300);
font: inherit;
text-align: left;
cursor: pointer;
@@ -310,9 +310,9 @@
}
.vk-media-card-kind {
font: 600 10px/1 ui-monospace, Menlo, monospace;
font: 500 10px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.08em;
color: var(--color-accent-400, var(--color-neutral-300));
color: var(--color-accent-300);
}
.vk-media-card-name {
@@ -354,7 +354,7 @@
border-radius: 50%;
background: var(--color-neutral-800);
color: var(--color-neutral-300);
font: 600 9px/1 ui-monospace, Menlo, monospace;
font: 500 9px/1 ui-monospace, Menlo, monospace;
cursor: help;
}
@@ -362,7 +362,7 @@
width: 90px;
padding: 3px 6px;
border: none;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
background: var(--color-neutral-900);
color: var(--color-neutral-200);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
@@ -384,23 +384,23 @@
}
.vk-seg-image {
background: #4f8cff;
background: var(--color-data-1);
}
.vk-seg-gif {
background: #8a6cff;
background: var(--color-data-2);
}
.vk-seg-video {
background: #ff7a45;
background: var(--color-data-3);
}
.vk-seg-audio {
background: #2fbf71;
background: var(--color-data-4);
}
.vk-seg-other {
background: #6b7280;
background: var(--color-data-5);
}
.vk-media-modal {
@@ -424,7 +424,7 @@
gap: 10px;
width: min(520px, 92vw);
padding: 14px;
border-radius: var(--radius-lg, 12px);
border-radius: var(--radius-lg);
background: var(--color-bg);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
@@ -465,7 +465,8 @@
.vk-media-tree-head {
padding: 6px 8px;
border: none;
border-radius: var(--radius-sm, 6px);
border-radius: var(--radius-sm);
cursor: pointer;
background: var(--color-neutral-900);
color: var(--color-neutral-200);
font: 500 12px/1.2 inherit;
@@ -510,7 +511,7 @@
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent-400, #4f8cff);
background: var(--color-accent-400);
animation: vk-media-pulse 1.2s ease-in-out infinite;
}
@@ -531,21 +532,22 @@
}
.vk-media-error {
color: #ff8080;
color: var(--color-danger-300);
}
/* Tinted, not flooded — and 10px so the count is actually readable. */
.vk-media-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
min-width: 16px;
height: 16px;
margin-left: 4px;
padding: 0 4px;
border-radius: 7px;
background: #e5484d;
color: #fff;
font: 600 9px/1 ui-monospace, Menlo, monospace;
border-radius: 8px;
background: var(--color-danger-800);
color: var(--color-danger-200);
font: 500 10px/1 ui-monospace, Menlo, monospace;
}
/* Bot tab: backfill jobs, stats, and settings. */
@@ -565,11 +567,11 @@
.vk-bot-limited {
padding: 8px 10px;
border-radius: 6px;
font: 500 11px/1.4 ui-monospace, Menlo, monospace;
color: #ffb0b0;
background: color-mix(in srgb, #ff5555 14%, transparent);
border: 1px solid color-mix(in srgb, #ff5555 35%, transparent);
border-radius: var(--radius-sm);
font: 400 11px/1.4 ui-monospace, Menlo, monospace;
color: var(--color-danger-200);
background: var(--color-danger-900);
box-shadow: inset 0 0 0 1px var(--color-danger-700);
}
.vk-bot-limited[hidden] {
@@ -577,8 +579,8 @@
}
.vk-bot-section {
border: 1px solid var(--color-divider);
border-radius: 6px;
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
border-radius: var(--radius-md);
padding: 8px 10px;
background: var(--color-surface);
}
@@ -592,7 +594,7 @@
}
.vk-bot-title {
font-weight: 600;
font-weight: 500;
font-size: 12px;
}
@@ -625,8 +627,8 @@
}
.vk-bot-job {
border: 1px solid var(--color-neutral-800);
border-radius: 5px;
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
border-radius: var(--radius-sm);
padding: 6px 8px;
display: flex;
flex-direction: column;
@@ -634,7 +636,7 @@
}
.vk-bot-job-running {
border-color: color-mix(in srgb, var(--color-accent, #4f8cff) 45%, transparent);
box-shadow: inset 0 0 0 1px var(--color-accent-700);
}
.vk-bot-job-head {
@@ -646,7 +648,7 @@
.vk-bot-job-title {
flex: 1;
font-weight: 600;
font-weight: 500;
font-size: 11px;
}
@@ -656,27 +658,28 @@
}
.vk-bot-state {
font: 600 10px/1.4 ui-monospace, Menlo, monospace;
font: 500 10px/1.4 ui-monospace, Menlo, monospace;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0 5px;
border-radius: 3px;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--color-neutral-800);
color: var(--color-neutral-300);
}
.vk-bot-state-running {
background: color-mix(in srgb, var(--color-accent, #4f8cff) 25%, transparent);
color: var(--color-accent-300, #9fc1ff);
background: var(--color-accent-900);
color: var(--color-accent-200);
}
.vk-bot-state-paused {
background: color-mix(in srgb, #ffaa33 20%, transparent);
color: #ffc880;
background: var(--color-warn-900);
color: var(--color-warn-200);
}
.vk-bot-state-done {
background: color-mix(in srgb, #44cc88 20%, transparent);
color: #8fe0b8;
background: var(--color-ok-900);
color: var(--color-ok-200);
}
.vk-bot-job-member {
@@ -687,14 +690,14 @@
.vk-bot-progress {
height: 4px;
border-radius: 2px;
border-radius: var(--radius-sm);
background: var(--color-neutral-800);
overflow: hidden;
}
.vk-bot-progress-fill {
height: 100%;
background: var(--color-accent-400, #4f8cff);
background: var(--color-accent-400);
}
.vk-bot-job-actions {
@@ -710,7 +713,7 @@
.vk-bot-group-title {
cursor: pointer;
font: 600 11px/1.6 ui-monospace, Menlo, monospace;
font: 500 11px/1.6 ui-monospace, Menlo, monospace;
color: var(--color-neutral-300);
}
@@ -739,11 +742,11 @@
}
.vk-media-limited {
color: #ff8080;
color: var(--color-danger-300);
}
.vk-media-done-note {
color: color-mix(in srgb, var(--color-neutral-400) 55%, transparent);
color: var(--color-neutral-500);
font-style: italic;
}
@@ -754,7 +757,7 @@
.vk-bot-eta {
cursor: help;
text-decoration: underline dotted color-mix(in srgb, var(--color-neutral-400) 60%, transparent);
text-decoration: underline dotted var(--color-neutral-600);
}
/* Stream buffer: scrollable room while the next page loads. */
@@ -778,7 +781,7 @@
/* Advanced drawer: the "Do not include" group. */
.vk-media-exclude-title {
cursor: pointer;
font: 600 11px/1.6 ui-monospace, Menlo, monospace;
font: 500 11px/1.6 ui-monospace, Menlo, monospace;
color: var(--color-neutral-300);
}
@@ -800,8 +803,8 @@
/* The YouTube title catch-up lane, inside the Bot tab backfill section. */
.vk-bot-lane {
border: 1px dashed var(--color-neutral-700);
border-radius: 5px;
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
border-radius: var(--radius-sm);
padding: 6px 8px;
margin-bottom: 8px;
display: flex;
@@ -814,15 +817,15 @@
}
.vk-bot-progress-cached {
background: var(--color-accent-400, #4f8cff);
background: var(--color-accent-400);
}
.vk-bot-progress-pending {
background: var(--color-neutral-700);
background: var(--color-neutral-800);
}
.vk-bot-progress-failed {
background: #c04040;
background: var(--color-danger-700);
}
.vk-bot-history {
@@ -830,20 +833,20 @@
}
.vk-bot-history-title {
font: 600 11px/1.6 ui-monospace, Menlo, monospace;
font: 500 11px/1.6 ui-monospace, Menlo, monospace;
color: var(--color-neutral-300);
margin-bottom: 2px;
}
.vk-bot-history-entry {
font: 400 10px/1.5 ui-monospace, Menlo, monospace;
color: var(--color-neutral-500);
color: var(--color-neutral-400);
}
.vk-bot-hint {
margin-top: 8px;
font: 400 10px/1.5 ui-monospace, Menlo, monospace;
color: var(--color-neutral-500);
color: var(--color-neutral-400);
}
/* Infinite/page toggle and pager. */
@@ -856,7 +859,7 @@
.vk-media-toggle {
display: inline-flex;
border: 1px solid var(--color-divider);
border-radius: 5px;
border-radius: var(--radius-md);
overflow: hidden;
}
@@ -870,8 +873,14 @@
}
.vk-toggle-half.vk-toggle-on {
background: var(--color-accent-400, #4f8cff);
color: #fff;
background: var(--color-accent-900);
color: var(--color-accent-200);
box-shadow: inset 0 0 0 1px var(--color-accent-700);
}
.vk-toggle-half:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.vk-media-page-nav {
@@ -893,7 +902,59 @@
}
.vk-media-viewer-title {
font: 600 13px/1.5 ui-monospace, Menlo, monospace;
font: 500 13px/1.5 ui-monospace, Menlo, monospace;
color: var(--color-text);
margin-bottom: 6px;
}
/* ── Focus: these controls are bare <button>s with background and border
reset, so they need the accent ring pinned back on. ───────────────────── */
.vk-media-link:focus-visible,
.vk-media-tree-head:focus-visible,
.vk-media-play:focus-visible,
.vk-media-media:focus-visible,
.vk-bot-group-title:focus-visible,
.vk-media-exclude-title:focus-visible,
.vk-media-advanced summary:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Storage bar legend: hue is never the only channel. Render one of these
per segment beside the bar so the categories survive a colour-blind
reader, a projector, and a screenshot. ────────────────────────────────── */
.vk-media-usage-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 6px;
}
.vk-media-usage-key {
display: inline-flex;
align-items: center;
gap: 6px;
font: 400 10px/1.4 ui-monospace, Menlo, monospace;
color: var(--color-neutral-400);
}
.vk-media-usage-key i {
width: 8px;
height: 8px;
flex: none;
border-radius: 2px;
background: var(--color-data-5);
}
.vk-media-usage-key.image i { background: var(--color-data-1); }
.vk-media-usage-key.gif i { background: var(--color-data-2); }
.vk-media-usage-key.video i { background: var(--color-data-3); }
.vk-media-usage-key.audio i { background: var(--color-data-4); }
/* Over the warn threshold the bar itself should say so. */
.vk-media-usage.over {
box-shadow: inset 0 0 0 1px var(--color-warn-700);
}
+76
View File
@@ -0,0 +1,76 @@
"use strict";
/* ─────────────────────────────────────────────────────────────────────────
Veskslop UI polish — progressive enhancement, no markup edits.
Three things CSS cannot reach:
· close buttons that are the letter "X"
· the play control that is a ▶ character at 44px
· help text trapped in a native title tooltip
Each fix is idempotent and runs again on DOM changes, so it survives the
Media and Bot tabs rebuilding their trees. Load AFTER icons.js.
───────────────────────────────────────────────────────────────────────── */
(function () {
const VK = window.VK;
if (!VK || !VK.icon) return;
const GLYPH_CLOSE = /^[xX✕✖×]$/;
const GLYPH_PLAY = /^[▶►▸]$/;
function polish(root) {
if (!root || root.nodeType !== 1) return;
/* Text-glyph close buttons → the x icon, with a real label. */
root.querySelectorAll("button, .vk-chip").forEach((el) => {
const text = (el.textContent || "").trim();
if (GLYPH_CLOSE.test(text)) {
el.innerHTML = VK.icon("x", 13);
el.classList.add("vk-icon-btn");
if (!el.getAttribute("aria-label")) el.setAttribute("aria-label", "Close");
} else if (GLYPH_PLAY.test(text)) {
el.innerHTML = VK.icon("play", 22);
if (!el.getAttribute("aria-label")) el.setAttribute("aria-label", "Play");
}
});
/* .vk-media-play is a ▶ character styled at 44px. */
root.querySelectorAll(".vk-media-play").forEach((el) => {
if (el.querySelector("svg")) return;
el.innerHTML = VK.icon("play", 22);
if (!el.getAttribute("aria-label")) el.setAttribute("aria-label", "Play");
});
/* Help marks: move title → data-tip so the styled tooltip is the only
one that fires, and keep the text reachable for screen readers. */
root.querySelectorAll(".vk-info").forEach((el) => {
const tip = el.getAttribute("title");
if (tip) {
el.dataset.tip = tip;
el.removeAttribute("title");
el.setAttribute("aria-label", tip);
}
if (!el.textContent.trim()) el.textContent = "i";
if (!el.hasAttribute("tabindex")) el.tabIndex = 0;
el.setAttribute("role", "note");
});
if (VK.hydrateIcons) VK.hydrateIcons(root);
}
function run() {
polish(document.body);
new MutationObserver((records) => {
for (const r of records) {
r.addedNodes.forEach((n) => polish(n));
}
}).observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();
+476
View File
@@ -0,0 +1,476 @@
/* ─────────────────────────────────────────────────────────────────────────
Veskslop — settings, menus, dialogs and toasts.
The shell reads as Nocturne; these surfaces did not. They were built from
flat 1px rules, bare native controls and text glyphs, so opening Settings
or a scrape dialog felt like leaving the app. Nothing here changes
behaviour or markup — it is presentation only.
Load LAST: nocturne.css · tokens-status.css · style.css · media.css ·
style-overrides.css · settings-popups.css
───────────────────────────────────────────────────────────────────────── */
/* ── 1. The scrim ─────────────────────────────────────────────────────────
A flat 55% wash left the page behind perfectly legible, so the eye kept
reading it. Deepen it, blur what is behind, and fade it in. */
.vk-overlay {
background: color-mix(in srgb, var(--color-bg) 72%, transparent);
backdrop-filter: blur(3px) saturate(0.9);
animation: vk-scrim-in 140ms ease-out;
}
@keyframes vk-scrim-in {
from {
opacity: 0;
}
}
/* ── 2. Settings panel ────────────────────────────────────────────────────
A slide-over with a square left edge butting into a blurred page reads as
a torn sheet. Round the leading corners, give it the system's hairline,
and let it arrive from the edge it is attached to. */
.vk-settings-panel {
width: min(720px, 100%);
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
box-shadow: var(--shadow-lg), inset 1px 0 0 var(--color-neutral-800);
animation: vk-panel-in 200ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes vk-panel-in {
from {
transform: translateX(18px);
opacity: 0;
}
}
/* Head: sticky, so the title survives a long scroll, and on the system's
fading rule rather than a flat one. */
.vk-settings-head {
position: sticky;
top: 0;
z-index: 2;
padding: 13px 16px;
background: var(--color-surface);
box-shadow: none;
background-image: linear-gradient(
to right,
transparent,
var(--color-neutral-800) 40px,
var(--color-neutral-800) calc(100% - 40px),
transparent
);
background-repeat: no-repeat;
background-position: bottom;
background-size: 100% 1px;
}
.vk-settings-title {
letter-spacing: -0.01em;
}
.vk-settings-body {
gap: 26px;
padding: 18px 16px 28px;
scrollbar-width: thin;
scrollbar-color: var(--color-neutral-800) transparent;
}
.vk-settings-body::-webkit-scrollbar {
width: 10px;
}
.vk-settings-body::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 6px;
background: var(--color-neutral-800) padding-box;
}
.vk-settings-body::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-700) padding-box;
}
/* ── 3. Section heads ─────────────────────────────────────────────────────
Label, breathing room, then the actions pinned right on the same
baseline — previously the buttons crowded the label. */
.vk-section-head {
align-items: center;
gap: 12px;
padding: 0 2px 9px;
}
.vk-settings-actions {
gap: 6px;
}
/* ── 4. Cards and rows ────────────────────────────────────────────────────
The card was a flat fill; rows ran into each other. Give the card the
system edge and separate rows with fading rules, inset from both ends. */
.vk-settings-card {
padding: 4px 12px;
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
.vk-status-row {
align-items: center;
gap: 16px;
padding: 9px 0;
box-shadow: none;
background: linear-gradient(
to right,
transparent,
var(--color-neutral-800) 16px,
var(--color-neutral-800) calc(100% - 16px),
transparent
)
no-repeat bottom / 100% 1px;
}
.vk-status-row:last-child,
.vk-settings-card > .vk-status-row:last-of-type {
background: none;
}
.vk-status-row .vk-status-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-neutral-400);
}
.vk-status-row .vk-status-value {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
font-variant-numeric: tabular-nums;
}
/* ── 5. The info affordance ───────────────────────────────────────────────
.vk-info was an unstyled letter "i" sitting in the text, and its help
text only existed as a native browser tooltip — half a second of delay,
no styling, and invisible on touch. Make it a real mark with a themed
tooltip. polish.js moves title → data-tip so the native one stops
double-firing; the title fallback below keeps it working without JS. */
.vk-info {
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
flex: none;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--color-neutral-700);
font: 500 9px/1 var(--font-heading);
color: var(--color-neutral-400);
cursor: help;
position: relative;
transition: color 120ms, box-shadow 120ms;
}
.vk-info:hover,
.vk-info:focus-visible {
color: var(--color-accent-300);
box-shadow: inset 0 0 0 1px var(--color-accent-700);
}
.vk-info::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
translate: -50% 0;
z-index: 30;
width: max-content;
max-width: 280px;
padding: 8px 10px;
border-radius: var(--radius-md);
background: var(--color-neutral-900);
box-shadow: var(--shadow-md), inset 0 0 0 1px var(--color-neutral-800);
font: 400 11.5px/1.5 var(--font-body);
letter-spacing: 0;
text-transform: none;
text-align: left;
color: var(--color-neutral-200);
opacity: 0;
pointer-events: none;
transition: opacity 120ms;
}
.vk-info:not([data-tip])::after {
content: attr(title);
}
.vk-info:hover::after,
.vk-info:focus-visible::after {
opacity: 1;
}
/* ── 6. Collapsible groups ────────────────────────────────────────────────
The summary was a bare line of uppercase text; nothing said it was a
control. Give it a row of its own, a hover tint and a caret that turns. */
.vk-settings-summary {
padding: 7px 10px;
border-radius: var(--radius-sm);
background: var(--color-neutral-900);
transition: background 120ms, color 120ms;
}
.vk-settings-summary:hover {
background: var(--color-neutral-800);
color: var(--color-accent-200);
}
.vk-settings-summary .vk-caret {
display: inline-flex;
color: var(--color-neutral-400);
transition: transform 160ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.vk-settings-group[open] > .vk-settings-summary {
color: var(--color-accent-200);
}
.vk-settings-group-body {
gap: 24px;
padding: 16px 0 4px 10px;
box-shadow: inset 1px 0 0 var(--color-neutral-900);
margin-left: 4px;
}
/* ── 7. Native controls ───────────────────────────────────────────────────
Selects kept the platform arrow and a 1px border; numbers kept their
spinners; ranges kept the platform track. All three read as unstyled. */
.vk-select {
appearance: none;
padding: 5px 26px 5px 9px;
border: none;
border-radius: var(--radius-sm);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
background-color: var(--color-bg);
background-image: none;
cursor: pointer;
transition: box-shadow 120ms;
}
/* The caret is a mask, not a coloured data-URI, so it takes its colour from
the token rather than from a hex baked into the URL. */
.vk-select-wrap {
position: relative;
display: inline-flex;
}
.vk-select-wrap::after,
.vk-status-value:has(> .vk-select)::after {
content: "";
position: absolute;
right: 9px;
top: 50%;
translate: 0 -50%;
width: 9px;
height: 9px;
pointer-events: none;
background: var(--color-neutral-400);
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='none' stroke='%23000' stroke-width='26' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M208 96l-80 80-80-80'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.vk-status-value:has(> .vk-select) {
position: relative;
}
.vk-select:hover {
box-shadow: inset 0 0 0 1px var(--color-neutral-700);
}
.vk-select:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.vk-media-input,
input[type="number"].input {
font-variant-numeric: tabular-nums;
text-align: right;
}
.vk-media-input::-webkit-outer-spin-button,
.vk-media-input::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
input[type="range"] {
appearance: none;
height: 18px;
background: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
background: var(--color-neutral-800);
}
input[type="range"]::-moz-range-track {
height: 3px;
border-radius: 2px;
background: var(--color-neutral-800);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
margin-top: -4.5px;
border-radius: 50%;
background: var(--color-accent-200);
box-shadow: 0 0 0 1px var(--color-accent);
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border: none;
border-radius: 50%;
background: var(--color-accent-200);
box-shadow: 0 0 0 1px var(--color-accent);
}
input[type="range"]:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── 8. Menus ─────────────────────────────────────────────────────────────
The guild picker floated with no edge, and the active item was a tint
with nothing to anchor it. */
.vk-menu {
padding: 5px;
box-shadow: var(--shadow-lg), inset 0 0 0 1px var(--color-neutral-800);
animation: vk-menu-in 120ms ease-out;
scrollbar-width: thin;
scrollbar-color: var(--color-neutral-800) transparent;
}
@keyframes vk-menu-in {
from {
transform: translateY(-4px);
opacity: 0;
}
}
.vk-menu-item {
border-radius: var(--radius-sm);
transition: background 100ms, color 100ms;
}
.vk-menu-item.active {
box-shadow: inset 2px 0 0 var(--color-accent);
}
/* ── 9. Dialogs ───────────────────────────────────────────────────────────
The voice-profile drawer was square-topped and flat-bottomed against the
scrim; the media modals had their own unrelated corner radii. */
.vk-profile-panel {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg), inset 0 0 0 1px var(--color-neutral-800);
}
.vk-profile-panel .vk-settings-head {
cursor: grab;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.vk-profile-panel .vk-settings-head:active {
cursor: grabbing;
}
/* A grab handle, so the drag target is discoverable. */
.vk-profile-panel .vk-settings-head::before {
content: "";
position: absolute;
top: 6px;
left: 50%;
translate: -50% 0;
width: 34px;
height: 3px;
border-radius: 2px;
background: var(--color-neutral-800);
}
.vk-profile-panel .vk-settings-head {
position: relative;
padding-top: 15px;
}
.vk-media-modal-box {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg), inset 0 0 0 1px var(--color-neutral-800);
}
.vk-media-head strong {
font: 500 14px/1.2 var(--font-heading);
letter-spacing: -0.01em;
}
/* ── 10. Toasts ───────────────────────────────────────────────────────────
Icon slot, roles carried by tokens, and an entrance that comes from the
corner they live in. */
.vk-toast {
display: flex;
align-items: flex-start;
gap: 9px;
line-height: 1.5;
box-shadow: var(--shadow-md), inset 0 0 0 1px var(--color-neutral-800);
animation: vk-toast-in 160ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes vk-toast-in {
from {
transform: translateY(6px);
opacity: 0;
}
}
.vk-toast .vk-icon {
flex: none;
margin-top: 1px;
}
.vk-toast.error .vk-icon {
color: var(--color-danger-400);
}
.vk-toast.warn .vk-icon {
color: var(--color-warn-400);
}
.vk-toast.ok .vk-icon {
color: var(--color-ok-400);
}
/* ── 11. Motion ───────────────────────────────────────────────────────────*/
@media (prefers-reduced-motion: reduce) {
.vk-overlay,
.vk-settings-panel,
.vk-menu,
.vk-toast {
animation: none;
}
.vk-settings-summary .vk-caret {
transition: none;
}
}
+168
View File
@@ -0,0 +1,168 @@
/* ─────────────────────────────────────────────────────────────────────────
Veskslop style overrides — Nocturne fidelity pass.
Small corrections to style.css. Load LAST, after style.css and media.css.
Fold into style.css when convenient; kept separate here so nothing you
edited gets clobbered.
───────────────────────────────────────────────────────────────────────── */
/* ── 1. Weight ────────────────────────────────────────────────────────────
Nocturne: headings never go past 500; hierarchy is size and space.
"Speaking" was font-weight 700, which also reflows the row as it toggles.
Carry it in color instead — the level meter beside it is the loud signal. */
.vk-person-name.speaking,
.vk-voice-member .speaking,
.vk-right-name .speaking,
.vk-right-name .vk-name.speaking {
font-weight: 500;
color: var(--color-accent-200);
}
/* ── 2. Focus ─────────────────────────────────────────────────────────────
nocturne.css gives :focus-visible a 2px accent ring, but every control
built from a bare <button> that resets background and border loses the
ring's contrast against its own surface. Pin it back on explicitly. */
.vk-rail-row:focus-visible,
.vk-rail-voice-head:focus-visible,
.vk-menu-item:focus-visible,
.vk-guild-mark:focus-visible,
.vk-scope-btn:focus-visible,
.vk-console-tab:focus-visible,
.vk-console-button:focus-visible,
.vk-chip:focus-visible,
.vk-star:focus-visible,
.vk-tab:focus-visible,
.vk-settings-summary:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Inset the ring where the control sits flush in a scroller, so it is not
clipped by the overflow edge. */
.vk-rail-row:focus-visible,
.vk-menu-item:focus-visible {
outline-offset: -2px;
}
/* ── 3. Presence ──────────────────────────────────────────────────────────
idle and dnd both landed on neutral-200, so they were indistinguishable.
idle drops a step; dnd keeps the bright dot and adds the bar that makes
it readable without color. */
.vk-presence.idle {
background: var(--color-neutral-400);
}
.vk-presence.dnd {
display: grid;
place-items: center;
background: var(--color-neutral-200);
}
.vk-presence.dnd::after {
content: "";
width: 4px;
height: 1.5px;
background: var(--color-surface);
}
/* ── 4. Rules ─────────────────────────────────────────────────────────────
Nocturne rules fade to transparent over 48px a side. The channel header
does this; the settings panel's dozens of flat rules do not, which is
what makes that panel read as a different product. */
.vk-settings-head,
.vk-status-row {
box-shadow: none;
background: linear-gradient(
to right,
transparent,
var(--color-neutral-900) 24px,
var(--color-neutral-900) calc(100% - 24px),
transparent
)
no-repeat bottom / 100% 1px;
}
.vk-settings-head {
background: linear-gradient(
to right,
transparent,
var(--color-neutral-800) 24px,
var(--color-neutral-800) calc(100% - 24px),
transparent
)
no-repeat bottom / 100% 1px;
}
.vk-status-row:last-child {
background: none;
}
/* ── 5. Toasts ────────────────────────────────────────────────────────────
The error toast was a neutral-200 stripe — the same mark the ok toast
used, one hue apart. Now they are genuinely different roles. */
.vk-toast.error {
box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-danger-400);
}
.vk-toast.warn {
box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-warn-400);
}
.vk-toast.ok {
box-shadow: var(--shadow-md), inset 3px 0 0 var(--color-ok-400);
}
/* ── 6. Offline ───────────────────────────────────────────────────────────
opacity .5 + saturate .6 pushes every label under 3:1 — the one state
where you most need to read what is on screen. Dim the chrome, not the
type, and say why. */
.vk-offline .vk-body,
.vk-offline .vk-console {
opacity: 1;
filter: none;
pointer-events: none;
}
.vk-offline .vk-body::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: color-mix(in srgb, var(--color-bg) 45%, transparent);
}
.vk-offline .vk-body {
position: relative;
}
.vk-offline .vk-composer,
.vk-offline .vk-rail,
.vk-offline .vk-right {
opacity: 0.72;
}
/* ── 7. The bridge dot ────────────────────────────────────────────────────
.error was neutral-200 — a white dot reads as "on". */
.vk-dot.error {
background: var(--color-danger-400);
}
.vk-dot.warn {
background: var(--color-warn-400);
}
/* ── 8. QR ────────────────────────────────────────────────────────────────
Pure white is correct here (scanner contrast), but give it the system's
edge so it is not a bare rectangle floating on the panel. */
.vk-qr {
box-shadow: var(--shadow-sm);
}
+86
View File
@@ -0,0 +1,86 @@
/* ─────────────────────────────────────────────────────────────────────────
Nocturne status + data tokens — additive layer for Veskslop.
Why this file exists: Nocturne ships one accent and a neutral ramp. It has
no error / warning / success role and no categorical data scale, so every
feature that needed one invented its own hex (#e5484d, #ffaa33, #44cc88,
#4f8cff, #ff8080 …). That is how a system drifts.
These roles are generated the same way Nocturne's own ramps are: OKLCH,
on the SAME shared lightness scale, so step 400 of danger has the same
visual weight as step 400 of accent or neutral. Chroma stays low — the
accent is still the loudest thing on screen.
Lightness scale, read off Nocturne's neutral ramp:
200 .924 · 300 .859 · 400 .763 · 700 .443 · 800 .339 · 900 .240
Load AFTER nocturne.css and BEFORE style.css.
───────────────────────────────────────────────────────────────────────── */
:root {
/* danger — hue 25. Failed jobs, rate limits, destructive confirms. */
--color-danger-200: oklch(0.924 0.045 25);
--color-danger-300: oklch(0.859 0.075 25);
--color-danger-400: oklch(0.763 0.105 25);
--color-danger-700: oklch(0.443 0.085 25);
--color-danger-800: oklch(0.339 0.065 25);
--color-danger-900: oklch(0.24 0.045 25);
/* warn — hue 75. Paused, degraded, over-threshold, mentions. */
--color-warn-200: oklch(0.924 0.045 75);
--color-warn-300: oklch(0.859 0.075 75);
--color-warn-400: oklch(0.763 0.105 75);
--color-warn-700: oklch(0.443 0.085 75);
--color-warn-800: oklch(0.339 0.065 75);
--color-warn-900: oklch(0.24 0.045 75);
/* ok — hue 155. Completed, healthy, connected. Used sparingly: the
accent already carries "live", so ok is for terminal success only. */
--color-ok-200: oklch(0.924 0.05 155);
--color-ok-300: oklch(0.859 0.08 155);
--color-ok-400: oklch(0.763 0.11 155);
--color-ok-700: oklch(0.443 0.09 155);
--color-ok-800: oklch(0.339 0.07 155);
--color-ok-900: oklch(0.24 0.05 155);
/* Categorical data scale — for the media storage bar and nothing else.
One lightness, five hues stepping away from the accent, all at low
chroma so the bar reads as a muted family rather than five alarms.
Always pair with a text legend: hue is never the only channel. */
--color-data-1: oklch(0.62 0.075 289); /* image — the accent's own hue */
--color-data-2: oklch(0.62 0.075 250); /* gif */
--color-data-3: oklch(0.62 0.075 205); /* video */
--color-data-4: oklch(0.62 0.075 155); /* audio */
--color-data-5: oklch(0.62 0.02 289); /* other — near-neutral */
/* Mention highlight: was a loose #d29922 declared mid-stylesheet. */
--color-mention: var(--color-warn-400);
}
/* ── Status helpers ────────────────────────────────────────────────────────
Tinted, never flooded. Full-opacity ink on the tint so small text clears
contrast; the fill is a deep ramp step, not an alpha wash. */
.vk-status-tint-danger {
background: var(--color-danger-900);
box-shadow: inset 0 0 0 1px var(--color-danger-700);
color: var(--color-danger-200);
}
.vk-status-tint-warn {
background: var(--color-warn-900);
box-shadow: inset 0 0 0 1px var(--color-warn-700);
color: var(--color-warn-200);
}
.vk-status-tint-ok {
background: var(--color-ok-900);
box-shadow: inset 0 0 0 1px var(--color-ok-700);
color: var(--color-ok-200);
}
.vk-status-tint-neutral {
background: var(--color-neutral-900);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
color: var(--color-neutral-300);
}