From aa0dfc0ee92c9bc50a686e753d2436b5312e31fd Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 17:43:18 +0000 Subject: [PATCH 01/28] scene: one owner of ink per sky state (names, phone-dusk labels), New Messages as plain label ink Sender names over the terrain step to the state's ink: same OKLCH hue and chroma, darker by day and dusk, lighter at night, until 4.5:1 on the lightest measured terrain patch (capped at OKLCH L 0.9 at night). Sidebar labels get a darker dusk ink on phones. An explicit theme still wins. The unread divider keeps only its green line. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 3 +- src/app/features/room-nav/styles.css.ts | 3 +- src/app/features/room/RoomTimeline.tsx | 13 +- src/app/features/room/message/Message.tsx | 6 +- src/app/features/scene/sceneShell.css.ts | 7 + src/app/features/scene/skyInk.test.ts | 177 ++++++++++++++++++++++ src/app/features/scene/skyInk.ts | 95 ++++++++++++ src/app/hooks/useMemberPowerTag.ts | 15 +- src/app/hooks/useTheme.ts | 29 +++- src/app/pages/ThemeManager.tsx | 22 ++- src/util/colorMXID.js | 10 +- 11 files changed, 361 insertions(+), 19 deletions(-) create mode 100644 src/app/features/scene/skyInk.test.ts create mode 100644 src/app/features/scene/skyInk.ts diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index e5db6c47..6d1a6691 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,7 +1,7 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; -import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk, surfaceInk } from '../../features/scene/sceneShell.css'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. @@ -222,6 +222,7 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { textDecorationLine: 'underline', }); phoneLabel(`:root[data-scene] ${label}`, { + color: skyLabelInk('inherit'), textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, }); diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b9655923..e5ab4f8e 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,6 +1,6 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk, surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, @@ -15,6 +15,7 @@ export const CategoryButton = style({ borderRadius: 0, }, ':root[data-scene] &&': { + color: skyLabelInk('inherit'), textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, filter: `drop-shadow(0 0 3px ${surfaceInk})`, }, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c533528d..552fb04c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -123,7 +123,8 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; -import { useTheme } from '../../hooks/useTheme'; +import { useSkyInk, useTheme } from '../../hooks/useTheme'; +import { skyLabelInk } from '../scene/sceneShell.css'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; @@ -462,10 +463,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const theme = useTheme(); + const skyInk = useSkyInk(); const accessiblePowerTagColors = useAccessiblePowerTagColors( theme.kind, creatorsTag, - powerLevelTags + powerLevelTags, + skyInk ); const permissions = useRoomPermissions(creators, powerLevels); @@ -1674,9 +1677,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli newDivider && eventJSX && eventSender !== mx.getUserId() ? ( - - New Messages - + + New Messages + ) : null; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 5c4421b6..62bb4dae 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -82,6 +82,8 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; +import { useSkyInk } from '../../../hooks/useTheme'; +import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -723,6 +725,7 @@ export const Message = as<'div', MessageProps>( ref ) => { const mx = useMatrixClient(); + const skyInk = useSkyInk(); const useAuthentication = useMediaAuthentication(); const senderId = mEvent.getSender() ?? ''; @@ -743,7 +746,8 @@ export const Message = as<'div', MessageProps>( ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; - const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; + const hashColor = skyInk ? userColorInk(senderId, skyInk) : colorMXID(senderId); + const usernameColor = legacyUsernameColor ? hashColor : tagColor; const headerJSX = !collapse && ( by the theme manager (ThemeManager.tsx) only where it + * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. + */ +export const SKY_LABEL_INK = '--sky-label-ink'; +export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts new file mode 100644 index 00000000..4cf2a6c4 --- /dev/null +++ b/src/app/features/scene/skyInk.test.ts @@ -0,0 +1,177 @@ +import chroma from 'chroma-js'; +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, NIGHT_SKY_LIGHTEST, luminance } from '../../../sky/skyTokens.js'; +import { indexColorMXID } from '../../../util/colorMXID'; +import type { FixedSkyState } from './skyBand'; +import { + LIGHTEST_PATCH, + NIGHT_INK_MAX_L, + inkForState, + labelInk, + sceneInk, + userColorInk, +} from './skyInk'; +import { sceneThemeId } from './skyTheme'; + +// Every name colour the client can show over the scene, per state and viewport: Aqua's key colour #22d3e6 as +// accessibleColor leaves it (Lab L capped to 50 on a light theme: #00889a; unchanged on a dark one: #22d3e6), and +// the eight hash hues of the theme the state wears (noonday for day and dusk, midnight for night). Columns: name, +// state, phone, colour in, ink out. Aqua's day and dusk rows are the values measured in +// ratatoskr-sidebar-contrast-20261110/results.md (T4): #05707f, #016a78, #055763, #03454f, and #43e5f8 at night. +const TABLE: Array<[string, FixedSkyState, boolean, string, string]> = [ + ['aqua', 'day', false, '#00889a', '#05707f'], + ['aqua', 'day', true, '#00889a', '#016a78'], + ['aqua', 'dusk', false, '#00889a', '#055763'], + ['aqua', 'dusk', true, '#00889a', '#03454f'], + ['aqua', 'night', false, '#22d3e6', '#43e5f8'], + ['aqua', 'night', true, '#22d3e6', '#87f1ff'], + ['hash1', 'day', false, '#2b4675', '#2b4675'], + ['hash1', 'day', true, '#2b4675', '#2b4675'], + ['hash1', 'dusk', false, '#2b4675', '#2b4675'], + ['hash1', 'dusk', true, '#2b4675', '#233e6c'], + ['hash1', 'night', false, '#4683ff', '#bcd3ff'], + ['hash1', 'night', true, '#4683ff', '#cbddfe'], + ['hash2', 'day', false, '#5d366d', '#5d366d'], + ['hash2', 'day', true, '#5d366d', '#5d366d'], + ['hash2', 'dusk', false, '#5d366d', '#5d366d'], + ['hash2', 'dusk', true, '#5d366d', '#562f66'], + ['hash2', 'night', false, '#986fff', '#d4cbfe'], + ['hash2', 'night', true, '#986fff', '#ded7fe'], + ['hash3', 'day', false, '#214e3e', '#214e3e'], + ['hash3', 'day', true, '#214e3e', '#214e3e'], + ['hash3', 'dusk', false, '#214e3e', '#214e3e'], + ['hash3', 'dusk', true, '#214e3e', '#184636'], + ['hash3', 'night', false, '#3893b1', '#86defe'], + ['hash3', 'night', true, '#3893b1', '#ace8ff'], + ['hash4', 'day', false, '#6d334a', '#6d334a'], + ['hash4', 'day', true, '#6d334a', '#6d334a'], + ['hash4', 'dusk', false, '#6d334a', '#6d334a'], + ['hash4', 'dusk', true, '#6d334a', '#652c43'], + ['hash4', 'night', false, '#b26adc', '#e8c7fe'], + ['hash4', 'night', true, '#b26adc', '#edd2ff'], + ['hash5', 'day', false, '#5f401d', '#5f401d'], + ['hash5', 'day', true, '#5f401d', '#5f401d'], + ['hash5', 'dusk', false, '#5f401d', '#5f401d'], + ['hash5', 'dusk', true, '#5f401d', '#563814'], + ['hash5', 'night', false, '#9f8365', '#ecceae'], + ['hash5', 'night', true, '#9f8365', '#f6d8b7'], + ['hash6', 'day', false, '#204c52', '#204c52'], + ['hash6', 'day', true, '#204c52', '#204c52'], + ['hash6', 'dusk', false, '#204c52', '#204c52'], + ['hash6', 'dusk', true, '#204c52', '#19454b'], + ['hash6', 'night', false, '#358dde', '#b1d7ff'], + ['hash6', 'night', true, '#358dde', '#c6e1fe'], + ['hash7', 'day', false, '#41426c', '#41426c'], + ['hash7', 'day', true, '#41426c', '#41426c'], + ['hash7', 'dusk', false, '#41426c', '#41426c'], + ['hash7', 'dusk', true, '#41426c', '#3b3b65'], + ['hash7', 'night', false, '#677dff', '#c4d1fe'], + ['hash7', 'night', true, '#677dff', '#d3ddfe'], + ['hash8', 'day', false, '#3f4a20', '#3f4a20'], + ['hash8', 'day', true, '#3f4a20', '#3f4a20'], + ['hash8', 'dusk', false, '#3f4a20', '#3f4a20'], + ['hash8', 'dusk', true, '#3f4a20', '#394319'], + ['hash8', 'night', false, '#6a926a', '#b2ddb1'], + ['hash8', 'night', true, '#6a926a', '#c0ebc0'], +]; + +const patchRatio = (hex: string, state: FixedSkyState, phone: boolean) => { + const p = LIGHTEST_PATCH[state][phone ? 'phone' : 'desktop']; + const l = luminance(hex); + return (Math.max(l, p) + 0.05) / (Math.min(l, p) + 0.05); +}; +const hueGap = (a: string, b: string) => { + const d = Math.abs(chroma(a).oklch()[2] - chroma(b).oklch()[2]) % 360; + return Math.min(d, 360 - d); +}; + +describe('inkForState', () => { + it.each(TABLE)('%s %s phone=%s: %s -> %s', (_name, state, phone, base, ink) => { + expect(inkForState(base, { state, phone })).toBe(ink); + }); + + it('clears 4.5:1 on the lightest patch, keeps the hue, and moves lightness the way the state needs', () => { + TABLE.forEach(([, state, phone, base, ink]) => { + const [l0] = chroma(base).oklch(); + const [l1] = chroma(ink).oklch(); + if (ink === base) { + expect(patchRatio(base, state, phone)).toBeGreaterThanOrEqual(AA); + return; + } + expect(state === 'night' ? l1 > l0 : l1 < l0).toBe(true); + expect(hueGap(base, ink)).toBeLessThan(2); + if (l1 < NIGHT_INK_MAX_L - 0.006) + expect(patchRatio(ink, state, phone)).toBeGreaterThanOrEqual(AA); + }); + }); + + it('stops lightening at NIGHT_INK_MAX_L: a phone-night name keeps its hue instead of reaching 4.5:1 on the one pale snow patch', () => { + const aqua = inkForState('#22d3e6', { state: 'night', phone: true }); + expect(aqua).toBe('#87f1ff'); + expect(chroma(aqua).oklch()[0]).toBeLessThanOrEqual(NIGHT_INK_MAX_L); + expect(chroma(aqua).oklch()[1]).toBeGreaterThan(0.08); + expect(patchRatio(aqua, 'night', true)).toBeGreaterThan(3.9); + // The one-patch figure is what the cap gives up; against the sky's own lightest (skyBackdrop) it clears AA. + expect((luminance(aqua) + 0.05) / (NIGHT_SKY_LIGHTEST + 0.05)).toBeGreaterThanOrEqual(AA); + }); + + it('leaves a colour that already clears its patch untouched, and a non-colour alone', () => { + expect(inkForState('#2b4675', { state: 'day', phone: false })).toBe('#2b4675'); + expect(inkForState('var(--mx-uc-1)', { state: 'day', phone: false })).toBe('var(--mx-uc-1)'); + }); +}); + +describe('userColorInk', () => { + it('takes the hash colour of the theme the state wears, then steps it', () => { + const id = '@aqua:gamecult.org'; + const n = indexColorMXID(id) - 1; + expect(userColorInk(id, { state: 'dusk', phone: true })).toBe( + inkForState(baked.noonday.userColors[n], { state: 'dusk', phone: true }) + ); + expect(userColorInk(id, { state: 'night', phone: false })).toBe( + inkForState(baked.midnight.userColors[n], { state: 'night', phone: false }) + ); + }); +}); + +describe('sceneInk: an explicit theme wins over the auto ink', () => { + it('is the sky state and viewport only while the sky theme is worn and a scene runs', () => { + // auto, scene on, no theme of the person's own: the sky theme is worn. + expect(sceneInk(sceneThemeId('auto', 'dusk', true, false), true, 'dusk', true)).toEqual({ + state: 'dusk', + phone: true, + }); + // auto, but the person chose a theme: it wins, the theme's own ink stands. + expect(sceneInk(sceneThemeId('auto', 'dusk', true, true), true, 'dusk', true)).toBeUndefined(); + // a fixed sky state is itself a choice and wins over the theme (skyTheme.ts), so its ink applies. + expect(sceneInk(sceneThemeId('night', 'night', true, true), true, 'night', false)).toEqual({ + state: 'night', + phone: false, + }); + // no scene behind the page: nothing for the ink to be measured against. + expect( + sceneInk(sceneThemeId('auto', 'day', false, false), false, 'day', false) + ).toBeUndefined(); + expect(sceneInk(sceneThemeId('day', 'day', true, false), false, 'day', false)).toBeUndefined(); + }); +}); + +describe('labelInk', () => { + it('differs from the theme only on a phone at dusk', () => { + const states: FixedSkyState[] = ['day', 'dusk', 'night']; + states.forEach((state) => + [false, true].forEach((phone) => { + expect(labelInk({ state, phone })).toBe(state === 'dusk' && phone ? '#29221a' : undefined); + }) + ); + }); + + it('is the shipped dark ink, same hue and chroma, a little darker', () => { + const dark = baked.noonday.tokens.Surface.OnContainer; + const ink = labelInk({ state: 'dusk', phone: true }) as string; + expect(dark).toBe('#342d25'); + expect(luminance(ink)).toBeLessThan(luminance(dark)); + expect(hueGap(dark, ink)).toBeLessThan(2); + }); +}); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts new file mode 100644 index 00000000..7219159c --- /dev/null +++ b/src/app/features/scene/skyInk.ts @@ -0,0 +1,95 @@ +// The one owner of ink for a sky state: which colour a sender name or a sidebar label wears over the scene, +// per fixed sky state (day, dusk, night) and viewport (phone or not). Pure: the hooks that read the resolved +// sky and the viewport (useSkyInk, hooks/useTheme.ts) only hand their values here. +// +// Names are bare ink over the terrain, so the theme's fitted colours (fitted against panel grounds, skyTokens.js) +// can land on the same luminance as the ridge behind them. The rule is measured, not tuned by eye: keep the +// colour's OKLCH hue and chroma and move its lightness (darker by day and dusk, lighter at night, in 0.005 steps) +// until it clears 4.5:1 (WCAG) against the LIGHTEST 24x12 patch of terrain under the timeline in that state +// and viewport. Measured 2026-11-10 on the demo's terrain scene at 1920x1080 and 390x844 (Chrome, SwiftShader, +// not a GPU; ratatoskr-sidebar-contrast-20261110/results.md, T3). These are the scene's own patches, not +// `skyBackdrop` (skyState.ts): that is the bare sky's extreme (day 0.90, dusk 0.97), which the terrain under the +// timeline never shows, and at night the terrain's snow (0.103, phone 0.149) is lighter than the sky (0.0816). +// Only a person whose theme is the sky's (no explicit theme of their own, sceneThemeId) wears this ink. +import chroma from 'chroma-js'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { baked } from '../../../sky/baked'; +import { indexColorMXID } from '../../../util/colorMXID'; +import type { FixedSkyState } from './skyBand'; +import type { SkyThemeId } from './skyTheme'; + +export interface SkyInk { + readonly state: FixedSkyState; + /** At or under the repo's mobile breakpoint (ScreenSize.Mobile, hooks/useScreenSize.ts); tablets read as desktop. */ + readonly phone: boolean; +} + +/** + * The ink in force: only while the page wears the sky's theme (`themeId`, from sceneThemeId, so a person's explicit + * theme, which that function lets win over 'auto', keeps its own ink) and a scene runs behind it. Otherwise undefined. + */ +export const sceneInk = ( + themeId: SkyThemeId | undefined, + sceneOn: boolean, + state: FixedSkyState, + phone: boolean +): SkyInk | undefined => (themeId && sceneOn ? { state, phone } : undefined); + +/** WCAG relative luminance of the lightest 24x12 patch of terrain behind the timeline. */ +export const LIGHTEST_PATCH: Record = { + day: { desktop: 0.777, phone: 0.703 }, + dusk: { desktop: 0.531, phone: 0.4 }, + night: { desktop: 0.103, phone: 0.149 }, +}; + +/** Night lightening stops here (OKLCH L): above it every hue reads white, and the hue is what names the sender. */ +export const NIGHT_INK_MAX_L = 0.9; + +const STEP = 0.005; +const SKY_COLORS = { noonday: baked.noonday.userColors, midnight: baked.midnight.userColors }; + +const inGamut = (l: number, c: number, h: number): string | undefined => { + for (let k = c; k > 1e-4; k *= 0.97) { + const out = chroma.oklch(l, k, h); + if (!out.clipped()) return out.hex(); + } + return chroma.oklch(l, 0, 0).hex(); +}; + +/** The ink for a colour (#rrggbb) in this sky state and viewport: itself when it already clears the lightest patch. */ +export function inkForState(hex: string, { state, phone }: SkyInk): string { + if (!chroma.valid(hex)) return hex; + const patch = LIGHTEST_PATCH[state][phone ? 'phone' : 'desktop']; + const ratio = (h: string) => { + const l = luminance(h); + return (Math.max(l, patch) + 0.05) / (Math.min(l, patch) + 0.05); + }; + if (ratio(hex) >= AA) return hex; + const [l0, c0, h0] = chroma(hex).oklch(); + const hue = Number.isNaN(h0) ? 0 : h0; + const dir = state === 'night' ? 1 : -1; + let best = hex; + for (let k = 1; k < 200; k += 1) { + const l = l0 + dir * k * STEP; + if (l < 0 || l > (state === 'night' ? NIGHT_INK_MAX_L : 1)) break; + best = inGamut(l, c0, hue) ?? best; + if (ratio(best) >= AA) break; + } + return best; +} + +/** The ink of a user's hash colour (the --mx-uc-1..8 the theme would give) in this sky state and viewport. */ +export function userColorInk(userId: string, ink: SkyInk): string { + const palette = ink.state === 'night' ? SKY_COLORS.midnight : SKY_COLORS.noonday; + return inkForState(palette[indexColorMXID(userId) - 1], ink); +} + +/** + * The sidebar label ink where it differs from the theme's. Phone Dusk: the dark ink #342d25 reached a median of 3.98:1 + * on the glyph pixels (worst 1.66); the same hue and chroma at OKLCH L 0.257 (#29221a) reaches 4.53 (worst 1.92), + * measured over the shipped dusk phone shot, halo included. Nothing darker is searched for: the ridge's dusk ochre + * sits at the dark ink's own luminance, so the worst pixels stay below 4.5. Every other state keeps the theme's + * ink (undefined): desktop and night were measured as shipped. + */ +export const labelInk = ({ state, phone }: SkyInk): string | undefined => + state === 'dusk' && phone ? '#29221a' : undefined; diff --git a/src/app/hooks/useMemberPowerTag.ts b/src/app/hooks/useMemberPowerTag.ts index 31e52aa0..68051749 100644 --- a/src/app/hooks/useMemberPowerTag.ts +++ b/src/app/hooks/useMemberPowerTag.ts @@ -6,6 +6,7 @@ import { MemberPowerTag, MemberPowerTagIcon } from '../../types/matrix/room'; import { useRoomCreatorsTag } from './useRoomCreatorsTag'; import { ThemeKind } from './useTheme'; import { accessibleColor } from '../plugins/color'; +import { inkForState, type SkyInk } from '../features/scene/skyInk'; export type GetMemberPowerTag = (userId: string) => MemberPowerTag; @@ -44,23 +45,29 @@ export const getPowerTagIconSrc = ( export const useAccessiblePowerTagColors = ( themeKind: ThemeKind, creatorsTag: MemberPowerTag, - powerLevelTags: PowerLevelTags + powerLevelTags: PowerLevelTags, + skyInk?: SkyInk ): Map => { const accessibleColors: Map = useMemo(() => { const colors: Map = new Map(); + // Over the scene a name wears the sky state's ink (skyInk.ts) in place of the theme's accessible colour's own step. + const ink = (color: string) => { + const accessible = accessibleColor(themeKind, color); + return skyInk ? inkForState(accessible, skyInk) : accessible; + }; if (creatorsTag.color) { - colors.set(creatorsTag.color, accessibleColor(themeKind, creatorsTag.color)); + colors.set(creatorsTag.color, ink(creatorsTag.color)); } Object.values(powerLevelTags).forEach((tag) => { const { color } = tag; if (!color) return; - colors.set(color, accessibleColor(themeKind, color)); + colors.set(color, ink(color)); }); return colors; - }, [powerLevelTags, creatorsTag, themeKind]); + }, [powerLevelTags, creatorsTag, themeKind, skyInk]); return accessibleColors; }; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 60f89757..1ef0278d 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,9 @@ import '../../sky.css'; import { defaultThemeId, settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { sceneThemeId } from '../features/scene/skyTheme'; +import { sceneInk, type SkyInk } from '../features/scene/skyInk'; import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; +import { ScreenSize, useScreenSizeContext } from './useScreenSize'; export enum ThemeKind { Light = 'light', @@ -113,18 +115,39 @@ const chosenTheme = (useSystemTheme: boolean, themeId?: string) => export const useSkyTheme = (): Theme | undefined => toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); -export const useActiveTheme = (): Theme => { +/** The sky theme the signed-in page wears, or undefined when the person's own theme wins. */ +const useSceneSkyThemeId = (): ReturnType => { const sky = useSkyState(); const resolved = useResolvedSkyState(); const sceneOn = useSceneOn(); + const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + const [themeId] = useSetting(settingsAtom, 'themeId'); + // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. + return sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId)); +}; + +/** The ink the page wears over the scene (`sceneInk`, skyInk.ts), or undefined when the theme's own ink stands. */ +export const useSceneInk = (): SkyInk | undefined => { + const themeId = useSceneSkyThemeId(); + const state = useResolvedSkyState(); + const sceneOn = useSceneOn(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + return useMemo(() => sceneInk(themeId, sceneOn, state, phone), [themeId, sceneOn, state, phone]); +}; + +const SkyInkContext = createContext(undefined); +export const SkyInkContextProvider = SkyInkContext.Provider; +/** The ink for this sky state and viewport (provided once by the theme manager), or undefined for the theme's own. */ +export const useSkyInk = (): SkyInk | undefined => useContext(SkyInkContext); + +export const useActiveTheme = (): Theme => { const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); - // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. - const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); + const skyTheme = toTheme(useSceneSkyThemeId()); if (skyTheme) return skyTheme; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index b7c50d93..980d35b7 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -2,12 +2,16 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { MidnightSkyTheme, + SkyInkContextProvider, ThemeContextProvider, useActiveTheme, + useSceneInk, useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +import { labelInk } from '../features/scene/skyInk'; +import { SKY_LABEL_INK } from '../features/scene/sceneShell.css'; export function UnAuthRouteThemeManager() { // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the @@ -26,6 +30,18 @@ export function UnAuthRouteThemeManager() { export function AuthRouteThemeManager({ children }: { children: ReactNode }) { const activeTheme = useActiveTheme(); const [monochromeMode] = useSetting(settingsAtom, 'monochromeMode'); + const skyInk = useSceneInk(); + const labels = skyInk && labelInk(skyInk); + + // The sky's label ink where it differs from the theme's (skyInk.ts); unset, the theme's ink stands. + useEffect(() => { + const root = document.documentElement; + if (labels) root.style.setProperty(SKY_LABEL_INK, labels); + else root.style.removeProperty(SKY_LABEL_INK); + return () => { + root.style.removeProperty(SKY_LABEL_INK); + }; + }, [labels]); useEffect(() => { document.body.className = ''; @@ -40,5 +56,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { } }, [activeTheme, monochromeMode]); - return {children}; + return ( + + {children} + + ); } diff --git a/src/util/colorMXID.js b/src/util/colorMXID.js index 95600d29..efebf90c 100644 --- a/src/util/colorMXID.js +++ b/src/util/colorMXID.js @@ -10,16 +10,20 @@ function hashCode(str) { for (i = 0; i < str.length; i += 1) { chr = str.charCodeAt(i); // eslint-disable-next-line no-bitwise - hash = ((hash << 5) - hash) + chr; + hash = (hash << 5) - hash + chr; // eslint-disable-next-line no-bitwise hash |= 0; } return Math.abs(hash); } +/** Which of the eight user colours (1..8) a user id takes. */ +export function indexColorMXID(userId) { + return (hashCode(userId) % 8) + 1; +} + export function cssColorMXID(userId) { - const colorNumber = hashCode(userId) % 8; - return `--mx-uc-${colorNumber + 1}`; + return `--mx-uc-${indexColorMXID(userId)}`; } export default function colorMXID(userId) { From de9955a24a241cfdc9a77a18ab485e9e81212300 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 17:59:07 +0000 Subject: [PATCH 02/28] dings: Aqua's v4 set (Void single onset, Nibu proposal 3 everywhere), still behind dings.enabled Replaces the 32 sender clips with AquaSynth 21e83ec's staged render; manifest and neutral fallback are byte-identical to the old ones. The manifest test now pins every clip by sha256 prefix. Unheard. Co-Authored-By: Claude Sonnet 5.5 --- public/dings/README.md | 4 +- public/dings/senders/aqua/dm.ogg | Bin 4687 -> 4687 bytes public/dings/senders/aqua/invite.ogg | Bin 4863 -> 4863 bytes public/dings/senders/aqua/mention.ogg | Bin 4538 -> 4538 bytes public/dings/senders/aqua/message.ogg | Bin 4563 -> 4563 bytes public/dings/senders/eureka/dm.ogg | Bin 4583 -> 4583 bytes public/dings/senders/eureka/invite.ogg | Bin 4727 -> 4727 bytes public/dings/senders/eureka/mention.ogg | Bin 4443 -> 4443 bytes public/dings/senders/eureka/message.ogg | Bin 4425 -> 4425 bytes public/dings/senders/heimdall/dm.ogg | Bin 5184 -> 5184 bytes public/dings/senders/heimdall/invite.ogg | Bin 5476 -> 5477 bytes public/dings/senders/heimdall/mention.ogg | Bin 5217 -> 5217 bytes public/dings/senders/heimdall/message.ogg | Bin 4746 -> 4746 bytes public/dings/senders/kiko/dm.ogg | Bin 5703 -> 5703 bytes public/dings/senders/kiko/invite.ogg | Bin 5627 -> 5627 bytes public/dings/senders/kiko/mention.ogg | Bin 5044 -> 5044 bytes public/dings/senders/kiko/message.ogg | Bin 5090 -> 5091 bytes public/dings/senders/libby/dm.ogg | Bin 5426 -> 5425 bytes public/dings/senders/libby/invite.ogg | Bin 5613 -> 5613 bytes public/dings/senders/libby/mention.ogg | Bin 5108 -> 5108 bytes public/dings/senders/libby/message.ogg | Bin 5075 -> 5074 bytes public/dings/senders/nibu/dm.ogg | Bin 6557 -> 6556 bytes public/dings/senders/nibu/invite.ogg | Bin 6583 -> 6583 bytes public/dings/senders/nibu/mention.ogg | Bin 6257 -> 6257 bytes public/dings/senders/nibu/message.ogg | Bin 5663 -> 5662 bytes public/dings/senders/void/dm.ogg | Bin 5402 -> 4754 bytes public/dings/senders/void/invite.ogg | Bin 5667 -> 5035 bytes public/dings/senders/void/mention.ogg | Bin 5022 -> 4561 bytes public/dings/senders/void/message.ogg | Bin 5362 -> 4570 bytes public/dings/senders/weksa/dm.ogg | Bin 5682 -> 5683 bytes public/dings/senders/weksa/invite.ogg | Bin 6120 -> 6122 bytes public/dings/senders/weksa/mention.ogg | Bin 5291 -> 5292 bytes public/dings/senders/weksa/message.ogg | Bin 5272 -> 5272 bytes src/app/features/dings/manifest.test.ts | 55 +++++++++++++++++----- 34 files changed, 44 insertions(+), 15 deletions(-) diff --git a/public/dings/README.md b/public/dings/README.md index 573a78f9..b36e1fb7 100644 --- a/public/dings/README.md +++ b/public/dings/README.md @@ -12,6 +12,6 @@ with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to th A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps its stock notification sounds; once one exists, a motif with no clip is silent. -The set here is Aqua's v3 (AquaSynth PR #22, staged at minds.gamecult.org/aqua/dings-voices-70a9795), -Nibu on proposal 1 (static-filter square). Nobody has heard it. The client plays none of it unless the +The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66), +Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the client config sets `"dings": { "enabled": true }` (default off, as in `config.json`). diff --git a/public/dings/senders/aqua/dm.ogg b/public/dings/senders/aqua/dm.ogg index 0a2b98da6b7cd4b4836af63bbbdf70323b098fdb..5a23554656f200923f9ff45ff79b50f5a12a466b 100644 GIT binary patch delta 83 zcmX@Fa$ZHsKRrE|fr$YMmh4h@2Qk~YA1<0GrN;x}GXnW8XRjRI81jb~EC5o?UyxWo YIfm~LQ{4N>3jEKRDjsd-6mVk#0LAwi8vpeO2QlUNG6R0j6iG6R0j6l9n|I4czL;moB1wg8=FrMGC JIfgHe82~AC5w8FM diff --git a/public/dings/senders/aqua/message.ogg b/public/dings/senders/aqua/message.ogg index ce68c47aa5e6474202000332c852eaa000f12680..3704f55aa24d3b942ab7dc4639ea754a15452c18 100644 GIT binary patch delta 69 zcmcbtd|6q_KRrE|fr$YM<~aD+ftc0DqwY?W(&GW~8G-!BYisQ{hWz0L3xHI&Rjir4 JIfk#C831S76CD5m delta 69 zcmcbtd|6q_KRrE|fr$YMwk$j+4`QYs%+#7FrN;x}GXnX6b&eSuL;moB1wg8=^1eB@ JIfk#C832%&6r}(F diff --git a/public/dings/senders/eureka/dm.ogg b/public/dings/senders/eureka/dm.ogg index a64c22f60b4025f0ac3569c2edf5cbd8126740c5..57024fbe5d6d158ebb190f7096dee302840c9771 100644 GIT binary patch delta 69 zcmaE^{9IYeKRrE|fr$YMS|&7QfSAjCHpoqs(&GW~8G-y?kE4w@hWz0L3xHI=l)wFK Ja|~ZMGXR@76+8d{ delta 69 zcmaE^{9IYeKRrE|fr$YM=1j5W1~F&c{~$S0N{M{TM$hWz0L3xHIA6??R3 Ja}1v}3jn2Z6t@5X diff --git a/public/dings/senders/eureka/mention.ogg b/public/dings/senders/eureka/mention.ogg index 0025d7429d2c03ead0c324b27675bc9a4791ed65..c4085faead552f391c5ae999be696952dbec8541 100644 GIT binary patch delta 69 zcmcbubX!TvKRrE|fr$YMdQMN11~G5R>Y7cI(&GW~8G-yN?lQ5BA%A$m0wC2N)fE?P Jj^X>y1OQ@-69WJM delta 69 zcmcbubX!TvKRrE|fr$YMmh_3Lf|&c7JqjmE>G6R0j6nX$nTk&~hWz0L3xHIAj@Mqe JIfm~)698l36KVhe diff --git a/public/dings/senders/eureka/message.ogg b/public/dings/senders/eureka/message.ogg index beb2a203b9d9af6218fde08c56c7c07126c0d4e0..6cd340ce391adf6c38117879b4170fad7febede9 100644 GIT binary patch delta 69 zcmX@9bW%ymKRrE|fr$YMYP5XWK}@qQ9> delta 69 zcmX@9bW%ymKRrE|fr$YMrks}rvo1<1Zk#Bk#{=Rs0!41$Q;XXe@`o2J08*V3pr^k% IhVLyC08MBT0{{R3 diff --git a/public/dings/senders/heimdall/dm.ogg b/public/dings/senders/heimdall/dm.ogg index f4844c021e4bca621f43c9cc89dec0f19c16c0d0..145e885336ba8be55c558c981273ace4d1088bbc 100644 GIT binary patch delta 91 zcmX@0aX>@LKRrE|fr$YMQnuYn2Qj7mejS`BrN;x}GXnXM;ubSDhWz0L3xHH_l~MaJ gIfk!8@Z$nIMus=@_=3A_yj?$wT5)dv%ID1r0J`TKvj6}9 delta 91 zcmX@0aX>@LKRrE|fr$YMYU*-wK+FY)_U28L(&GW~8G-ympV=304Ee(g767T{ZGZB3 gatvRGU|qm`28J^K_LZ*lmM;3qHJ4@cS3Yl60K%#r=l}o! diff --git a/public/dings/senders/heimdall/invite.ogg b/public/dings/senders/heimdall/invite.ogg index 6e27e760b4f9aa1387826ffc5633be9da21ec8c9..98403c21128d08f0960988357323e9255b6b963d 100644 GIT binary patch delta 142 zcmaE&^;ApBKRrE|fr$YMLfp-zLCn)xJN8YK(&GW~8G-zzTPkO54Ee(g767T{bbDq! zIfhS?(Q$JbUp1@k-B00+JNDTs9M~@EVfUW@Yz>ROwHWGrHtUI)GXemWrz{=- diff --git a/public/dings/senders/heimdall/mention.ogg b/public/dings/senders/heimdall/mention.ogg index e006414703fe25088289ba127c85f579645d7cf1..5a39a98ce44d2b6673af338c344850af4fd23bbc 100644 GIT binary patch delta 123 zcmaE;@lZp`KRrE|fr$YMa$=8(ftVao|5r|w(&GW~8G-y1XE%k7A%A$m0wC49vQ<4c z$MB^v8q^E$W>{a?yKvbKCWb$OlSE^dgA8DJ@U<+YuW${z4W2c!wzz delta 123 zcmaE;@lZp`KRrE|fr$YMT2kV~K}@!*9XBUR>G6R0j6l9;j_Tu$A%A$m0wC3bXLerN z9K)BwXt2MN?~CkG6R0j6nWD{t0#)L;moB1wg80>*6~m Q$MA(PEtlE6iSGt80J)D8J^%m! delta 76 zcmeBD?NXKUPfrhKU}AuR65l=%5Hsfe#qx<#dORRLBanaFb>F^?A%A$m0wC4-+z%or Q$MA(PeV5z3iSGt80NaZdt^fc4 diff --git a/public/dings/senders/kiko/dm.ogg b/public/dings/senders/kiko/dm.ogg index 8b76726e0b934f2c741cd1844fec12b9ec8e6c67..7c35b072c4ed71a0c4942185fe976cd5266f1aba 100644 GIT binary patch delta 124 zcmX@Eb6iKtKRrE|fr$YMTy$PYf|x~9YJDe4>G6R0j6nX|TO}7ahWz0L3xHI=?&tl# zIficuo1u8Vf4=IMn*Wn0S8V>xT(q~#LzsaflfjRffkT{uA%LBM;Yd6qgTtE*+vGqd MKgir{C@RJX0LL37x&QzG delta 124 zcmX@Eb6iKtKRrE|fr$YMVs<RNwp za}1vXix~FM%d{&o&$N>=s UhYpa!1DawC3=zDO*Ngf90I0z%0{{R3 delta 134 zcmeyZ{aahgKRrE|fr$YMd?qIG6R0j6i-;O=jfAkUzX&0g!6b*>$d) zWB3$U#P}a5wdjG=0D=GEpPCK_Cmr~=a`Pmizf21G_a>LsT>bkse%;q}Q&$151TF>z T7m&gSBHRoNYuF~Q7xe)EzU3%P diff --git a/public/dings/senders/kiko/mention.ogg b/public/dings/senders/kiko/mention.ogg index 388dbbabfff693398d68b15fdbf993f3a4d85163..12cccf293a5cf9c59988f06a428a7e6ee674f0ac 100644 GIT binary patch delta 113 zcmdm@zC~TiKRrE|fr$YMeC+4iftV#b6FyIr(&GW~8G-!!b}uD2hWz0L3xHG$tQX$9 zIfm~xBh$Y(lPv{Qn0NjR-kc!tj!FEg2q!B;s4#~($Q%ZSbGbmGL7ic8s<1x*$f_Q8 delta 113 zcmdm@zC~TiKRrE|fr$YMQo5FefSA=4>vm0)(&GW~8G-y;2GdG6R0j6lAhi@D>*kUzZQAOVo-C!Gtn zNa<>&+1T5dSz4HGPUo{_(*Hk8Ns*PoL5g7kTMG}!7*-(G;AUX>vD7-7f#CrM1B2W) fn|$+w)1RID`Si`^D+0F|SzUpO95!zjGGGJ%NH!;= diff --git a/public/dings/senders/libby/dm.ogg b/public/dings/senders/libby/dm.ogg index 8b580a4bfc3c17780ebb3ed86552d970bdfe9825..1316c19600e1fda5f082ccbb6245736fd55b0058 100644 GIT binary patch delta 156 zcmdm_wNXpTKRrE|fr$YMR6ZrKgP2Z>X0DznrN;x}GXnYT#zHO|L;mn8fdoLRyR2XE zD@b{}xmsG9SXmh8n~SL_#d%4oiyIR;78YVC?2pEA( so_Cq?KqM!F!(NdC6~~wu8qR!A;{*yj6i=2HzQ>Zi&rW_bqlh6R0NWWYDF6Tf delta 156 zcmdm}wMk3LKRrE|fr$YMY@AZWK+G>EpEXUC(&GW~8G-ya2K?q5L;mn8fdoLR7X?jS zq9En%=4xqaVr5~VZ!V^$6zk>W=xDpSh_8ypc<1MuTmSdJzE|2==sWQO1A{;i1A`MA tP~(AOD+UW~MurvBnIz6fFfdH`c~1vqLk8buIpKRO+WYMEH;EWB0s!9vEI$AM diff --git a/public/dings/senders/libby/invite.ogg b/public/dings/senders/libby/invite.ogg index f88e6d2877996eb88e609a02884f7b57fc8c18d2..4b92a0b7d285989c60e106eabb1a72d3aeee91b6 100644 GIT binary patch delta 112 zcmaE>{Z?DbKRrE|fr$YML@r11f|%vkWj0Ne(&GW~8G(GoKlP>?L;moB1wg7@ekVWJ x9K&~?RrPLG7fU7+!xByghD4x*91x3eevqCQ&j(~NFgU!l-Id|IIYFeC5ddU)9bEtb delta 112 zcmaE>{Z?DbKRrE|fr$YMbdI`mf|yJWJ7^M*4_Kf&o1A*T)>wZ0A?mG0ssI2 diff --git a/public/dings/senders/libby/message.ogg b/public/dings/senders/libby/message.ogg index d923f60bdd2fad0bc6ecfdebc6e4de6b0f60685b..432f8623c5cd8de934c10644e03c39a185c8888c 100644 GIT binary patch delta 135 zcmcbteo0-*KRrE|fr$YMWKMjx0x>rzyV*^Y(&GW~8G-yqjVkgRL;mneg9JdT_3iWC zNz2NGdpX-!8e5qfx=2ZE&gQ$yB=Wy)W42?!m#H9A7#ilS)xG?vVsohADMk_Z)tW+5 W3=D_N85smXk_Eo(3nm*2F8~1fQzoDQ delta 136 zcmcblepy}0KRrE|fr$YMOe{B7fS89K$f-?~(&GW~8G-y2A2{kahWz1`1_^*vU;qA= zTUu5w+{@X<(%8z>&_zmeb2i^qCeix#jmrZaE`~#ltlGA%=g3E%%^`xP7)7F2YYIsL U%{FIb5CBy4TaPRzE)6Vzyew+Qplo=D88S1{>HWmB&SJ`oDrf=X6 zMwt_Km&AbjzsL)57*9-bG$?M}dvf(OYPfudAtoQ;%)c;xxK0@jJM>2IM57+c;?NQ#ZNH;0FlN-SpWb4 delta 231 zcmbPZJl9ytKRrE|fr$YMcrGr905Q|ohHRTCrN;x}GXnYR92ZaA81jb~EC5pN#kWFl zatxmYW7_6YzLiXj*_-7B)mXWl8Sb5bYub5U{@iANF=fX3V1~MHcTL5<{#16Hx^tPv z9|pb@`IjII{8F0Iw;UT)-ZZoHG0 zx-P2XWq%^`VIiJ#;pg@LZ(Fq}lv(x;ANftbBYuY)H_>G6R0j6nV_LxYnWL;moB1wg8^-ygiW JIfgHq8vubE6r2D6 diff --git a/public/dings/senders/nibu/mention.ogg b/public/dings/senders/nibu/mention.ogg index 6a065e39d171638f3e009678c6c921e66bd82a59..5f6d2dfc2d20d5310cf4b084dce302400d795bcf 100644 GIT binary patch delta 267 zcmexp@XjI!B3EvCiG zcYKL#J;S;#C3>n5(^qDO9kW2Dacur6xPXzdWb!Ow4Wp>5F`F7*>&ISQ((JwW+qApB zE1rm2wr#hXZ{{nQ-xMI0?O48g`%MX5eg*?Wpu@K*GHm`VoW{g4IrR0novVNR-JB>U GzytuTlWHdb delta 267 zcmexp@XG6R0j6i-~rI+2tkUzZQAOVo-M{kbk ziO84bWEG^Orlu!vPUov*=JefK`+eu;KmR%>zZ3Lhf5OJV;4qtcvWw7J=9>B)n?;1T zF~&zT?Ad>Oedq7~U|&OB1_z;|TUddnHP=@$97*sI_4(PmIgXL(pj=E5TXpW`6bnD*ylh delta 216 zcmbQIGhau_KRrE|fr$YM)_h#(0AgNSb;5I^lpYU=&j{pix!g2)W5^#~agYE=b&!wb z84>xioUDSB)YSBp&FOq~%$&_xzuy(7|NPrK`JJF2N6KcPx?Dbn$<9J&neW)gY!(*Y z#u(quP_ch|yzqDa<+GBa7#dW%GP!}u*#3WE=r}k-YsM$(^u0_hO}0Cp@O-Q3?1ypE!{O482%mmvp?1G|E;z0SMsH@=SMb=Keoc2sEO>^Fpows|6 z`8RhQS6*%VYQO!9_qDh6%#OW}TorS!7$md!$m7JV%=1NBSsCgy>omd{J8HkrZYy7L z@`^|}(Di>(s+bO_)UyJuIS?FO-*oTKYbkD%Af}1`{@7@*i%8w)-mY;pWw!opom$tI zuTy9HOD&8MjjElk&nRcPFG8GgiT&~X2eW%-J>p{6P+$1H7~*?|-_rh)3g@nr0J#&K zx^&moKDK0Kn8&eCYdOP{^PG1+w9YEz6o9H_V4Gi`&t?aV^d?T>>=%2qXR@4#&t3wO zjk{5+wHv78WA^87KrO5?4=$@hbUFMlXIml&VrRO3-SjbybA$N3_S%oT8D5?5w5a?I z3_?~035ZgLJzE)mv4hwP7NmqT2bjgB*M3~hu;M%CoISM*m(37kV5kSH{BO+MaAK8O z^i>OnQ|t3ULifz>&e8u9SZ!i{R~nHnH|&>T6fk-kc0Fz#9~(p5w?C)F_pK6M?EKfs z*U%Si5(5LWuKcjoBtseWtN z;`z7hm^Z?pG7g4@7iXQg7{N+3n#`}si3RLGv>cckp;lD_lN{SVsh3GCASE?gcLH~d ZU(vrJa+#sH-xiY43U2RLe!enL762&Cd7S_N delta 1643 zcmYLIdpy%?7=O16C6hF_)+Co)lG~J|Hup}EOXPkjx6|nGkwwOSKMEaPoGwaJTBjPy zrIankkcy+w$*r`x9E%QVg@|T5zg3^ldH#4m@AJIR^ZmZ>^L*d)1`HEz&)C>~01bYc z*yWc5*_JljcJG|_=9MxZh4{n^AzJr+#mX+SfGivCywiwZx7WnP0k5fRXn5b(z`#H+ z%F5F63M*S*SAWBXl|H$RI-<PFU`xK~w#<23(~N_10c=o=PYoTcZGv@?es4Hl*{m zC^t#OcI9A(NZn^v?gjBlk=q&JOnNLPLJbpB4k~TR8?hM3{sZ<;LgJGOL|G2%{ew7K9f7waf@fMI- zU`>5U*43h_8D^jzH@@7?MNe3S3ER44%4KHGq!PapOiGPckO0{?8foCC$pQenho`qA zLn06Kt3YhUR9A04JnowE5rio$ zk*b;DQv_uJ&6a5Ml<0tfKnSR292UUR>1OTSNPy5UZlvn+1~Q1rBCImj!^6_!```d< zyBv^t_oLO2Bnev#Sz)Ac^Isj7EIaEVQ0enLaT)%1(zM`J7J@dwaH5jJB+U z@su)mC2gCeY9O&4l-EE#60~A?hrAJrHHq~3>kNclX3%MjMdVi=a=G>9370Aa?_={i z_v#NC@q&U;w;E_oISDH$d8VL=jXa9L-eq=({MizPQ$La{qD3Hz?|=(%~^dpM*4$ z1(Nw}H-`gJ(guHp+`qS2bEYtaXi^M*L6qBgo|&My__k3}phxYA&Hpe?f6-OD5tW@lu@zSiTlMtaazIJB~ z%P1XyXw%Q_?kF(Rp%0q6GM0kq?r8RjCMgBW^C+b%h8s9%vFhv|R+jO<;1g6|Xu%sp z)Sx@G1tGJX5FdYUm98|i#_V@9mUibvc}}&jN7H5Nd*LoBA2^(??Av;UW8*5Oi`2Tv zSIx?>&S_nKAqNc~jy-F-f66K%T^GPpmo4gw5g`xuif^jVom3lR z@ovQIQv0C|cpOoc9QSbYfwd@5TqBOe6?5k_-6#Bs!T*02i$^%1Xjp#JMjcU1jaUOG zDVft`(r>T|4S%43Pz*Rx|84Z38X~H}8os^yoF8r2dCAekavh)F`RzxRm);>Iq5Li! F_z!jE#XA51 diff --git a/public/dings/senders/void/invite.ogg b/public/dings/senders/void/invite.ogg index 0b344456aa20b0f0bbc8807728512440a794c415..ed46cbb91d5dd46845c139731487738547de0926 100644 GIT binary patch delta 1433 zcmYjRX;4#F6h1F3hM+_R$`Zj;WfKKz1Cb?*;z&kNz&N5}SzJcNg@LGvRuT##7Eu%w z6a>^_L~KA5QIsXxvB7EyHi#Grp@5)*21;!xNnZLMFxv0ex%YhMoNvB6=l-KFk z&D=0cceuVBV?c@8s>p<4dEsiXWPa5#aeAqQ&nZxpi_@lQS8xKd%lcZji9Y_d_8v{a zE07-zO_CQETI96(n>kycedHGOBXipA?+5B4xnb6LHvo3i2h*_GGnc$95-_ahJQ#|k z3-1|Nn|pT7XEkEueIL^v;!Xj)WhrMu_mO`?+{VIt3Ba6Y zV9m22*~`4kW6K>Brjs*=DP92a8K}lwxyE}Dg0|~fw=Ttga5nkGuqnipQ7387uH^&h z29p&e3xKX#y{PHHL!(j>+m={kg-x6s2%V-t?QiegWvmIh;=HWX*FQEs)H;kUYq!+8PuTw#Hs(s2dq>Es)iaY2EgRh#`aqjUnSPs%2}G zu^WbrgofRs_-Z2~uBwHKFgsMEB0j>YR0XL280(RER06cJV$MLWEiQKaWByFaNOW{S z{R!@=b6rcBPyF3~JIrcQZ%inCky{V*CJ9bzJN$-z=TRiK&gV;5ZV1oVUJ z+3K?ZM2|kP*o_}alIq9zsPGher+(j%O{$Lfel)G>V(YiQy(AF~2Ow{iv-Xx@`P33N z`JeI{ZPy*Py1~;veq;THz*CEd#3fFL*3}0xF+5Q>iHxX0r`3E8MkX*fC+F~y3Fz@0 z{b16cDAGub8Oj7@kAUgi85_Bx95*-*#)eHv6GATxX4(2&b6oXddGaICNy48`8%HfRUYHvTS zL4+NuiYVmh(ys-=-4Wc)n2(H+T_%$SJzp&VX{e42@UWu#iZ!n#h}R4v^cq7_1kY0R z!JjIZtxhrnsK*#Mx+RL~Ablgp!bZhi+q+&bJ&{Jfs)CN39T+2b`I}<)rJ7yB9uvo~ zCInzTHjwzr@y6l(=|v{Mq%(eb0Dh$mT6b}91lNW+$%Jl2v^AWM%V>RikS9V~cOQW+ za|6T2BF>j@bdv=}Wqny>DVGP*quGh$y}l|M#I)-fJ|yaMzWVn{&Cld7`;yAs4s6oZ z<=2~wE=Lj2JNjY`;igu_Tav<~lzWJ=Rhz)YaBC<#Zgb0!E!)ol=UTmY^mfzJ6{^@1 zZHbj-3;)-z+8o#mwvE4zsx!fPaxX%fRM{}&J^dx;viQ8~RKJ@ckji$?(QIr(aMnPi X;72FyPw)^==95?Ts%7W-GF$i$2&YiB delta 2070 zcmYLKdpy&7AOCJ9LsFq!rX!NZhl7=a{Jh>L}IGVX+zj0!SB!v=&LqoZ& zSXr}qL@Ae@+?E=-L^;$-jk57<>3N>dU*FI7^Zs03pKFa_Iab>>Bm@uO;3v&$t|iN! zFBrR?2t` z(P*>*>i&U&mG=?>l7^6(133Bm0o+i=+^gkFO2A5sY`2_!g0mXm`$`(oL{X?(qaRUN*y5oQE+h@S>3#;&|K8E@?@*tslXYB!X}`F{wysYW0R?sI z&3eU-1)K~CItK9wW|b}f?vJQ=_?88+eHoX)s-Y#Qrc_oIppmP^i8{LjaLL=NFxy~Y zxkJo>g7#f#^j2slQP=!{t)eJ>av&|vu&7iWfR-ll0ql7;$&~IV?FD&|c39TGpB}=% zvntudzzR@rFJS#jF#xAswT!czl`9TeO`YV5kI=5d`aR*Bh8s&MNra6 zBd4B!LGJoyUC_c81b={RPU{LDRD8-BnYxRq1%Su#%mG(%G8YHG%sZqxpq!3mk#7Bt zH7-71P?b8L{V|q#htE>dVj)y;^{j~eQ%VdA?*SDu(vCrZJ(y!XxfrQ?Hp3Wz@xCRe zpJVs{T0*{w^LYw@|K$7xQgrP+PbCUQe#fR0AlQ9@OsATTF@dd)766pi?mUo-0RG8} zI(b387z^;yy^wiX8q+e9ebO8Db`7Z{7tiUG>8vJ98u&`HuD{gZwiI!cV5iL?U>-NH zQ6}{b*(q1^6v>a+_af(dOtl69P=;=R=Ddbvx=9Tj0t=_+u@6$(YRnZ*Xfeml$+vOaRF%IIIia+Gcz}~*RKqh zL;nEn3k#;ji$0&JfH6~=@K+0Cs6W#fa1DT+&AysD+1eOD@gAGSXU2xQ>-R*aq@`nT z*DL#Y8U;?>wP;GK?0FvgS@DR;UD{DkjhPv&as~S|v#8?3xq~Hvxt1yoyv&@$%U*f$rQpyT*c(zM1V?w0$J`n{-xzTW_F{a|?jrpH)b<{fGr zV~0Id@a5Z@Tf*)SnY7Lq+WlwN5r-`GPho zcvM_YAyw10JOSX28m{g%ZsRRi1@NF#x(W5}rrW3d3sdF@Cx;LRjouRTys0_OX~Sbm z0Gu+%o$zM-Sq-Puex_>CV6KPN4Zgzl)w;q28&9g%h3)&|ge_v06JBi}hclOE7N)nU zLuWKuTvij7svn(Cqi7Lc6JcbJ zhc6Zf2W`TmndxslYaNd~K7T36jA(;OBA<%P!O2C5HY3y@TR$WA$2Kt4h<{b0=gR9N zKnptS1|B?=FrCYCV@ietP6A4X1{=zHrVeJ`#f8B^&bP^q-$LHozyN>L#^c6F(*7Z! z{QO{9n6JaIe_ZEG*5^Zv@+{w*`SbJhM6#d@g)0c~+oL$3xo)O<*rK&16x`X>8@1%} zrl*Bk0lNNxMBS-+*tyE1O|i{03soLz_5mM8CD!s2v`dd|_Li|r?l z*M|U@Yep3gd-J6StWjJT2Crud63WQYI{!am;8qC&Agiv-h1gU(!nZVuO;6(yS3FrI zso%S=4<7&D2?Ls80PJ@Nwr}px1ya~;4+Nr=&n_iL%lE5G9j%fG3DzUuezH}zl3}LZ VV$*eh?8sGV!HY$aCi}St_z!*fiQ)hN diff --git a/public/dings/senders/void/mention.ogg b/public/dings/senders/void/mention.ogg index 80ec43e39c6f8d7d48bf87f1aabd5dd4340e4278..b6295e00cd637181fe9814c5369e34c7b224fa5b 100644 GIT binary patch delta 653 zcmbQIeo)R$u>G6R0j6i<$kEZh*L;mpEfdoLRb5Aj; ziA(uvX{xEHs3}LGkAL;VoIQJ{@tgg%^h!-Zjmk?u^Rf0Z2@h9@Tue;RdgN)IEsJT8rnjzto-phCippt8U_{ERJAAimMbvB==>qAzE zk{jz88zvN1miq%;&hYQppZ%!^|KGB$3%H*d5G)c0QPc2`lcBz$z;=DVG(!U$!@X@l zRf_*^#oDj9U$O$|F^H-O`RoiE_-u0)1KqQ4+aLR70`YIx>Ay0UVlzEz0*MQTM`DZ& zN^j5B0=Xblk{8-;t=<#3|0R;w*RV2h)St6t2l~7YXiB=H(C+&S>K2MFo5A*N_x&13 OAU0ICDL$3jr2zo&i0U{1 delta 1117 zcmcbpJWpN9KRrE|fr$YMSnf3mt);HE*;Z8*D4?vYs;H#M6V`se|9j(t8Q(-5fLb~s%+WwC zByXSF_G|U+b7%ao@7rG{YwC5o{{OAgD=QYfe;+?PaB;zWh9!0X|Jv-m=)eio3)KCb zp``MLOIV7l{(c$OElvOXPru6L*zxb}T(C4l@b9{~I-V(Y|9{T9S|2H;k>~FWk+^m; z^F`vE!WxE@x_W!Q%@-Y^=Dp{h(=WVfqUhc~o7m>m0ge0iR)=Ywz1>z8us$zSLoF*A zhB;OLf7gLE0W89uz1*kqahr{HSQ8~s@?Jtk^1 zIiBAVyl%^0)hSi&zg9dw@XKC};WI-+mto)a{xAD?yePdg`$3+`lIwGfa$nDvQTf%L zTX1smi3r2cVCzX@It-4tO=T8`D>D2#JAHSh+JTZezv{1Dwl-e2Z%^SDUv~M52dDm5 z7nFy`%{S5dx-0wQPc~=Y4F86>lDtu}T3uC>mxm`=PH9PTG z^ZDtOT+>yWx{hr0PxK6ZUOR2_E~)8FzLN|j0@jLzhcY&tvdPciuAqH&gMm}6vjFeI zWZf^fw2zn`STcKiedvM&z2sT_^Au{e)AJT`q;y<)*KjX4Jy z0~AGoA-dteKXb!`$i+9j8Rx8KX7GA)ZBoO|g3Mg0ur@s|2?@GG^STvau;J@xaa=Ye%FHdw{pyP>wgDuE(9urs5|>TQ4QH!1wHlCxiNUkT(Kh)p-*`56wx ztiQe%$p5$XkGS}g_}$m}Uu|bFb$!SRQL^KoK10GO?Ycjg85m4Jil&R6iQe6B{z^P6 zyekvp8iqIX85vT-Ozb2X80G zE+XZprKPH>qN1v%5@o8dudAV{s9+Lx{lDFrrUey`Lzx+XS`+kMe*I*1{YrpKjdWfyzf%%^kwk`92S2EYl_*=iI)9sDj!fJOW)1xK_ zK`vlmc#(fwfZ@WnKVIrTRv%^Dm;P5iilwoz#-Y~v>;yHi3WkRITO|w*77pK)*@}!~ z80yUb-d}V;{J8SMeeKRGBrmblLrhZe3uR(>Q-8^9)dU8HI`+T&Qyur;TFZQQx2(*p zSxK`XW-(M&F)$dOKel-a(3x`A|Ljja`2SXH{FQvGDcl3+fh^uFzb@2~jp-IXi=ZZHn1Zko|Hi_VVrLT7LL$yYD}@In!o6 zN>rZr|84v_bJZ8o!i)@Cg@Agd>IqeCJA32x!d=qV$)(fZWiQop+kE|DvDyi-!@GSN zldi7umD1D*b_?28tQYuA@7C)Nj;^WJ0{lJ5|2~cL*^! z|cu`e>3v81kAqC$P~vK>qefA;6|LG(7%sw=W0d%Qx30Y zm~P7^*YWpRnBD&J{bv66I|A#2eYipL3=9tYCJWjya=g^9`?y?4BYj@B7)Ufu{qOy( zhxS|9Tb#e2OAZM35eA6@&HHZJz{J2k&pz9W;kmpg@4aV#e-dJ-+xjP0^jv&3%ZhkVbQnRD9r!88pzvtn9amLGhIw26 fD04qwcQ<=M-NI!v*nYfoPl70#Fi-B@mD7v>K*lb4 delta 1603 zcmX|Adpy%?7=M3bZfojfpIjRiYJ@b(T-scsa*(4TmsyUiC?faRZ{4V*yD%kL;)n_% z$*iM}Ri8LwMcG!IlVf7!?(A~5^f}L8@Av(FpYQv8-sgEftow#D@T1WJ00R7o=_u(> z%40$>GT^d-^)e-f6u95N$G3Km1ie#vp;XgOp1XuK_O-BZGt@INH?Jd`nVIk3YHPK{ zP+#9@1Fn{CW`YUKX~T+DTy4iJfx?6OfnQfSoq`(5+bT#KvcGyxn+3n?lM+3i#f+Wz zoXcjG9Z^#!DIX)r{jzjWJ+!!VW`XXmjM18Q?L?=?Mi=$$wPdpVi?1NzA;6BQA8oo2)>$sjNfbAF zHX((?p1ftgASvZnCuT&c>^CjWL%=B(ByzoN?p^LNP9JqN8c3RKVa>jdo401B+NTl3 z)CX>Ogy);WRT}{>xA&I@H^5C$W4wglBqmQKO&qrjqR2DUjUS8TE(iD{eVdqhL%0jh z@%*ZUF{3$K?mv9ArG?KuN=qyi4(`=w4FGmeS)>X?F8ii%Ogp5s=DWDL(MidrJu1eI`pqD!{=A zqNIPVDWjF@LmRB@ctku_p^@GdE~t+hndZ{exa+3TiGF_AwWeVoI`hT{Ad0S3SY{$~ z#G+@MjF`+wW+?!$vMcB9)d2@ihtcH?A*Mo)U*Be-3?EdG=P8vjQPPTx0QgZr%*jb!zLf=k1=d|G0Q^_ z%|5?u;&ef^^w1rJI!)t7N0ST|Hk+Ouc+lL;u6C`OyI&MG_&H&kK<*FM#;BJSz%CYtVD+WK?c9I9+v3;0`+JJ0ZNZ=s&VvXP|e5I?zSK_Rd5(<+r(@(b5IK$q5W9e`AG~X-&dJ=YV7qm z@+FcQt{GS;^$&umZ5L5pi`vHa?jf9|!nAYRh3iZGT|NoDy3*fGz}zDIR8Nmh4K{Qa zPGR?R9FdL9V4YmKREGrzjh)$X8nc;BSd|$k&!9h6Ta6J`seWfo(*JhfJ`@ZxW8`$= zw42w9wu-Lr1fx2KXd`x1agM%4ycPYwnK2lyTr@AF{-IAZGK`9=NH2(^AKC{~k33)^&qonzMqk0G-(>#-Hq?=qK8_=*l~)f zSFQ_UGqtdhENy>Q(UzO;Cq($*(Iz~4hf#NrbA5~k`<(PBW_cCZs`~|TBt?erq*rTF&oIv7 z0liTobbY9St>%lN8N-C&m~#hz4AjIB1Q`4I{qbA?WDSy3i?8HkdcBGz>S16B9ZRDT MCZ6Qa=IbK=1Io+28UO$Q diff --git a/public/dings/senders/weksa/dm.ogg b/public/dings/senders/weksa/dm.ogg index 3eb3fa88c8d4444a1f853ac52e7ced92a8f87634..37b5f990d09f611b962c35b0e5984ebf1f168a91 100644 GIT binary patch delta 148 zcmdm_vsp*VKRrE|fr$YMjwnwS2QhV?ESWV?N{F?$3?&N6gC#A1k65^MpsGz8!uHBiElo%4?>tJnRVz9Z9?>(b_zp1Me7sCdi z&NB=TLHd8qW@LC^0%W{rVz4nc1=(Tn|M(;j-7x*x=7j>&Sp)3%?7aEf?b`MqoxyMG z_FtZ*w2&=dq nUtbq=mjEql>;*aX#T^D_pmfrX%u6ghKt@KbS|-Z@c@+i#JF?$3?&4_eC#A1k65^MpsGz8!uHBiElo%4?>tJnWqPMw`?>(d8`B_SetPBxA zqtYCt7)n4UEZ8r}z~INs!0F_TSI+ofyFI!fOpsk$Qs(!;hEmSXxdqFmSM1ft(1`IcFV%9guzn;(VCLD`!nD6 uW7eA;Eb;uiJa#VYFxhaC(_se>P^#nMGEZS3y%#8F#rH4!*o~x diff --git a/public/dings/senders/weksa/mention.ogg b/public/dings/senders/weksa/mention.ogg index 148a6aca49b402a19a33f1757f759a1e6c38d553..98fa0f7507bb4695057a2603e27a0fb8268b90a4 100644 GIT binary patch delta 301 zcmZ3jxkgjUKRrE|fr$YM&OCc41Y*AK`1@v}lpYU=&j{o;p6lz z^1q6nZgZ<+V|c*Fz>pwscuXRhC4qZ``ntJOH|O5omV3MH!SAMP&!n2(|K9ih_cmR| z12qw^-u`CVti~V4#Qslg?z;Sg{F9pnef9TcxjAt$Yydjt8xw~((DVh{fY#)*GBA8# zV@SyNTMM!=rfwTEh@LO8`M;n&qxl_ue#ZT3zjJx~{__2;GO?SsMpGz=p}}&;Zcc|C fAcgOR85rz{H3<`!D`+$Bwp|QDC*o+YX)8uaB delta 299 zcmZ3Zxmr`oKRrE|fr$YMo;WfkftVFHTpcG$>G6R0j6gnjd`soVkUzWvAOVnSg$L{R zNy@3^OitiSOW5_{%l7;0A4UexaqXY&Z})Yj|J{AIZcGdjK&{rM3cer{85kbvXWD;x zdMdZ~4-dluSq6p!^9+wkB(pd$emk;tbJ4ck+dw2x6Mx}WjPr-u``>HtZ&PLX;QirS zE#qc&{xBx?dt$Qs`3J=(w+Q;`+nFjWvVzU8W^CaG8XgcW%+RnM=&}lCh7IP+!$215 z+201ay}_SnlaM{5xy`n6#{I{B=RUde{=$0q$H^yWDFMxx(Y$*$1K5ZUc8m-Ul!5Mg VXRQp>*YH*vNc1pmE)zCm1OOHDWzGNq diff --git a/public/dings/senders/weksa/message.ogg b/public/dings/senders/weksa/message.ogg index 2e678ea6c91affc50dbba2b0c5ba43ab90215b08..f552996b8ee1c137cfe304f25badcdc6f2c1c34c 100644 GIT binary patch delta 202 zcmbQCIYU#*KRrE|fr$YM_L#2l1u^9+e`ZgV(&GW~8G-!E2WlHPhWz0L3xHH}m^|p2 z9K-inc{Y%rNSuM;fMCPp$Bzq^xUUCE_uQ^e2hk3x3A?y97x5c0%GOEs%~DciWrzT3 z5K@Q%N;Z@O_4NU{Nnw)@3M8n=2L`JwVPGgO*v=3Dl3u9Az#yZ-z>pv}! = { + 'aqua/mention': 'cedb63552008', + 'aqua/dm': '5b996d01c770', + 'aqua/message': '8be19ccd505a', + 'aqua/invite': '462e63687f0b', + 'eureka/mention': '7afe0bcc90d0', + 'eureka/dm': '3909d70b0d45', + 'eureka/message': '0b466cd4b437', + 'eureka/invite': '837e1bbbdacf', + 'heimdall/mention': '36051569eb2c', + 'heimdall/dm': '26ebc4158d51', + 'heimdall/message': 'c383d6ce3da0', + 'heimdall/invite': 'fd47e9a97bb2', + 'kiko/mention': 'e73d2d586f4e', + 'kiko/dm': 'b3e39946ab70', + 'kiko/message': '6730b9df5bfa', + 'kiko/invite': '1a3c1d118b9e', + 'libby/mention': 'eb9f3ac60b02', + 'libby/dm': '77dfd01cc3ca', + 'libby/message': '257b84a114a2', + 'libby/invite': '8ef7e91100b8', + 'nibu/mention': '56484d731df3', + 'nibu/dm': '573f5db4de4b', + 'nibu/message': '30f6a5c9ce54', + 'nibu/invite': 'bb36c0975f35', + 'void/mention': 'dee69f4af562', + 'void/dm': '92e3ebbc0b29', + 'void/message': 'fadc5556b681', + 'void/invite': 'd602121389d8', + 'weksa/mention': 'db46a36ab3c5', + 'weksa/dm': 'efe993382a46', + 'weksa/message': '00e323635949', + 'weksa/invite': '9bf03a36758b', +}; const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite']; const VOICES = ['aqua', 'eureka', 'heimdall', 'kiko', 'libby', 'nibu', 'void', 'weksa']; @@ -37,7 +72,7 @@ describe('dings switch', () => { }); }); -describe('v3 manifest against the parser', () => { +describe('v4 manifest against the parser', () => { it('survives parseManifest whole: no path or motif is dropped', () => { expect(manifest.neutral).toEqual(raw.neutral); expect(Object.keys(manifest.senders).sort()).toEqual(VOICES); @@ -72,17 +107,11 @@ describe('v3 manifest against the parser', () => { }); }); - it("Nibu's four files are AquaSynth's proposals/nibu3 (sha256 prefix)", () => { - const want: Record = { - mention: '3833f504903f', - dm: 'ad4839400f2c', - message: '6ec6fc893b94', - invite: 'aefb6ff79c42', - }; - MOTIFS.forEach((m) => { - const hash = createHash('sha256').update(readFileSync(join(DINGS, `senders/nibu/${m}.ogg`))); - expect(hash.digest('hex').slice(0, 12)).toBe(want[m]); - }); + it("every voice file is Aqua's v4 render (sha256 prefix)", () => { + const hash = (file: string) => + createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12); + expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length); + Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want)); }); it('keeps the clip lengths the design assumes: message under 400 ms, nothing over 600 ms', () => { From 14ea0dbab0ddd0db10bf1db086f5c92a87c0a7ec Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 18:00:41 +0000 Subject: [PATCH 03/28] scene: skyLabelInk leaves sceneShell.css.ts (vanilla-extract refuses a function export; vite build failed on dev) #74 exported a function from a .css.ts file. vitest's plugin tolerates it, vite build's compile does not ("Invalid exports"), so dev did not build. The helper and its custom-property name move to skyLabelInk.ts; a test compiles the three .css.ts files that use it the build's way and fails on the old arrangement. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 3 ++- src/app/features/room-nav/styles.css.ts | 3 ++- src/app/features/room/RoomTimeline.tsx | 2 +- src/app/features/scene/cssModules.test.ts | 18 ++++++++++++++++++ src/app/features/scene/sceneShell.css.ts | 7 ------- src/app/features/scene/skyLabelInk.ts | 7 +++++++ src/app/pages/ThemeManager.tsx | 2 +- 7 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 src/app/features/scene/cssModules.test.ts create mode 100644 src/app/features/scene/skyLabelInk.ts diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 6d1a6691..62e3928e 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,7 +1,8 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; -import { skyLabelInk, surfaceInk } from '../../features/scene/sceneShell.css'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk } from '../../features/scene/skyLabelInk'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index e5ab4f8e..88b2c333 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,6 +1,7 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; -import { skyLabelInk, surfaceInk } from '../scene/sceneShell.css'; +import { surfaceInk } from '../scene/sceneShell.css'; +import { skyLabelInk } from '../scene/skyLabelInk'; export const CategoryButton = style({ flexGrow: 1, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 552fb04c..6cce595e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -124,7 +124,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useSkyInk, useTheme } from '../../hooks/useTheme'; -import { skyLabelInk } from '../scene/sceneShell.css'; +import { skyLabelInk } from '../scene/skyLabelInk'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; diff --git a/src/app/features/scene/cssModules.test.ts b/src/app/features/scene/cssModules.test.ts new file mode 100644 index 00000000..27fd4d4d --- /dev/null +++ b/src/app/features/scene/cssModules.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; + +// vitest imports a .css.ts through the vanilla plugin, which is lenient; `vite build` compiles it with +// vanilla-extract's integration, which refuses a function export. Compile the scene's .css.ts files the build's way. +const FILES = [ + 'src/app/features/scene/sceneShell.css.ts', + 'src/app/features/room-nav/styles.css.ts', + 'src/app/components/nav/styles.css.ts', +]; + +describe('scene .css.ts files, compiled as the production build compiles them', () => { + it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => { + const filePath = `${process.cwd()}/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string'); + }); +}); diff --git a/src/app/features/scene/sceneShell.css.ts b/src/app/features/scene/sceneShell.css.ts index 3d220e81..18911a1b 100644 --- a/src/app/features/scene/sceneShell.css.ts +++ b/src/app/features/scene/sceneShell.css.ts @@ -28,10 +28,3 @@ globalStyle(':root[data-scene] #root', { const surfaceInkVar = createVar(); globalStyle(':root[data-scene] body', { vars: { [surfaceInkVar]: color.Surface.Container } }); export const surfaceInk = fallbackVar(surfaceInkVar, color.Surface.Container); - -/** - * The sky's label ink (skyInk.ts `labelInk`), set on by the theme manager (ThemeManager.tsx) only where it - * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. - */ -export const SKY_LABEL_INK = '--sky-label-ink'; -export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/features/scene/skyLabelInk.ts b/src/app/features/scene/skyLabelInk.ts new file mode 100644 index 00000000..03b9e660 --- /dev/null +++ b/src/app/features/scene/skyLabelInk.ts @@ -0,0 +1,7 @@ +/** + * The sky's label ink (skyInk.ts `labelInk`), set on by the theme manager (ThemeManager.tsx) only where it + * differs from the theme's; unset, `skyLabelInk(fallback)` is the fallback, so a person's own theme keeps its ink. + * Not in sceneShell.css.ts: vanilla-extract refuses a function exported from a .css.ts file. + */ +export const SKY_LABEL_INK = '--sky-label-ink'; +export const skyLabelInk = (fallback: string) => `var(${SKY_LABEL_INK}, ${fallback})`; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 980d35b7..092ace6c 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -11,7 +11,7 @@ import { import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { labelInk } from '../features/scene/skyInk'; -import { SKY_LABEL_INK } from '../features/scene/sceneShell.css'; +import { SKY_LABEL_INK } from '../features/scene/skyLabelInk'; export function UnAuthRouteThemeManager() { // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the From 5def1bdfb8fc25cab55260c79647e487f7190ddf Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 09:48:58 +0000 Subject: [PATCH 04/28] docs: Mind feedback pages spec (placement, close, hiding, receiver) Folds Kiko's spec with Heimdall's draft rulings on guests, erase and hiding. Design only; no code. Co-Authored-By: Claude Sonnet 5.5 --- docs/mind-feedback-pages.md | 186 ++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/mind-feedback-pages.md diff --git a/docs/mind-feedback-pages.md b/docs/mind-feedback-pages.md new file mode 100644 index 00000000..562efad4 --- /dev/null +++ b/docs/mind-feedback-pages.md @@ -0,0 +1,186 @@ +# Mind feedback pages + +Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings +on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`. +Every number below is provisional. + +Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its +recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk +(taken from the brief; no trace of it was found in this repo or in the Minds' state). + +## 1. Placement + +Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person +is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title, +so the section root stays a list of questions, not a wall of forms. + +## 2. Authority + +| Concern | Owner | The page's part | +|---|---|---| +| The question | a typed `question` document in the **owning Mind's own mind** | a view of it; the page stores and edits nothing | +| Which questions appear | the owning Mind, by a typed opt-in act carrying an `audience` | shows only what was opted in | +| An answer | its author, as an append-only record | submits one record per answer | +| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has | +| Status (open, answered, closed) | derived in the Mind from its documents, never written | shows it | +| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client | +| Close | a request the owning Mind honours | offers Close only to a grant holder; **the page writes no ruling** | +| Hiding | the owning Mind alone, as a typed act | shows the labelled stub; hides nothing itself | + +An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no +ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything. + +## 3. Receiver and carriers + +- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind` and `time`, + appends the record, delivers it to the owning Mind, and serves the projection the page reads. The client writes no record that carries + identity; a Matrix sender is a different identity authority and never becomes `answerer`. +- **Huginn is RUDP-only.** The client never talks to Huginn. The Mind's questions reach the page as a projection Bifrost serves. +- **The Huginn kinds this needs are Eureka's:** the opt-in act with `audience`, `closeGrants` on a question, the close request, the hide and + unhide acts, and the redaction record. This spec names what it needs and proposes no kinds. `huginn-feedback-kinds-proposal.md` was not in + `/opt/commons/handoff` on 2026-11-07; the kinds wait on it. + +## 4. Mock + +``` +Kiko > Feedback [synced 2 min ago] +-------------------------------------------------------------------------------------- +Open questions [ Open ] [ Settled ] + +OPEN | asked 6 Nov 2026 09:12 | waiting 1 day +Which source feeds audio_spectrum? [ Details v ] +The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more") +Waits on: "Overlay-wide effect budget" (open, links to that question) + + ( ) A Overlay's own audio only + ( ) B OBS captured stream Kiko recommends this + ( ) C Loopback of the mix, microphone excluded + ( ) Something else: [ up to 2000 characters ] + Answers are visible to everyone who can see this question. + [ Send answer ] + [ Ask Kiko to close this question ] <- only if you hold the close grant on it + +Answers (every one, oldest first) + Meta operator B OBS captured stream 6 Nov 21:04 + Aqua Mind B OBS captured stream 6 Nov 21:40 + Nibu member C Loopback of the mix 7 Nov 08:12 + Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02 + a guest guest A Overlay's own audio only 7 Nov 09:15 + (answer hidden) guest Hidden by Kiko, 7 Nov, spam + Earlier answers by the same person fold under their latest. +-------------------------------------------------------------------------------------- +SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov +``` + +- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer** + and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such. +- The recommendation is stated in words, not colour, and is never preselected. +- Answering a settled question is not offered. + +## 5. Data contract + +Typed, closed, versioned by name. Logical shapes; the typed interface and its fixtures come first (section 9). + +**Question** (`org.gamecult.feedback.question.v0`), projected read-only from the owning Mind's `question` document: + +| Field | Meaning | Source | +|---|---|---| +| `id` | `:question: