cursedbelt 5.63.0 → 5.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/looks.css +237 -0
- package/dist/react/components/Button.d.ts.map +1 -1
- package/dist/react/components/Button.js +13 -3
- package/dist/react/components/Button.js.map +1 -1
- package/dist/react/components/CopyButton.d.ts.map +1 -1
- package/dist/react/components/CopyButton.js +2 -1
- package/dist/react/components/CopyButton.js.map +1 -1
- package/dist/react/components/auth/SignInCard.d.ts +9 -1
- package/dist/react/components/auth/SignInCard.d.ts.map +1 -1
- package/dist/react/components/auth/SignInCard.js +2 -2
- package/dist/react/components/auth/SignInCard.js.map +1 -1
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/look/LookPicker.d.ts +27 -0
- package/dist/react/look/LookPicker.d.ts.map +1 -0
- package/dist/react/look/LookPicker.js +29 -0
- package/dist/react/look/LookPicker.js.map +1 -0
- package/dist/react/look/lookStore.d.ts +88 -0
- package/dist/react/look/lookStore.d.ts.map +1 -0
- package/dist/react/look/lookStore.js +120 -0
- package/dist/react/look/lookStore.js.map +1 -0
- package/dist/react/look/useLook.d.ts +22 -0
- package/dist/react/look/useLook.d.ts.map +1 -0
- package/dist/react/look/useLook.js +16 -0
- package/dist/react/look/useLook.js.map +1 -0
- package/dist/styles-areas/calendar.css +2 -2
- package/dist/styles-areas/chat.css +7 -7
- package/dist/styles-areas/core.css +38 -37
- package/dist/styles-areas/emoji.css +3 -3
- package/dist/styles-areas/folder-tree.css +3 -3
- package/dist/styles-areas/layout-engine.css +6 -6
- package/dist/styles-areas/media-gallery.css +3 -3
- package/dist/styles-areas/media.css +4 -4
- package/dist/styles-areas/overlays.css +4 -4
- package/dist/styles-areas/workbench.css +7 -7
- package/dist/styles-utilities.css +16 -0
- package/package.json +2 -1
- package/scripts/styleAreas.ts +2 -0
- package/src/looks.css +237 -0
- package/src/looks.spec.ts +128 -0
- package/src/react/components/Button.tsx +14 -3
- package/src/react/components/CopyButton.tsx +2 -1
- package/src/react/components/auth/SignInCard.spec.tsx +9 -0
- package/src/react/components/auth/SignInCard.tsx +10 -1
- package/src/react/index.ts +22 -0
- package/src/react/look/LookPicker.spec.tsx +40 -0
- package/src/react/look/LookPicker.tsx +112 -0
- package/src/react/look/lookStore.spec.ts +120 -0
- package/src/react/look/lookStore.ts +172 -0
- package/src/react/look/useLook.ts +37 -0
- package/src/styles-areas/calendar.css +2 -2
- package/src/styles-areas/chat.css +7 -7
- package/src/styles-areas/core.css +38 -37
- package/src/styles-areas/emoji.css +3 -3
- package/src/styles-areas/folder-tree.css +3 -3
- package/src/styles-areas/layout-engine.css +6 -6
- package/src/styles-areas/media-gallery.css +3 -3
- package/src/styles-areas/media.css +4 -4
- package/src/styles-areas/overlays.css +4 -4
- package/src/styles-areas/workbench.css +7 -7
- package/src/styles-utilities.css +16 -0
|
@@ -30,6 +30,15 @@ describe('SignInCard', () => {
|
|
|
30
30
|
expect(warning.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
test("`chrome='look'` drops the forced aurora front door so the card wears the app's own look", () => {
|
|
34
|
+
const aurora = render(<SignInCard title='Family' onSignIn={() => {}} />);
|
|
35
|
+
expect(aurora.container.querySelector('.cb-aurora')).not.toBeNull();
|
|
36
|
+
cleanup();
|
|
37
|
+
const look = render(<SignInCard title='Family' onSignIn={() => {}} chrome='look' />);
|
|
38
|
+
expect(look.container.querySelector('.cb-aurora')).toBeNull();
|
|
39
|
+
expect(look.container.querySelector('.bg-background')).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
33
42
|
test('a disabled reason disables the button instead of hiding it', () => {
|
|
34
43
|
const onSignIn = mock(() => {});
|
|
35
44
|
const { getByRole } = render(
|
|
@@ -49,6 +49,14 @@ export interface SignInCardProps {
|
|
|
49
49
|
children?: ReactNode;
|
|
50
50
|
/** A line under the card, outside it. */
|
|
51
51
|
footer?: ReactNode;
|
|
52
|
+
/**
|
|
53
|
+
* `aurora` (default): the fleet's dark-aurora front door, whatever look the app wears — the
|
|
54
|
+
* continuous hand-off from cursedalchemy.com. `look`: the page chrome of the app's OWN look
|
|
55
|
+
* and scheme (`AuthCard` without `aurora`), for an app whose signed-out screen must read as
|
|
56
|
+
* the same product as its signed-in one — a monochrome look wants no glass and no glow on its
|
|
57
|
+
* front door either (forge task 2891).
|
|
58
|
+
*/
|
|
59
|
+
chrome?: 'aurora' | 'look';
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -79,6 +87,7 @@ export function SignInCard({
|
|
|
79
87
|
anotherAccountLabel = 'Use a different account',
|
|
80
88
|
children,
|
|
81
89
|
footer,
|
|
90
|
+
chrome = 'aurora',
|
|
82
91
|
}: SignInCardProps) {
|
|
83
92
|
// A refusal plus somewhere else to go: the other account leads, because retrying as the
|
|
84
93
|
// account that was just refused would be refused again.
|
|
@@ -96,7 +105,7 @@ export function SignInCard({
|
|
|
96
105
|
</Button>
|
|
97
106
|
) : null;
|
|
98
107
|
return (
|
|
99
|
-
<AuthCard aurora title={title} subtitle={subtitle} logo={logo} footer={footer}>
|
|
108
|
+
<AuthCard aurora={chrome === 'aurora'} title={title} subtitle={subtitle} logo={logo} footer={footer}>
|
|
100
109
|
<div className='flex flex-col gap-4'>
|
|
101
110
|
{refusal ? (
|
|
102
111
|
<Alert tone='warning' title={refusalTitle}>
|
package/src/react/index.ts
CHANGED
|
@@ -39,6 +39,28 @@ export {
|
|
|
39
39
|
} from 'cursedbelt-core/color-scheme';
|
|
40
40
|
export { ThemeToggle, type ThemeToggleProps } from './colorScheme/ThemeToggle.js';
|
|
41
41
|
export { type UseColorScheme, useColorScheme } from './colorScheme/useColorScheme.js';
|
|
42
|
+
export { LookPicker, type LookPickerProps } from './look/LookPicker.js';
|
|
43
|
+
export {
|
|
44
|
+
applyLookToDocument,
|
|
45
|
+
createLookStore,
|
|
46
|
+
DEFAULT_LOOK,
|
|
47
|
+
getLook,
|
|
48
|
+
initLook,
|
|
49
|
+
isLookId,
|
|
50
|
+
LOOK_STORAGE_KEY,
|
|
51
|
+
LOOKS,
|
|
52
|
+
type Look,
|
|
53
|
+
type LookId,
|
|
54
|
+
type LookStorage,
|
|
55
|
+
type LookStore,
|
|
56
|
+
type LookStoreOptions,
|
|
57
|
+
lookClass,
|
|
58
|
+
lookStore,
|
|
59
|
+
resetLook,
|
|
60
|
+
setLook,
|
|
61
|
+
subscribeLook,
|
|
62
|
+
} from './look/lookStore.js';
|
|
63
|
+
export { type UseLook, useLook } from './look/useLook.js';
|
|
42
64
|
export { Bubble, type BubbleProps, type BubbleVariant } from './chat/Bubble.js';
|
|
43
65
|
export { DateMarker, type DateMarkerProps } from './chat/DateMarker.js';
|
|
44
66
|
export { MessageComposer, type MessageComposerProps } from './chat/MessageComposer.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { LookPicker } from './LookPicker.js';
|
|
4
|
+
import { initLook, LOOK_STORAGE_KEY, LOOKS, lookClass, resetLook } from './lookStore.js';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
cleanup();
|
|
8
|
+
// The picker drives the app-wide singleton — reset it between tests.
|
|
9
|
+
resetLook();
|
|
10
|
+
localStorage.removeItem(LOOK_STORAGE_KEY);
|
|
11
|
+
for (const look of LOOKS) document.documentElement.classList.remove(lookClass(look.id));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('LookPicker', () => {
|
|
15
|
+
test('offers every shipped look as a radio, the fallback checked, and a pick applies + persists', () => {
|
|
16
|
+
initLook({ fallback: 'obsidian' });
|
|
17
|
+
const { getByRole, getAllByRole } = render(<LookPicker />);
|
|
18
|
+
expect(getAllByRole('radio')).toHaveLength(LOOKS.length);
|
|
19
|
+
expect(getByRole('radio', { name: /Obsidian/ }).getAttribute('aria-checked')).toBe('true');
|
|
20
|
+
|
|
21
|
+
fireEvent.click(getByRole('radio', { name: /Nord/ }));
|
|
22
|
+
expect(document.documentElement.classList.contains('cb-nord')).toBe(true);
|
|
23
|
+
expect(document.documentElement.classList.contains('cb-obsidian')).toBe(false);
|
|
24
|
+
expect(localStorage.getItem(LOOK_STORAGE_KEY)).toBe('nord');
|
|
25
|
+
expect(getByRole('radio', { name: /Nord/ }).getAttribute('aria-checked')).toBe('true');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('`looks` narrows and orders what is offered', () => {
|
|
29
|
+
const { getAllByRole } = render(<LookPicker looks={['aurora', 'obsidian']} label='Appearance' />);
|
|
30
|
+
expect(getAllByRole('radio').map((r) => r.textContent)).toEqual([
|
|
31
|
+
expect.stringContaining('Aurora'),
|
|
32
|
+
expect.stringContaining('Obsidian'),
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('each card draws its picture inside its own look scope', () => {
|
|
37
|
+
const { container } = render(<LookPicker />);
|
|
38
|
+
for (const look of LOOKS) expect(container.querySelector(`.${lookClass(look.id)}`)).not.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as RadioGroup from '@radix-ui/react-radio-group';
|
|
2
|
+
import { Check } from 'lucide-react';
|
|
3
|
+
import { type ReactNode, useId } from 'react';
|
|
4
|
+
import { cn } from '../lib/cn.js';
|
|
5
|
+
import { TAP_TARGET } from '../lib/tapTarget.js';
|
|
6
|
+
import { type LookId, LOOKS, lookClass } from './lookStore.js';
|
|
7
|
+
import { useLook } from './useLook.js';
|
|
8
|
+
|
|
9
|
+
export interface LookPickerProps {
|
|
10
|
+
/** Which looks to offer, in order. Default: every look the stylesheets ship. */
|
|
11
|
+
looks?: readonly LookId[];
|
|
12
|
+
/** The question the cards answer. Default "Look". */
|
|
13
|
+
label?: ReactNode;
|
|
14
|
+
/** Accessible name when there is no visible `label`. */
|
|
15
|
+
'aria-label'?: string;
|
|
16
|
+
/** Help under the cards. */
|
|
17
|
+
description?: ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pick the app's look: one card per look, each a small picture of itself drawn with that look's
|
|
23
|
+
* OWN tokens (the card wears the `cb-<look>` scope, so what it shows is what you get, in the
|
|
24
|
+
* colour scheme the page is in right now). Reads and writes the shared look store itself, like
|
|
25
|
+
* `ThemeToggle` does the colour scheme, so dropping it into Settings is the whole integration.
|
|
26
|
+
*
|
|
27
|
+
* Linear's Preferences → Theme and Obsidian's Appearance both draw their themes as swatch cards,
|
|
28
|
+
* which is why this is cards and not a select (REV-5). Radix `RadioGroup`: each card IS the
|
|
29
|
+
* focusable element (roving tabindex, arrow keys), nothing hidden is drawn anywhere else — the
|
|
30
|
+
* same reason `ChoiceGroup` is built on it. The checked card is marked by its accent edge AND a
|
|
31
|
+
* check mark, never by colour alone.
|
|
32
|
+
*/
|
|
33
|
+
export function LookPicker({ looks: offered, label = 'Look', 'aria-label': ariaLabel, description, className }: LookPickerProps) {
|
|
34
|
+
const { look, setLook } = useLook();
|
|
35
|
+
const id = useId();
|
|
36
|
+
const labelId = label != null ? `${id}-label` : undefined;
|
|
37
|
+
const descriptionId = description != null ? `${id}-desc` : undefined;
|
|
38
|
+
const options = offered ? offered.flatMap((wanted) => LOOKS.filter((l) => l.id === wanted)) : LOOKS;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={cn('flex min-w-0 flex-col gap-1.5', className)} data-cb-look-picker=''>
|
|
42
|
+
{label != null ? (
|
|
43
|
+
<span id={labelId} className='text-sm font-medium text-foreground'>
|
|
44
|
+
{label}
|
|
45
|
+
</span>
|
|
46
|
+
) : null}
|
|
47
|
+
<RadioGroup.Root
|
|
48
|
+
aria-labelledby={labelId}
|
|
49
|
+
aria-label={labelId ? undefined : ariaLabel}
|
|
50
|
+
aria-describedby={descriptionId}
|
|
51
|
+
value={look}
|
|
52
|
+
onValueChange={(next) => setLook(next as LookId)}
|
|
53
|
+
className='grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-4'
|
|
54
|
+
>
|
|
55
|
+
{options.map((option) => (
|
|
56
|
+
<RadioGroup.Item
|
|
57
|
+
key={option.id}
|
|
58
|
+
value={option.id}
|
|
59
|
+
className={cn(
|
|
60
|
+
'group relative flex min-w-0 flex-col gap-2 rounded-lg border border-border bg-card p-2 text-left transition-colors',
|
|
61
|
+
'hover:border-border-strong',
|
|
62
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
63
|
+
'data-[state=checked]:border-accent data-[state=checked]:ring-1 data-[state=checked]:ring-accent',
|
|
64
|
+
TAP_TARGET,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{/* The picture: this look's own canvas, chrome, a panel and the accent, under the page's
|
|
68
|
+
current scheme (`.dark .cb-<look>` matches when the page is dark). */}
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden='true'
|
|
71
|
+
className={cn(
|
|
72
|
+
'flex h-14 w-full flex-col overflow-hidden rounded-md border border-border bg-background',
|
|
73
|
+
lookClass(option.id),
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<span className='flex h-4 shrink-0 items-center gap-1 border-b border-border bg-card px-1.5'>
|
|
77
|
+
<span className='size-1.5 rounded-full bg-accent-solid' />
|
|
78
|
+
<span className='h-1 w-6 rounded-full bg-muted-foreground/50' />
|
|
79
|
+
</span>
|
|
80
|
+
<span className='flex flex-1 gap-1.5 p-1.5'>
|
|
81
|
+
<span className='flex w-1/3 flex-col gap-1 rounded-sm border border-border bg-card p-1'>
|
|
82
|
+
<span className='h-1 w-full rounded-full bg-foreground/70' />
|
|
83
|
+
<span className='h-1 w-2/3 rounded-full bg-muted-foreground/50' />
|
|
84
|
+
</span>
|
|
85
|
+
<span className='flex flex-1 flex-col gap-1 rounded-sm border border-border bg-card-raised p-1'>
|
|
86
|
+
<span className='h-1 w-1/2 rounded-full bg-foreground/70' />
|
|
87
|
+
<span className='h-1 w-full rounded-full bg-muted-foreground/40' />
|
|
88
|
+
<span className='h-1 w-3/4 rounded-full bg-muted-foreground/40' />
|
|
89
|
+
</span>
|
|
90
|
+
</span>
|
|
91
|
+
</span>
|
|
92
|
+
<span className='flex min-w-0 items-start justify-between gap-2'>
|
|
93
|
+
<span className='flex min-w-0 flex-col'>
|
|
94
|
+
<span className='text-sm font-medium text-foreground'>{option.label}</span>
|
|
95
|
+
<span className='text-xs text-muted-foreground'>{option.description}</span>
|
|
96
|
+
</span>
|
|
97
|
+
<Check
|
|
98
|
+
aria-hidden='true'
|
|
99
|
+
className='mt-0.5 size-4 shrink-0 text-accent opacity-0 transition-opacity group-data-[state=checked]:opacity-100'
|
|
100
|
+
/>
|
|
101
|
+
</span>
|
|
102
|
+
</RadioGroup.Item>
|
|
103
|
+
))}
|
|
104
|
+
</RadioGroup.Root>
|
|
105
|
+
{descriptionId ? (
|
|
106
|
+
<p id={descriptionId} className='text-xs text-muted-foreground'>
|
|
107
|
+
{description}
|
|
108
|
+
</p>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
applyLookToDocument,
|
|
4
|
+
createLookStore,
|
|
5
|
+
DEFAULT_LOOK,
|
|
6
|
+
isLookId,
|
|
7
|
+
LOOK_STORAGE_KEY,
|
|
8
|
+
type LookId,
|
|
9
|
+
type LookStorage,
|
|
10
|
+
LOOKS,
|
|
11
|
+
lookClass,
|
|
12
|
+
} from './lookStore.js';
|
|
13
|
+
|
|
14
|
+
const makeStorage = (): LookStorage & { map: Map<string, string> } => {
|
|
15
|
+
const map = new Map<string, string>();
|
|
16
|
+
return {
|
|
17
|
+
map,
|
|
18
|
+
getItem: (k) => map.get(k) ?? null,
|
|
19
|
+
setItem: (k, v) => void map.set(k, v),
|
|
20
|
+
removeItem: (k) => void map.delete(k),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const look of LOOKS) document.documentElement.classList.remove(lookClass(look.id));
|
|
26
|
+
delete document.documentElement.dataset.look;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('look store (injected side-effects)', () => {
|
|
30
|
+
test('nothing stored → the fallback, and it is not a choice', () => {
|
|
31
|
+
const store = createLookStore({ fallback: 'obsidian' });
|
|
32
|
+
expect(store.get()).toBe('obsidian');
|
|
33
|
+
expect(store.isChosen()).toBe(false);
|
|
34
|
+
expect(createLookStore().get()).toBe(DEFAULT_LOOK);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('hydrates a stored choice; garbage in storage → the fallback', () => {
|
|
38
|
+
const storage = makeStorage();
|
|
39
|
+
storage.map.set(LOOK_STORAGE_KEY, 'nord');
|
|
40
|
+
const store = createLookStore({ storage, fallback: 'obsidian' });
|
|
41
|
+
expect(store.get()).toBe('nord');
|
|
42
|
+
expect(store.isChosen()).toBe(true);
|
|
43
|
+
|
|
44
|
+
const garbage = makeStorage();
|
|
45
|
+
garbage.map.set(LOOK_STORAGE_KEY, 'blurple');
|
|
46
|
+
expect(createLookStore({ storage: garbage, fallback: 'obsidian' }).get()).toBe('obsidian');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('set persists + applies + notifies; reset clears the choice back to the fallback', () => {
|
|
50
|
+
const storage = makeStorage();
|
|
51
|
+
const applied: LookId[] = [];
|
|
52
|
+
const store = createLookStore({ storage, apply: (l) => applied.push(l), fallback: 'obsidian' });
|
|
53
|
+
let notifications = 0;
|
|
54
|
+
store.subscribe(() => notifications++);
|
|
55
|
+
|
|
56
|
+
store.set('dracula');
|
|
57
|
+
expect(storage.map.get(LOOK_STORAGE_KEY)).toBe('dracula');
|
|
58
|
+
expect(applied).toEqual(['dracula']);
|
|
59
|
+
expect(notifications).toBe(1);
|
|
60
|
+
expect(store.isChosen()).toBe(true);
|
|
61
|
+
|
|
62
|
+
store.reset();
|
|
63
|
+
expect(storage.map.has(LOOK_STORAGE_KEY)).toBe(false);
|
|
64
|
+
expect(applied).toEqual(['dracula', 'obsidian']);
|
|
65
|
+
expect(store.get()).toBe('obsidian');
|
|
66
|
+
expect(store.isChosen()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('init applies the stored look, and a later init with a new fallback re-applies it', () => {
|
|
70
|
+
const storage = makeStorage();
|
|
71
|
+
const applied: LookId[] = [];
|
|
72
|
+
const store = createLookStore({ storage, apply: (l) => applied.push(l) });
|
|
73
|
+
store.init({ fallback: 'obsidian' });
|
|
74
|
+
expect(applied).toEqual(['obsidian']);
|
|
75
|
+
store.init({ fallback: 'nord' });
|
|
76
|
+
expect(applied).toEqual(['obsidian', 'nord']);
|
|
77
|
+
expect(store.getFallback()).toBe('nord');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an outside change drops the cached choice so the next read re-hydrates', () => {
|
|
81
|
+
const storage = makeStorage();
|
|
82
|
+
let fire: () => void = () => {};
|
|
83
|
+
const store = createLookStore({
|
|
84
|
+
storage,
|
|
85
|
+
onExternalChange: (listener) => {
|
|
86
|
+
fire = listener;
|
|
87
|
+
return () => {};
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
store.init();
|
|
91
|
+
expect(store.get()).toBe(DEFAULT_LOOK);
|
|
92
|
+
storage.map.set(LOOK_STORAGE_KEY, 'nord');
|
|
93
|
+
expect(store.get()).toBe(DEFAULT_LOOK); // cached
|
|
94
|
+
fire();
|
|
95
|
+
expect(store.get()).toBe('nord');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('isLookId knows exactly the shipped looks', () => {
|
|
99
|
+
for (const look of LOOKS) expect(isLookId(look.id)).toBe(true);
|
|
100
|
+
expect(isLookId('cb-aurora')).toBe(false);
|
|
101
|
+
expect(isLookId(null)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('applyLookToDocument', () => {
|
|
106
|
+
test('puts exactly one look class on <html>, removing every other look, and keeps unrelated classes', () => {
|
|
107
|
+
const root = document.documentElement;
|
|
108
|
+
root.classList.add('dark', 'cb-aurora', 'cb-dense');
|
|
109
|
+
applyLookToDocument('obsidian');
|
|
110
|
+
expect(root.classList.contains('cb-obsidian')).toBe(true);
|
|
111
|
+
expect(root.classList.contains('cb-aurora')).toBe(false);
|
|
112
|
+
expect(root.classList.contains('dark')).toBe(true);
|
|
113
|
+
expect(root.classList.contains('cb-dense')).toBe(true);
|
|
114
|
+
expect(root.dataset.look).toBe('obsidian');
|
|
115
|
+
|
|
116
|
+
applyLookToDocument('aurora');
|
|
117
|
+
expect([...root.classList].filter((c) => LOOKS.some((l) => lookClass(l.id) === c))).toEqual(['cb-aurora']);
|
|
118
|
+
root.classList.remove('dark', 'cb-dense');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LOOK store — which theme scope the app wears.
|
|
3
|
+
*
|
|
4
|
+
* A look is one of the `cb-<id>` scopes the stylesheets define: `cb-aurora` in `theme.css`, and
|
|
5
|
+
* `cb-obsidian`, `cb-nord` and `cb-dracula` in `looks.css`. Every look re-binds the SAME semantic
|
|
6
|
+
* surface tokens (`--background`, `--card`, `--border`, the shadows, the glass, the field aliases),
|
|
7
|
+
* so a component never knows which look it is in — and light/dark stays the colour-scheme store's
|
|
8
|
+
* business: `.dark` sits beside the look class on `<html>`, and each look declares both schemes.
|
|
9
|
+
*
|
|
10
|
+
* This holds the chosen look, persists it under {@link LOOK_STORAGE_KEY}, applies it as exactly ONE
|
|
11
|
+
* `cb-<look>` class on `<html>` (every other look's class is removed first, so an app whose markup
|
|
12
|
+
* carries a static default class is corrected, never doubled) and notifies subscribers. It is the
|
|
13
|
+
* same shape as `cursedbelt-core`'s `colorSchemeStore`, for the same reasons: it works before React
|
|
14
|
+
* mounts (`initLook()` at boot, after the app's own pre-paint script), and its side effects are
|
|
15
|
+
* injected so a test drives a real store with fakes.
|
|
16
|
+
*
|
|
17
|
+
* An app that never calls it is untouched: a static `class="cb-aurora"` on `<html>` keeps working,
|
|
18
|
+
* because nothing here runs until {@link initLook} does.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type LookId = 'aurora' | 'obsidian' | 'nord' | 'dracula';
|
|
22
|
+
|
|
23
|
+
export interface Look {
|
|
24
|
+
id: LookId;
|
|
25
|
+
/** The name a picker shows. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** One line on what it is. */
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every look the stylesheets ship, in the order a picker offers them. `looks.spec.ts` holds the CSS to this list. */
|
|
32
|
+
export const LOOKS: readonly Look[] = [
|
|
33
|
+
{ id: 'obsidian', label: 'Obsidian', description: 'Monochrome and quiet. Colour only where it means something.' },
|
|
34
|
+
{ id: 'aurora', label: 'Aurora', description: 'Glass panels over a field of ambient light.' },
|
|
35
|
+
{ id: 'nord', label: 'Nord', description: 'Cool blue-grey, soft on the eyes.' },
|
|
36
|
+
{ id: 'dracula', label: 'Dracula', description: 'Deep violet-grey with warm text.' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export const LOOK_STORAGE_KEY = 'cwip.look';
|
|
40
|
+
|
|
41
|
+
/** The look when nothing is stored and the app named no fallback: what every app wore before looks existed. */
|
|
42
|
+
export const DEFAULT_LOOK: LookId = 'aurora';
|
|
43
|
+
|
|
44
|
+
export const isLookId = (value: unknown): value is LookId => LOOKS.some((look) => look.id === value);
|
|
45
|
+
|
|
46
|
+
/** The class a look puts on `<html>`: the scope `theme.css` / `looks.css` style. */
|
|
47
|
+
export const lookClass = (id: LookId): string => `cb-${id}`;
|
|
48
|
+
|
|
49
|
+
/** The slice of the Web Storage API the store needs (so a test can fake it). */
|
|
50
|
+
export interface LookStorage {
|
|
51
|
+
getItem(key: string): string | null;
|
|
52
|
+
setItem(key: string, value: string): void;
|
|
53
|
+
removeItem(key: string): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface LookStoreOptions {
|
|
57
|
+
storage?: LookStorage | null;
|
|
58
|
+
/** Apply a look to the page. Default: {@link applyLookToDocument}. */
|
|
59
|
+
apply?: (look: LookId) => void;
|
|
60
|
+
/** Subscribe to the root element's class being changed by something OTHER than this store
|
|
61
|
+
* (devtools, a visual-test harness driving the DOM directly). Default: a `MutationObserver`. */
|
|
62
|
+
onExternalChange?: (listener: () => void) => () => void;
|
|
63
|
+
/** localStorage key. */
|
|
64
|
+
key?: string;
|
|
65
|
+
/** The look when nothing is stored. `init({ fallback })` overrides it. */
|
|
66
|
+
fallback?: LookId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface LookStore {
|
|
70
|
+
/** The look the page wears: the stored choice, else the fallback. */
|
|
71
|
+
get(): LookId;
|
|
72
|
+
/** The fallback the app named (what `get()` answers when nothing is stored). */
|
|
73
|
+
getFallback(): LookId;
|
|
74
|
+
/** Whether the look is the reader's own stored choice rather than the app's fallback. */
|
|
75
|
+
isChosen(): boolean;
|
|
76
|
+
/** Choose, persist, apply, notify. */
|
|
77
|
+
set(look: LookId): void;
|
|
78
|
+
/** Forget the choice: back to the app's fallback. */
|
|
79
|
+
reset(): void;
|
|
80
|
+
/** Apply the stored (or fallback) look to the page and start watching for outside changes.
|
|
81
|
+
* Call once on app boot; calling again with a new `fallback` re-applies. */
|
|
82
|
+
init(options?: { fallback?: LookId }): void;
|
|
83
|
+
subscribe(listener: () => void): () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createLookStore(options: LookStoreOptions = {}): LookStore {
|
|
87
|
+
const { storage = null, apply, onExternalChange, key = LOOK_STORAGE_KEY } = options;
|
|
88
|
+
let fallback: LookId = options.fallback ?? DEFAULT_LOOK;
|
|
89
|
+
const listeners = new Set<() => void>();
|
|
90
|
+
let chosen: LookId | null | undefined; // undefined until first hydrate; null = nothing stored
|
|
91
|
+
let watchingExternal = false;
|
|
92
|
+
|
|
93
|
+
const hydrate = (): LookId | null => {
|
|
94
|
+
if (chosen !== undefined) return chosen;
|
|
95
|
+
const raw = storage?.getItem(key);
|
|
96
|
+
chosen = isLookId(raw) ? raw : null;
|
|
97
|
+
return chosen;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const resolve = (): LookId => hydrate() ?? fallback;
|
|
101
|
+
|
|
102
|
+
const notify = (): void => {
|
|
103
|
+
for (const listener of listeners) listener();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const commit = (next: LookId | null): void => {
|
|
107
|
+
chosen = next;
|
|
108
|
+
if (next === null) storage?.removeItem(key);
|
|
109
|
+
else storage?.setItem(key, next);
|
|
110
|
+
apply?.(resolve());
|
|
111
|
+
notify();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
get: resolve,
|
|
116
|
+
getFallback: () => fallback,
|
|
117
|
+
isChosen: () => hydrate() !== null,
|
|
118
|
+
set: (look) => commit(look),
|
|
119
|
+
reset: () => commit(null),
|
|
120
|
+
init(init = {}) {
|
|
121
|
+
if (init.fallback) fallback = init.fallback;
|
|
122
|
+
apply?.(resolve());
|
|
123
|
+
notify();
|
|
124
|
+
if (!watchingExternal && onExternalChange) {
|
|
125
|
+
watchingExternal = true;
|
|
126
|
+
onExternalChange(() => {
|
|
127
|
+
// Something outside this store touched the root's class or the storage: drop the cached
|
|
128
|
+
// choice so the next read re-hydrates, and tell every subscriber to re-check.
|
|
129
|
+
chosen = undefined;
|
|
130
|
+
notify();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
subscribe(listener) {
|
|
135
|
+
listeners.add(listener);
|
|
136
|
+
return () => listeners.delete(listener);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Apply a look to the document root: exactly one `cb-<look>` class, every other look's class
|
|
143
|
+
* removed, and `data-look` beside it for a stylesheet or a test that wants to ask. SSR-safe.
|
|
144
|
+
*/
|
|
145
|
+
export function applyLookToDocument(look: LookId): void {
|
|
146
|
+
if (typeof document === 'undefined') return;
|
|
147
|
+
const root = document.documentElement;
|
|
148
|
+
for (const other of LOOKS) if (other.id !== look) root.classList.remove(lookClass(other.id));
|
|
149
|
+
root.classList.add(lookClass(look));
|
|
150
|
+
root.dataset.look = look;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const defaultStorage: LookStorage | null = typeof localStorage !== 'undefined' ? localStorage : null;
|
|
154
|
+
|
|
155
|
+
/** The app-wide singleton bound to localStorage and the page. */
|
|
156
|
+
export const lookStore = createLookStore({
|
|
157
|
+
storage: defaultStorage,
|
|
158
|
+
apply: applyLookToDocument,
|
|
159
|
+
onExternalChange: (listener) => {
|
|
160
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => {};
|
|
161
|
+
const observer = new MutationObserver(listener);
|
|
162
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
163
|
+
return () => observer.disconnect();
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export const getLook = (): LookId => lookStore.get();
|
|
168
|
+
export const setLook = (look: LookId): void => lookStore.set(look);
|
|
169
|
+
export const resetLook = (): void => lookStore.reset();
|
|
170
|
+
export const subscribeLook = (listener: () => void): (() => void) => lookStore.subscribe(listener);
|
|
171
|
+
/** Apply the saved look (or the app's `fallback`) on boot, before React mounts, beside `initColorScheme()`. */
|
|
172
|
+
export const initLook = (options?: { fallback?: LookId }): void => lookStore.init(options);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LOOK,
|
|
4
|
+
type Look,
|
|
5
|
+
type LookId,
|
|
6
|
+
LOOKS,
|
|
7
|
+
lookStore,
|
|
8
|
+
resetLook,
|
|
9
|
+
setLook,
|
|
10
|
+
subscribeLook,
|
|
11
|
+
} from './lookStore.js';
|
|
12
|
+
|
|
13
|
+
export interface UseLook {
|
|
14
|
+
/** The look the page wears right now. */
|
|
15
|
+
look: LookId;
|
|
16
|
+
/** Whether that is the reader's own stored choice, rather than the app's fallback. */
|
|
17
|
+
chosen: boolean;
|
|
18
|
+
/** Every look the stylesheets ship, in picker order. */
|
|
19
|
+
looks: readonly Look[];
|
|
20
|
+
setLook: (look: LookId) => void;
|
|
21
|
+
/** Forget the choice: back to the app's fallback. */
|
|
22
|
+
resetLook: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read + control the app-wide look. Backed by the shared {@link lookStore} (persisted to
|
|
27
|
+
* localStorage, applied as the `cb-<look>` class on `<html>`), so every consumer — a
|
|
28
|
+
* {@link LookPicker} in Settings, a row in an account menu — stays in sync. SSR-safe: renders the
|
|
29
|
+
* default look on the server, hydrates the saved one on the client.
|
|
30
|
+
*
|
|
31
|
+
* Pair with `initLook({ fallback })` at app start, beside `initColorScheme()`.
|
|
32
|
+
*/
|
|
33
|
+
export function useLook(): UseLook {
|
|
34
|
+
const look = useSyncExternalStore(subscribeLook, () => lookStore.get(), () => DEFAULT_LOOK);
|
|
35
|
+
const chosen = useSyncExternalStore(subscribeLook, () => lookStore.isChosen(), () => false);
|
|
36
|
+
return { look, chosen, looks: LOOKS, setLook, resetLook };
|
|
37
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 77 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
@source inline("dark:text-rose-200 dark:text-sky-200 dark:text-slate-200 dark:text-teal-200 dark:text-violet-200");
|
|
35
35
|
@source inline("focus:opacity-100 grid-cols-[repeat(7,minmax(0,1fr))] group-focus-within:opacity-100 h-3.5");
|
|
36
36
|
@source inline("hover:bg-muted/30 hover:bg-muted/40 hover:border-border/80 last:border-r-0 line-through min-h-28");
|
|
37
|
-
@source inline("min-h-40 opacity-85 outline-1 outline-dashed outline-tone-amber-ink
|
|
37
|
+
@source inline("min-h-40 opacity-85 outline-1 outline-dashed outline-tone-amber-ink sm:inline sm:min-h-0");
|
|
38
38
|
@source inline("sm:min-h-24 sm:px-1.5 sm:text-xs text-accent-solid-foreground text-amber-900 text-blue-800");
|
|
39
39
|
@source inline("text-emerald-800 text-green-800 text-purple-800 text-rose-800 text-sky-800 text-slate-800");
|
|
40
40
|
@source inline("text-teal-800 text-tone-amber-ink text-violet-800 w-20 w-3.5");
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 67 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
@source inline("bg-background/30 bg-black/10 bg-gradient-to-t border-border/40 bottom-3 col collapse");
|
|
29
29
|
@source inline("dark:bg-white/10 first:mt-0 flex-row flex-row-reverse flex-shrink focus-visible:outline-2");
|
|
30
30
|
@source inline("focus-visible:outline-offset-2 focus-visible:outline-ring from-surface h-105 h-3.5");
|
|
31
|
-
@source inline("hover:bg-background/50 hover:border-
|
|
32
|
-
@source inline("
|
|
33
|
-
@source inline("
|
|
34
|
-
@source inline("rounded-
|
|
35
|
-
@source inline("text-[length:var(--text-code-
|
|
36
|
-
@source inline("
|
|
31
|
+
@source inline("hover:bg-background/50 hover:border-field-ring hover:opacity-80 italic list-decimal list-disc");
|
|
32
|
+
@source inline("max-w-4/5 min-h-20 min-w-16 ml-0.5 ml-1.5 mr-9 outline-2 outline-accent outline-dashed pl-5");
|
|
33
|
+
@source inline("pointer-coarse:mr-12 pt-0.5 px-0.5 px-3.5 resize-none right-1 rounded-2xl rounded-bl-md");
|
|
34
|
+
@source inline("rounded-bl-sm rounded-br-md rounded-br-sm size-7 text-[length:var(--text-code-block)]");
|
|
35
|
+
@source inline("text-[length:var(--text-code-inline)] text-xl to-transparent top-1 underline-offset-2 w-1.5 w-3");
|
|
36
|
+
@source inline("w-3.5 w-40 w-48 w-56 w-88 z-[9]");
|