@carlesandres/house 0.3.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/CHANGELOG.md +179 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/package.json +67 -0
- package/src/Browser.tsx +472 -0
- package/src/Footer.tsx +151 -0
- package/src/HelpOverlay.tsx +130 -0
- package/src/cli/argv.ts +106 -0
- package/src/discovery/filter.ts +56 -0
- package/src/discovery/walk.ts +143 -0
- package/src/index.tsx +265 -0
- package/src/io/readFile.ts +14 -0
- package/src/keymap/browser.ts +229 -0
- package/src/keymap/keymap.ts +86 -0
- package/src/serve/css.ts +120 -0
- package/src/serve/openBrowser.ts +19 -0
- package/src/serve/render.ts +56 -0
- package/src/serve/server.ts +163 -0
- package/src/theme/atom.ts +23 -0
- package/src/theme/colors.ts +79 -0
- package/src/theme/loader.ts +110 -0
- package/src/theme/registry.ts +12 -0
- package/src/theme/resolve.ts +168 -0
- package/src/theme/themes/aura.json +58 -0
- package/src/theme/themes/ayu.json +69 -0
- package/src/theme/themes/carbonfox.json +201 -0
- package/src/theme/themes/catppuccin-frappe.json +186 -0
- package/src/theme/themes/catppuccin-macchiato.json +186 -0
- package/src/theme/themes/catppuccin.json +212 -0
- package/src/theme/themes/cobalt2.json +181 -0
- package/src/theme/themes/cursor.json +202 -0
- package/src/theme/themes/dracula.json +172 -0
- package/src/theme/themes/everforest.json +194 -0
- package/src/theme/themes/flexoki.json +190 -0
- package/src/theme/themes/github.json +186 -0
- package/src/theme/themes/gruvbox.json +195 -0
- package/src/theme/themes/kanagawa.json +180 -0
- package/src/theme/themes/lucent-orng.json +186 -0
- package/src/theme/themes/material.json +188 -0
- package/src/theme/themes/matrix.json +180 -0
- package/src/theme/themes/mercury.json +198 -0
- package/src/theme/themes/monokai.json +174 -0
- package/src/theme/themes/nightowl.json +174 -0
- package/src/theme/themes/nord.json +176 -0
- package/src/theme/themes/one-dark.json +184 -0
- package/src/theme/themes/opencode.json +198 -0
- package/src/theme/themes/orng.json +202 -0
- package/src/theme/themes/osaka-jade.json +193 -0
- package/src/theme/themes/palenight.json +175 -0
- package/src/theme/themes/rosepine.json +187 -0
- package/src/theme/themes/solarized.json +176 -0
- package/src/theme/themes/synthwave84.json +179 -0
- package/src/theme/themes/tokyonight.json +196 -0
- package/src/theme/themes/vercel.json +198 -0
- package/src/theme/themes/vesper.json +171 -0
- package/src/theme/themes/zenburn.json +176 -0
- package/src/theme/types.ts +109 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* themeAtom — signals the active theme identity to React.
|
|
3
|
+
*
|
|
4
|
+
* Components read this atom only to subscribe to re-renders; they pull the
|
|
5
|
+
* actual token values from the `colors` singleton (mutated by
|
|
6
|
+
* `setActiveTheme` before the re-render fires).
|
|
7
|
+
*
|
|
8
|
+
* Shape: `{ id, tone }` so that a future theme picker can write both fields
|
|
9
|
+
* atomically and the component sees a single re-render.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
13
|
+
import type { Tone } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
export interface ThemeState {
|
|
16
|
+
readonly id: string
|
|
17
|
+
readonly tone: Tone
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const themeAtom: Atom.Writable<ThemeState, ThemeState> = Atom.make<ThemeState>({
|
|
21
|
+
id: "opencode",
|
|
22
|
+
tone: "dark",
|
|
23
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { StyleDefinitionInput } from "@opentui/core"
|
|
2
|
+
import { resolveTheme } from "./resolve.ts"
|
|
3
|
+
import type { ColorPalette, ResolvedTheme, ThemeDefinition, Tone } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adapt a resolved theme (opencode-shaped flat tokens) to the
|
|
7
|
+
* {@link ColorPalette} shape consumed by Browser / HelpOverlay / index.
|
|
8
|
+
*
|
|
9
|
+
* - UI tokens map name-for-name where they overlap.
|
|
10
|
+
* - `surface` ← `backgroundPanel`, `selectedBg` ← `backgroundElement`,
|
|
11
|
+
* `selectedBgInactive` ← `borderSubtle`.
|
|
12
|
+
* - `textStrong` borrows `markdownStrong` (the brightest/most-emphasized
|
|
13
|
+
* text token in opencode's palette).
|
|
14
|
+
* - `syntax` is a fully populated opentui tree-sitter scope map built from
|
|
15
|
+
* `markdown*` and `syntax*` tokens.
|
|
16
|
+
*/
|
|
17
|
+
const buildPalette = (r: ResolvedTheme): ColorPalette => ({
|
|
18
|
+
background: r.background,
|
|
19
|
+
surface: r.backgroundPanel,
|
|
20
|
+
text: r.text,
|
|
21
|
+
textStrong: r.markdownStrong,
|
|
22
|
+
textMuted: r.textMuted,
|
|
23
|
+
border: r.border,
|
|
24
|
+
borderActive: r.borderActive,
|
|
25
|
+
selectedBg: r.backgroundElement,
|
|
26
|
+
selectedBgInactive: r.borderSubtle,
|
|
27
|
+
error: r.error,
|
|
28
|
+
syntax: buildSyntaxMap(r),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const buildSyntaxMap = (r: ResolvedTheme): Record<string, StyleDefinitionInput> => {
|
|
32
|
+
const codeBg = r.backgroundPanel
|
|
33
|
+
return {
|
|
34
|
+
keyword: { fg: r.syntaxKeyword, bold: true },
|
|
35
|
+
string: { fg: r.syntaxString },
|
|
36
|
+
comment: { fg: r.syntaxComment, italic: true },
|
|
37
|
+
number: { fg: r.syntaxNumber },
|
|
38
|
+
function: { fg: r.syntaxFunction },
|
|
39
|
+
type: { fg: r.syntaxType },
|
|
40
|
+
operator: { fg: r.syntaxOperator },
|
|
41
|
+
variable: { fg: r.syntaxVariable },
|
|
42
|
+
property: { fg: r.syntaxFunction },
|
|
43
|
+
"punctuation.bracket": { fg: r.syntaxPunctuation },
|
|
44
|
+
"punctuation.delimiter": { fg: r.syntaxPunctuation },
|
|
45
|
+
"punctuation.special": { fg: r.syntaxPunctuation },
|
|
46
|
+
"markup.heading": { fg: r.markdownHeading, bold: true },
|
|
47
|
+
"markup.heading.1": { fg: r.markdownHeading, bold: true, underline: true },
|
|
48
|
+
"markup.heading.2": { fg: r.markdownHeading, bold: true },
|
|
49
|
+
"markup.heading.3": { fg: r.markdownHeading },
|
|
50
|
+
"markup.bold": { fg: r.markdownStrong, bold: true },
|
|
51
|
+
"markup.strong": { fg: r.markdownStrong, bold: true },
|
|
52
|
+
"markup.italic": { fg: r.markdownEmph, italic: true },
|
|
53
|
+
"markup.list": { fg: r.markdownListItem },
|
|
54
|
+
"markup.quote": { fg: r.markdownBlockQuote, italic: true },
|
|
55
|
+
"markup.raw": { fg: r.markdownCode, bg: codeBg },
|
|
56
|
+
"markup.raw.block": { fg: r.markdownCodeBlock, bg: codeBg },
|
|
57
|
+
"markup.raw.inline": { fg: r.markdownCode, bg: codeBg },
|
|
58
|
+
"markup.link": { fg: r.markdownLink, underline: true },
|
|
59
|
+
"markup.link.label": { fg: r.markdownLinkText, underline: true },
|
|
60
|
+
"markup.link.url": { fg: r.markdownLink, underline: true },
|
|
61
|
+
label: { fg: r.markdownListItem },
|
|
62
|
+
conceal: { fg: r.borderSubtle },
|
|
63
|
+
default: { fg: r.markdownText },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Active flat palette. Components import `colors` and read tokens
|
|
69
|
+
* directly. `setActiveTheme` mutates this in place so the reference stays
|
|
70
|
+
* stable (no Provider/context).
|
|
71
|
+
*
|
|
72
|
+
* Initial value uses the all-fallback resolution; `setActiveTheme` is
|
|
73
|
+
* called at boot from `index.tsx` before the React tree mounts.
|
|
74
|
+
*/
|
|
75
|
+
export const colors: ColorPalette = buildPalette(resolveTheme({ theme: {} }, "dark"))
|
|
76
|
+
|
|
77
|
+
export const setActiveTheme = (definition: ThemeDefinition, tone: Tone): void => {
|
|
78
|
+
Object.assign(colors, buildPalette(resolveTheme(definition.source, tone)))
|
|
79
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// THIS FILE IS AUTO-GENERATED by dev/build-themes.ts — do not edit by hand.
|
|
2
|
+
// Run `bun run build:themes` to regenerate after adding or updating themes.
|
|
3
|
+
import auraJson from "./themes/aura.json" with { type: "json" }
|
|
4
|
+
import ayuJson from "./themes/ayu.json" with { type: "json" }
|
|
5
|
+
import carbonfoxJson from "./themes/carbonfox.json" with { type: "json" }
|
|
6
|
+
import catppuccinJson from "./themes/catppuccin.json" with { type: "json" }
|
|
7
|
+
import catppuccinFrappeJson from "./themes/catppuccin-frappe.json" with { type: "json" }
|
|
8
|
+
import catppuccinMacchiatoJson from "./themes/catppuccin-macchiato.json" with { type: "json" }
|
|
9
|
+
import cobalt2Json from "./themes/cobalt2.json" with { type: "json" }
|
|
10
|
+
import cursorJson from "./themes/cursor.json" with { type: "json" }
|
|
11
|
+
import draculaJson from "./themes/dracula.json" with { type: "json" }
|
|
12
|
+
import everforestJson from "./themes/everforest.json" with { type: "json" }
|
|
13
|
+
import flexokiJson from "./themes/flexoki.json" with { type: "json" }
|
|
14
|
+
import githubJson from "./themes/github.json" with { type: "json" }
|
|
15
|
+
import gruvboxJson from "./themes/gruvbox.json" with { type: "json" }
|
|
16
|
+
import kanagawaJson from "./themes/kanagawa.json" with { type: "json" }
|
|
17
|
+
import lucentOrngJson from "./themes/lucent-orng.json" with { type: "json" }
|
|
18
|
+
import materialJson from "./themes/material.json" with { type: "json" }
|
|
19
|
+
import matrixJson from "./themes/matrix.json" with { type: "json" }
|
|
20
|
+
import mercuryJson from "./themes/mercury.json" with { type: "json" }
|
|
21
|
+
import monokaiJson from "./themes/monokai.json" with { type: "json" }
|
|
22
|
+
import nightowlJson from "./themes/nightowl.json" with { type: "json" }
|
|
23
|
+
import nordJson from "./themes/nord.json" with { type: "json" }
|
|
24
|
+
import oneDarkJson from "./themes/one-dark.json" with { type: "json" }
|
|
25
|
+
import opencodeJson from "./themes/opencode.json" with { type: "json" }
|
|
26
|
+
import orngJson from "./themes/orng.json" with { type: "json" }
|
|
27
|
+
import osakaJadeJson from "./themes/osaka-jade.json" with { type: "json" }
|
|
28
|
+
import palenightJson from "./themes/palenight.json" with { type: "json" }
|
|
29
|
+
import rosepineJson from "./themes/rosepine.json" with { type: "json" }
|
|
30
|
+
import solarizedJson from "./themes/solarized.json" with { type: "json" }
|
|
31
|
+
import synthwave84Json from "./themes/synthwave84.json" with { type: "json" }
|
|
32
|
+
import tokyonightJson from "./themes/tokyonight.json" with { type: "json" }
|
|
33
|
+
import vercelJson from "./themes/vercel.json" with { type: "json" }
|
|
34
|
+
import vesperJson from "./themes/vesper.json" with { type: "json" }
|
|
35
|
+
import zenburnJson from "./themes/zenburn.json" with { type: "json" }
|
|
36
|
+
import { isThemeJson } from "./resolve.ts"
|
|
37
|
+
import type { ThemeDefinition, ThemeJson } from "./types.ts"
|
|
38
|
+
|
|
39
|
+
interface BundledEntry {
|
|
40
|
+
readonly id: string
|
|
41
|
+
readonly json: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bundled: readonly BundledEntry[] = [
|
|
45
|
+
{ id: "aura", json: auraJson },
|
|
46
|
+
{ id: "ayu", json: ayuJson },
|
|
47
|
+
{ id: "carbonfox", json: carbonfoxJson },
|
|
48
|
+
{ id: "catppuccin", json: catppuccinJson },
|
|
49
|
+
{ id: "catppuccin-frappe", json: catppuccinFrappeJson },
|
|
50
|
+
{ id: "catppuccin-macchiato", json: catppuccinMacchiatoJson },
|
|
51
|
+
{ id: "cobalt2", json: cobalt2Json },
|
|
52
|
+
{ id: "cursor", json: cursorJson },
|
|
53
|
+
{ id: "dracula", json: draculaJson },
|
|
54
|
+
{ id: "everforest", json: everforestJson },
|
|
55
|
+
{ id: "flexoki", json: flexokiJson },
|
|
56
|
+
{ id: "github", json: githubJson },
|
|
57
|
+
{ id: "gruvbox", json: gruvboxJson },
|
|
58
|
+
{ id: "kanagawa", json: kanagawaJson },
|
|
59
|
+
{ id: "lucent-orng", json: lucentOrngJson },
|
|
60
|
+
{ id: "material", json: materialJson },
|
|
61
|
+
{ id: "matrix", json: matrixJson },
|
|
62
|
+
{ id: "mercury", json: mercuryJson },
|
|
63
|
+
{ id: "monokai", json: monokaiJson },
|
|
64
|
+
{ id: "nightowl", json: nightowlJson },
|
|
65
|
+
{ id: "nord", json: nordJson },
|
|
66
|
+
{ id: "one-dark", json: oneDarkJson },
|
|
67
|
+
{ id: "opencode", json: opencodeJson },
|
|
68
|
+
{ id: "orng", json: orngJson },
|
|
69
|
+
{ id: "osaka-jade", json: osakaJadeJson },
|
|
70
|
+
{ id: "palenight", json: palenightJson },
|
|
71
|
+
{ id: "rosepine", json: rosepineJson },
|
|
72
|
+
{ id: "solarized", json: solarizedJson },
|
|
73
|
+
{ id: "synthwave84", json: synthwave84Json },
|
|
74
|
+
{ id: "tokyonight", json: tokyonightJson },
|
|
75
|
+
{ id: "vercel", json: vercelJson },
|
|
76
|
+
{ id: "vesper", json: vesperJson },
|
|
77
|
+
{ id: "zenburn", json: zenburnJson },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
const toDefinition = (id: string, raw: unknown): ThemeDefinition | null => {
|
|
81
|
+
if (!isThemeJson(raw)) return null
|
|
82
|
+
const json = raw as ThemeJson
|
|
83
|
+
return { id, name: json.name ?? id, source: json }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Load all bundled themes, indexed by id. Invalid entries are dropped. */
|
|
87
|
+
export const loadBundledThemes = (): Map<string, ThemeDefinition> => {
|
|
88
|
+
const map = new Map<string, ThemeDefinition>()
|
|
89
|
+
for (const { id, json } of bundled) {
|
|
90
|
+
const def = toDefinition(id, json)
|
|
91
|
+
if (def) map.set(id, def)
|
|
92
|
+
}
|
|
93
|
+
return map
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stub for user-supplied themes. A future release will load these from the
|
|
98
|
+
* XDG config dir and from project-local `.house/themes/`. Until then,
|
|
99
|
+
* returns an empty map.
|
|
100
|
+
*/
|
|
101
|
+
export const loadUserThemes = async (): Promise<Map<string, ThemeDefinition>> => {
|
|
102
|
+
return new Map()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Bundled + user themes merged. User themes override built-ins by id. */
|
|
106
|
+
export const loadAllThemes = async (): Promise<Map<string, ThemeDefinition>> => {
|
|
107
|
+
const all = loadBundledThemes()
|
|
108
|
+
for (const [id, def] of await loadUserThemes()) all.set(id, def)
|
|
109
|
+
return all
|
|
110
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { loadBundledThemes } from "./loader.ts"
|
|
2
|
+
import type { ThemeDefinition } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
const map: Map<string, ThemeDefinition> = loadBundledThemes()
|
|
5
|
+
|
|
6
|
+
/** All known themes. Order is the bundled-themes order in `loader.ts`. */
|
|
7
|
+
export const themeDefinitions: readonly ThemeDefinition[] = [...map.values()]
|
|
8
|
+
|
|
9
|
+
export const getThemeDefinition = (id: string): ThemeDefinition | undefined => map.get(id)
|
|
10
|
+
|
|
11
|
+
export const isThemeId = (value: unknown): value is string =>
|
|
12
|
+
typeof value === "string" && map.has(value)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColorValue,
|
|
3
|
+
HexColor,
|
|
4
|
+
ResolvedTheme,
|
|
5
|
+
ThemeJson,
|
|
6
|
+
ThemeTokens,
|
|
7
|
+
TokenName,
|
|
8
|
+
Tone,
|
|
9
|
+
} from "./types.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hard-coded safe defaults for every UI / markdown / syntax token. Used when
|
|
13
|
+
* a theme JSON omits a token entirely (validation is permissive — opencode
|
|
14
|
+
* does the same). Fallback values are reasonable greys so the app stays
|
|
15
|
+
* legible even with a near-empty theme.
|
|
16
|
+
*/
|
|
17
|
+
const HARD_FALLBACK_RAW: Readonly<Record<TokenName, HexColor>> = {
|
|
18
|
+
primary: "#7AA2F7",
|
|
19
|
+
secondary: "#9ECE6A",
|
|
20
|
+
accent: "#BB9AF7",
|
|
21
|
+
error: "#F7768E",
|
|
22
|
+
warning: "#E0AF68",
|
|
23
|
+
success: "#9ECE6A",
|
|
24
|
+
info: "#7DCFFF",
|
|
25
|
+
text: "#D8DEE9",
|
|
26
|
+
textMuted: "#7B8794",
|
|
27
|
+
selectedListItemText: "#FFFFFF",
|
|
28
|
+
background: "#1A1B26",
|
|
29
|
+
backgroundPanel: "#24283B",
|
|
30
|
+
backgroundElement: "#414868",
|
|
31
|
+
border: "#3B4261",
|
|
32
|
+
borderActive: "#7AA2F7",
|
|
33
|
+
borderSubtle: "#292E42",
|
|
34
|
+
markdownText: "#D8DEE9",
|
|
35
|
+
markdownHeading: "#7AA2F7",
|
|
36
|
+
markdownLink: "#7DCFFF",
|
|
37
|
+
markdownLinkText: "#7AA2F7",
|
|
38
|
+
markdownCode: "#9ECE6A",
|
|
39
|
+
markdownBlockQuote: "#7B8794",
|
|
40
|
+
markdownEmph: "#E0AF68",
|
|
41
|
+
markdownStrong: "#FFFFFF",
|
|
42
|
+
markdownHorizontalRule: "#7B8794",
|
|
43
|
+
markdownListItem: "#7AA2F7",
|
|
44
|
+
markdownListEnumeration: "#BB9AF7",
|
|
45
|
+
markdownImage: "#7DCFFF",
|
|
46
|
+
markdownImageText: "#BB9AF7",
|
|
47
|
+
markdownCodeBlock: "#D8DEE9",
|
|
48
|
+
syntaxComment: "#7B8794",
|
|
49
|
+
syntaxKeyword: "#7AA2F7",
|
|
50
|
+
syntaxFunction: "#7DCFFF",
|
|
51
|
+
syntaxVariable: "#D8DEE9",
|
|
52
|
+
syntaxString: "#9ECE6A",
|
|
53
|
+
syntaxNumber: "#BB9AF7",
|
|
54
|
+
syntaxType: "#E0AF68",
|
|
55
|
+
syntaxOperator: "#7AA2F7",
|
|
56
|
+
syntaxPunctuation: "#D8DEE9",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HEX_RE_INIT = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
|
60
|
+
const lowerHex = (hex: HexColor): HexColor => {
|
|
61
|
+
const v = hex.replace("#", "")
|
|
62
|
+
const expanded =
|
|
63
|
+
v.length === 3 || v.length === 4
|
|
64
|
+
? v
|
|
65
|
+
.split("")
|
|
66
|
+
.map((c) => c + c)
|
|
67
|
+
.join("")
|
|
68
|
+
: v
|
|
69
|
+
const rgb = expanded.length === 8 ? expanded.slice(0, 6) : expanded
|
|
70
|
+
return `#${rgb.toLowerCase()}` as HexColor
|
|
71
|
+
}
|
|
72
|
+
// Sanity: ensure HARD_FALLBACK_RAW matches the hex regex (catch a typo at load).
|
|
73
|
+
for (const v of Object.values(HARD_FALLBACK_RAW)) {
|
|
74
|
+
if (!HEX_RE_INIT.test(v)) throw new Error(`bad fallback hex: ${v}`)
|
|
75
|
+
}
|
|
76
|
+
const HARD_FALLBACK: Readonly<Record<TokenName, HexColor>> = Object.fromEntries(
|
|
77
|
+
Object.entries(HARD_FALLBACK_RAW).map(([k, v]) => [k, lowerHex(v)]),
|
|
78
|
+
) as Readonly<Record<TokenName, HexColor>>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tokens that fall back to another token (rather than `HARD_FALLBACK`) when
|
|
82
|
+
* the theme omits them. Mirrors opencode's resolve behavior.
|
|
83
|
+
*/
|
|
84
|
+
const TOKEN_FALLBACK: Partial<Record<TokenName, TokenName>> = {
|
|
85
|
+
selectedListItemText: "background",
|
|
86
|
+
markdownText: "text",
|
|
87
|
+
markdownCodeBlock: "text",
|
|
88
|
+
borderSubtle: "border",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
|
92
|
+
|
|
93
|
+
const isHex = (value: string): value is HexColor => HEX_RE.test(value)
|
|
94
|
+
|
|
95
|
+
/** Normalize a hex value to `#rrggbb` (lowercase, 6-digit). */
|
|
96
|
+
const normalizeHex = (value: HexColor): HexColor => {
|
|
97
|
+
const v = value.replace("#", "")
|
|
98
|
+
const expanded =
|
|
99
|
+
v.length === 3 || v.length === 4
|
|
100
|
+
? v
|
|
101
|
+
.split("")
|
|
102
|
+
.map((c) => c + c)
|
|
103
|
+
.join("")
|
|
104
|
+
: v
|
|
105
|
+
const rgb = expanded.length === 8 ? expanded.slice(0, 6) : expanded
|
|
106
|
+
return `#${rgb.toLowerCase()}` as HexColor
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a single side of a {dark,light} variant — i.e. a string that's
|
|
111
|
+
* either a hex literal or a name in the theme's `defs` map. Returns `null`
|
|
112
|
+
* if the input doesn't resolve (caller falls back).
|
|
113
|
+
*/
|
|
114
|
+
const resolveSide = (value: string, defs: Record<string, string>): HexColor | null => {
|
|
115
|
+
if (isHex(value)) return normalizeHex(value)
|
|
116
|
+
const looked = defs[value]
|
|
117
|
+
if (looked && isHex(looked)) return normalizeHex(looked)
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolve a single ColorValue (variant, hex, or defs ref) for a tone. */
|
|
122
|
+
export const resolveColorValue = (
|
|
123
|
+
value: ColorValue | undefined,
|
|
124
|
+
tone: Tone,
|
|
125
|
+
defs: Record<string, string> = {},
|
|
126
|
+
): HexColor | null => {
|
|
127
|
+
if (value === undefined) return null
|
|
128
|
+
if (typeof value === "string") return resolveSide(value, defs)
|
|
129
|
+
const side = value[tone]
|
|
130
|
+
return resolveSide(side, defs)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a theme JSON to a flat record of `{ token → #rrggbb }` for the
|
|
135
|
+
* given tone. Missing tokens fall back via `TOKEN_FALLBACK` (chained) and
|
|
136
|
+
* ultimately `HARD_FALLBACK`.
|
|
137
|
+
*/
|
|
138
|
+
export const resolveTheme = (theme: ThemeJson, tone: Tone): ResolvedTheme => {
|
|
139
|
+
const defs = theme.defs ?? {}
|
|
140
|
+
const out: Partial<Record<TokenName, HexColor>> = {}
|
|
141
|
+
|
|
142
|
+
const tokenNames = Object.keys(HARD_FALLBACK) as readonly TokenName[]
|
|
143
|
+
const seen = new Set<TokenName>()
|
|
144
|
+
const resolveOne = (name: TokenName): HexColor => {
|
|
145
|
+
if (seen.has(name)) return HARD_FALLBACK[name]
|
|
146
|
+
seen.add(name)
|
|
147
|
+
const direct = resolveColorValue(theme.theme[name], tone, defs)
|
|
148
|
+
if (direct) return direct
|
|
149
|
+
const fallback = TOKEN_FALLBACK[name]
|
|
150
|
+
if (fallback) return resolveOne(fallback)
|
|
151
|
+
return HARD_FALLBACK[name]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const name of tokenNames) {
|
|
155
|
+
seen.clear()
|
|
156
|
+
out[name] = resolveOne(name)
|
|
157
|
+
}
|
|
158
|
+
return out as ResolvedTheme
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Permissive runtime validation: any object with a `theme` object passes. */
|
|
162
|
+
export const isThemeJson = (value: unknown): value is ThemeJson => {
|
|
163
|
+
if (typeof value !== "object" || value === null) return false
|
|
164
|
+
const v = value as Record<string, unknown>
|
|
165
|
+
return typeof v["theme"] === "object" && v["theme"] !== null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type { ThemeTokens }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"defs": {
|
|
3
|
+
"darkBg": "#0f0f0f",
|
|
4
|
+
"darkBgPanel": "#15141b",
|
|
5
|
+
"darkBorder": "#2d2d2d",
|
|
6
|
+
"darkFgMuted": "#6d6d6d",
|
|
7
|
+
"darkFg": "#edecee",
|
|
8
|
+
"purple": "#a277ff",
|
|
9
|
+
"pink": "#f694ff",
|
|
10
|
+
"blue": "#82e2ff",
|
|
11
|
+
"red": "#ff6767",
|
|
12
|
+
"orange": "#ffca85",
|
|
13
|
+
"cyan": "#61ffca",
|
|
14
|
+
"green": "#9dff65"
|
|
15
|
+
},
|
|
16
|
+
"theme": {
|
|
17
|
+
"primary": "purple",
|
|
18
|
+
"secondary": "pink",
|
|
19
|
+
"accent": "purple",
|
|
20
|
+
"error": "red",
|
|
21
|
+
"warning": "orange",
|
|
22
|
+
"success": "cyan",
|
|
23
|
+
"info": "purple",
|
|
24
|
+
"text": "darkFg",
|
|
25
|
+
"textMuted": "darkFgMuted",
|
|
26
|
+
"background": "darkBg",
|
|
27
|
+
"backgroundPanel": "darkBgPanel",
|
|
28
|
+
"backgroundElement": "darkBgPanel",
|
|
29
|
+
"border": "darkBorder",
|
|
30
|
+
"borderActive": "darkFgMuted",
|
|
31
|
+
"borderSubtle": "darkBorder",
|
|
32
|
+
"markdownText": "darkFg",
|
|
33
|
+
"markdownHeading": "purple",
|
|
34
|
+
"markdownLink": "pink",
|
|
35
|
+
"markdownLinkText": "purple",
|
|
36
|
+
"markdownCode": "cyan",
|
|
37
|
+
"markdownBlockQuote": "darkFgMuted",
|
|
38
|
+
"markdownEmph": "orange",
|
|
39
|
+
"markdownStrong": "purple",
|
|
40
|
+
"markdownHorizontalRule": "darkFgMuted",
|
|
41
|
+
"markdownListItem": "purple",
|
|
42
|
+
"markdownListEnumeration": "purple",
|
|
43
|
+
"markdownImage": "pink",
|
|
44
|
+
"markdownImageText": "purple",
|
|
45
|
+
"markdownCodeBlock": "darkFg",
|
|
46
|
+
"syntaxComment": "darkFgMuted",
|
|
47
|
+
"syntaxKeyword": "pink",
|
|
48
|
+
"syntaxFunction": "purple",
|
|
49
|
+
"syntaxVariable": "purple",
|
|
50
|
+
"syntaxString": "cyan",
|
|
51
|
+
"syntaxNumber": "green",
|
|
52
|
+
"syntaxType": "purple",
|
|
53
|
+
"syntaxOperator": "pink",
|
|
54
|
+
"syntaxPunctuation": "darkFg"
|
|
55
|
+
},
|
|
56
|
+
"name": "Aura",
|
|
57
|
+
"$schema": "../../../schema/house-theme.schema.json"
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"defs": {
|
|
3
|
+
"darkBg": "#0B0E14",
|
|
4
|
+
"darkBgAlt": "#0D1017",
|
|
5
|
+
"darkLine": "#11151C",
|
|
6
|
+
"darkPanel": "#0F131A",
|
|
7
|
+
"darkFg": "#BFBDB6",
|
|
8
|
+
"darkFgMuted": "#565B66",
|
|
9
|
+
"darkGutter": "#6C7380",
|
|
10
|
+
"darkTag": "#39BAE6",
|
|
11
|
+
"darkFunc": "#FFB454",
|
|
12
|
+
"darkEntity": "#59C2FF",
|
|
13
|
+
"darkString": "#AAD94C",
|
|
14
|
+
"darkRegexp": "#95E6CB",
|
|
15
|
+
"darkMarkup": "#F07178",
|
|
16
|
+
"darkKeyword": "#FF8F40",
|
|
17
|
+
"darkSpecial": "#E6B673",
|
|
18
|
+
"darkComment": "#ACB6BF",
|
|
19
|
+
"darkConstant": "#D2A6FF",
|
|
20
|
+
"darkOperator": "#F29668",
|
|
21
|
+
"darkAdded": "#7FD962",
|
|
22
|
+
"darkRemoved": "#F26D78",
|
|
23
|
+
"darkAccent": "#E6B450",
|
|
24
|
+
"darkError": "#D95757",
|
|
25
|
+
"darkIndentActive": "#6C7380"
|
|
26
|
+
},
|
|
27
|
+
"theme": {
|
|
28
|
+
"primary": "darkEntity",
|
|
29
|
+
"secondary": "darkConstant",
|
|
30
|
+
"accent": "darkAccent",
|
|
31
|
+
"error": "darkError",
|
|
32
|
+
"warning": "darkSpecial",
|
|
33
|
+
"success": "darkAdded",
|
|
34
|
+
"info": "darkTag",
|
|
35
|
+
"text": "darkFg",
|
|
36
|
+
"textMuted": "darkFgMuted",
|
|
37
|
+
"background": "darkBg",
|
|
38
|
+
"backgroundPanel": "darkPanel",
|
|
39
|
+
"backgroundElement": "darkBgAlt",
|
|
40
|
+
"border": "darkGutter",
|
|
41
|
+
"borderActive": "darkIndentActive",
|
|
42
|
+
"borderSubtle": "darkLine",
|
|
43
|
+
"markdownText": "darkFg",
|
|
44
|
+
"markdownHeading": "darkConstant",
|
|
45
|
+
"markdownLink": "darkEntity",
|
|
46
|
+
"markdownLinkText": "darkTag",
|
|
47
|
+
"markdownCode": "darkString",
|
|
48
|
+
"markdownBlockQuote": "darkSpecial",
|
|
49
|
+
"markdownEmph": "darkSpecial",
|
|
50
|
+
"markdownStrong": "darkFunc",
|
|
51
|
+
"markdownHorizontalRule": "darkFgMuted",
|
|
52
|
+
"markdownListItem": "darkEntity",
|
|
53
|
+
"markdownListEnumeration": "darkTag",
|
|
54
|
+
"markdownImage": "darkEntity",
|
|
55
|
+
"markdownImageText": "darkTag",
|
|
56
|
+
"markdownCodeBlock": "darkFg",
|
|
57
|
+
"syntaxComment": "darkComment",
|
|
58
|
+
"syntaxKeyword": "darkKeyword",
|
|
59
|
+
"syntaxFunction": "darkFunc",
|
|
60
|
+
"syntaxVariable": "darkEntity",
|
|
61
|
+
"syntaxString": "darkString",
|
|
62
|
+
"syntaxNumber": "darkConstant",
|
|
63
|
+
"syntaxType": "darkSpecial",
|
|
64
|
+
"syntaxOperator": "darkOperator",
|
|
65
|
+
"syntaxPunctuation": "darkFg"
|
|
66
|
+
},
|
|
67
|
+
"name": "Ayu",
|
|
68
|
+
"$schema": "../../../schema/house-theme.schema.json"
|
|
69
|
+
}
|