Skip to content

Commit

Permalink
Add theme editor to styling guide (#470)
Browse files Browse the repository at this point in the history
  • Loading branch information
delucis authored Aug 10, 2023
1 parent df24060 commit d076aec
Show file tree
Hide file tree
Showing 15 changed files with 781 additions and 24 deletions.
26 changes: 26 additions & 0 deletions .changeset/red-rockets-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@astrojs/starlight': minor
---

Drop support for the `--sl-hue-accent` CSS custom property.

⚠️ **BREAKING CHANGE** — In previous Starlight versions you could control the accent color by setting the `--sl-hue-accent` custom property. This could result in inaccessible color contrast and unpredictable results.

You must now set accent colors directly. If you relied on setting `--sl-hue-accent`, migrate by setting light and dark mode colors in your custom CSS:

```css
:root {
--sl-hue-accent: 234;
--sl-color-accent-low: hsl(var(--sl-hue-accent), 54%, 20%);
--sl-color-accent: hsl(var(--sl-hue-accent), 100%, 60%);
--sl-color-accent-high: hsl(var(--sl-hue-accent), 100%, 87%);
}

:root[data-theme="light"] {
--sl-color-accent-high: hsl(var(--sl-hue-accent), 80%, 30%);
--sl-color-accent: hsl(var(--sl-hue-accent), 90%, 60%);
--sl-color-accent-low: hsl(var(--sl-hue-accent), 88%, 90%);
}
```

The [new color theme editor](https://starlight.astro.build/guides/css-and-tailwind/#color-theme-editor) might help if you’d prefer to set a new color scheme.
2 changes: 2 additions & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
},
"dependencies": {
"@astrojs/starlight": "workspace:*",
"@types/culori": "^2.0.0",
"astro": "^2.10.4",
"culori": "^3.2.0",
"sharp": "^0.32.3"
},
"devDependencies": {
Expand Down
147 changes: 147 additions & 0 deletions docs/src/components/theme-designer.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
import { TabItem, Tabs } from '@astrojs/starlight/components';
import ColorEditor, { Props as EditorProps } from './theme-designer/color-editor.astro';
import Presets, { Props as PresetsProps } from './theme-designer/presets.astro';
import Preview from './theme-designer/preview.astro';
interface Props {
labels: {
presets: PresetsProps['labels'];
editor: EditorProps['labels'] & { accentColor: string; grayColor: string };
preview: Record<
'darkMode' | 'lightMode' | 'bodyText' | 'linkText' | 'dimText' | 'inlineCode',
string
>;
};
}
const {
labels: { presets, editor, preview },
} = Astro.props;
---

<Presets labels={presets} />

<div>
<theme-designer>
<div class="flex controls not-content">
<ColorEditor key="accent" legend={editor.accentColor} labels={editor} />
<ColorEditor key="gray" legend={editor.grayColor} labels={editor} />
</div>

<div class="preview" data-accent-preview>
<Preview labels={preview} data-dark />
<Preview labels={preview} data-light />
</div>

<Tabs>
<TabItem label="CSS">
<slot name="css-docs" />
<pre class="generated-code" tabindex="0"><code style="background-color: var(--astro-code-color-background);color: var(--sl-color-text)" data-theme-css /></pre>
</TabItem>
<TabItem label="Tailwind">
<slot name="tailwind-docs" />
<pre class="generated-code" tabindex="0"><code style="background-color: var(--astro-code-color-background);color: var(--sl-color-text)" data-theme-tailwind /></pre>
</TabItem>
</Tabs>
</theme-designer>
</div>

<script>
import { getPalettes } from './theme-designer/color-lib';
import { store } from './theme-designer/store';

class ThemeDesigner extends HTMLElement {
#stylesheet = new CSSStyleSheet();

constructor() {
super();
// Add our stylesheet to the document.
document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.#stylesheet];
// Update theme palettes on user input.
const onInput = () => this.#update();
store.accent.subscribe(onInput);
store.gray.subscribe(onInput);
}

#update() {
const palettes = getPalettes({ accent: store.accent.get(), gray: store.gray.get() });
this.#updatePreview(palettes);
this.#updateStylesheet(palettes);
this.#updateTailwindConfig(palettes);
}

/** Apply the generated palettes to the style attributes of the in-content preview panes. */
#updatePreview({ dark, light }: ReturnType<typeof getPalettes>) {
const previews = this.querySelectorAll<HTMLDivElement>('[data-accent-preview] > *');
previews.forEach((preview) => {
const theme = 'dark' in preview.dataset ? dark : light;
Object.entries(theme).forEach(([key, color]) => {
preview.style.setProperty(`--sl-color-${key}`, color);
});
});
}

/** Convert a color palette into a string of CSS rules. */
#paletteToRules(palette: ReturnType<typeof getPalettes>['dark' | 'light']) {
return Object.entries(palette)
.map(([key, color]) => `--sl-color-${key}: ${color};`)
.join('\n\t');
}

/** Update the CSS stylesheet applied to the current page and offered to users to copy. */
#updateStylesheet({ dark, light }: ReturnType<typeof getPalettes>) {
const styles = `/* Dark mode colors. */
:root {\n\t${this.#paletteToRules(dark)}\n}
/* Light mode colors. */
:root[data-theme='light'] {\n\t${this.#paletteToRules(light)}\n}`;
this.#stylesheet.replaceSync(styles);
const codePreview = this.querySelector('[data-theme-css]');
if (codePreview) codePreview.innerHTML = styles;
}

#updateTailwindConfig({ dark, light }: ReturnType<typeof getPalettes>) {
const config = `const starlightPlugin = require('@astrojs/starlight-tailwind');

// Generated color palettes
const accent = { 200: '${dark['accent-high']}', 600: '${light.accent}', 900: '${light['accent-high']}', 950: '${dark['accent-low']}' };
const gray = { 100: '${light['gray-7']}', 200: '${light['gray-6']}', 300: '${light['gray-5']}', 400: '${light['gray-4']}', 500: '${light['gray-3']}', 700: '${light['gray-2']}', 800: '${light['gray-1']}', 900: '${light.white}' };

/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: { accent, gray },
},
},
plugins: [starlightPlugin()],
};`;
const codePreview = this.querySelector('[data-theme-tailwind]');
if (codePreview) codePreview.innerHTML = config;
}
}

customElements.define('theme-designer', ThemeDesigner);
</script>

<style>
.controls {
flex-wrap: wrap;
gap: 1.5rem;
}
.controls > :global(*) {
flex: 1 1;
}
.preview {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}

.generated-code {
height: 16rem;
background-color: var(--astro-code-color-background);
overflow: auto scroll;
user-select: all;
}
</style>
31 changes: 31 additions & 0 deletions docs/src/components/theme-designer/atom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class Atom<T> {
#v: T;
#subscribers = new Map<(v: T) => void, (v: T) => void>();
#notify = () => this.#subscribers.forEach((cb) => cb(this.#v));
constructor(init: T) {
this.#v = init;
}
get(): T {
return this.#v;
}
set(v: T): void {
this.#v = v;
this.#notify();
}
subscribe(cb: (v: T) => void): () => boolean {
cb(this.#v);
this.#subscribers.set(cb, cb);
return () => this.#subscribers.delete(cb);
}
}

type MapStore<T> = Atom<T> & { setKey: (key: keyof T, value: T[typeof key]) => void };

export function map<T extends Record<string, unknown>>(value: T): MapStore<T> {
const atom = new Atom(value) as MapStore<T>;
atom.setKey = (key: keyof T, value: T[typeof key]) => {
const curr = atom.get();
if (curr[key] !== value) atom.set({ ...curr, [key]: value });
};
return atom;
}
93 changes: 93 additions & 0 deletions docs/src/components/theme-designer/color-editor.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
import { oklchToHex } from './color-lib';
import { store } from './store';
import ValueSlider from './value-slider.astro';
export interface Props {
key: keyof typeof store;
legend: string;
labels: Record<'pickColor' | 'hue' | 'chroma', string>;
}
const { key, legend, labels } = Astro.props;
const { hue, chroma } = store[key].get();
const initialColor = oklchToHex(52, chroma, hue);
---

<color-editor data-key={key}>
<fieldset>
<legend>{legend}</legend>
<label class="color-picker">
<span class="sr-only">{labels.pickColor}</span>
<input type="color" value={initialColor} />
</label>
<div class="sliders">
<ValueSlider label={labels.hue} storeKey={key} type="hue" />
<ValueSlider label={labels.chroma} storeKey={key} type="chroma" />
</div>
</fieldset>
</color-editor>

<script>
import { oklch, oklchToHex } from './color-lib';
import { store } from './store';

export class ColorEditor extends HTMLElement {
#store = store[this.dataset.key as keyof typeof store];
#colorInput = this.querySelector<HTMLInputElement>('input[type="color"]')!;

constructor() {
super();
// Update color on user input.
this.#store.subscribe(({ chroma, hue }) => {
this.#colorInput.value = oklchToHex(52, chroma, hue);
});
this.#colorInput.addEventListener('input', (e) => {
if (!(e.target instanceof HTMLInputElement)) return;
const old = this.#store.get();
const { c, h } = { ...oklch(e.target.value) };
this.#store.set({ hue: h ?? old.hue, chroma: c ?? old.chroma });
});
}
}
customElements.define('color-editor', ColorEditor);
</script>

<style>
fieldset {
border: 1px solid var(--sl-color-gray-5);
background-color: var(--sl-color-gray-7, var(--sl-color-gray-6));
padding: 1rem;
color: var(--sl-color-white);
}
legend {
float: left;
float: inline-start;
font-weight: 600;
}
.color-picker {
float: right;
float: inline-end;
}
.sliders {
clear: both;
}
input[type='color'] {
border: 0;
background: transparent;
height: 2em;
width: 3rem;
cursor: pointer;
--swatch-border: var(--sl-color-gray-3);
}
input[type='color']:hover {
--swatch-border: var(--sl-color-gray-1);
}
input[type='color']::-webkit-color-swatch {
border: 1px solid var(--swatch-border);
border-radius: 0.5rem;
}
input[type='color']::-moz-color-swatch {
border: 1px solid var(--swatch-border);
border-radius: 0.5rem;
}
</style>
Loading

0 comments on commit d076aec

Please sign in to comment.