agents/openai.yaml
interface:
display_name: "CodexThemes Creator"
short_description: "Design and verify cohesive Codex desktop themes"
default_prompt: "Use $codex-theme-creator to create a cohesive, native-safe Codex desktop theme from my brief or reference image, then validate every required Codex surface."
assets/editorial-showcase.css
/* __THEME_ID__: bounded home composition; keep native controls above it. */
:root[data-codexthemes-theme="__THEME_ID__"] {
--ct-canvas: #fffaf6;
--ct-surface: rgba(255, 253, 251, 0.92);
--ct-raised: #ffffff;
--ct-text: #3f3033;
--ct-muted: #806d72;
--ct-accent: #bd4968;
--ct-border: #ead3d9;
--ct-art: url("./assets/artwork.png");
color-scheme: light;
--color-background-surface: var(--ct-canvas) !important;
--color-background-surface-under: var(--ct-surface) !important;
--color-background-panel: var(--ct-surface) !important;
--color-background-control: var(--ct-raised) !important;
--color-background-control-opaque: var(--ct-raised) !important;
--color-background-editor-opaque: var(--ct-canvas) !important;
--color-text-foreground: var(--ct-text) !important;
--color-text-foreground-secondary: var(--ct-muted) !important;
--color-text-foreground-tertiary: var(--ct-muted) !important;
--color-icon-primary: var(--ct-text) !important;
--color-icon-secondary: var(--ct-muted) !important;
--color-icon-tertiary: var(--ct-muted) !important;
--color-border: var(--ct-border) !important;
--color-border-light: var(--ct-border) !important;
--color-border-heavy: var(--ct-border) !important;
--color-border-focus: var(--ct-accent) !important;
--color-token-bg-primary: var(--ct-canvas) !important;
--color-token-foreground: var(--ct-text) !important;
--color-token-input-background: var(--ct-raised) !important;
--color-token-menu-background: var(--ct-raised) !important;
--color-token-conversation-body: var(--ct-text) !important;
--color-token-text-code-block-background: var(--ct-raised) !important;
}
main.main-surface[data-codexthemes-page="home"] {
color: var(--ct-text);
background: var(--ct-canvas);
}
main.main-surface[data-codexthemes-page="home"]::before {
content: "";
position: absolute;
inset: 24px;
z-index: 0;
border: 1px solid var(--ct-border);
border-radius: 24px;
background: linear-gradient(90deg, rgba(255,250,246,.92) 0 44%, rgba(255,250,246,.18) 68%), var(--ct-art) 70% 35% / cover no-repeat;
pointer-events: none;
}
main.main-surface[data-codexthemes-page="conversation"],
aside.app-shell-left-panel,
.composer-surface-chrome,
[role="menu"],
[role="dialog"],
[data-codexthemes-surface="output"],
[data-codexthemes-surface="diff"] {
color: var(--ct-text);
background: var(--ct-surface);
border-color: var(--ct-border);
}
[data-codex-terminal="true"] {
--vscode-terminal-background: var(--ct-canvas) !important;
--vscode-terminal-foreground: var(--ct-text) !important;
--color-background-panel: var(--ct-canvas) !important;
--color-background-editor-opaque: var(--ct-canvas) !important;
color: var(--ct-text) !important;
background: var(--ct-canvas) !important;
}
.xterm,
.xterm-viewport,
.xterm-screen,
[data-codexthemes-surface="terminal"] {
color: var(--ct-text) !important;
background: var(--ct-canvas) !important;
}
@media (max-width: 980px) {
main.main-surface[data-codexthemes-page="home"]::before { inset: 12px; background-position: 64% 35%; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
assets/native-background.css
/* __THEME_ID__: preserve native geometry and components. */
:root[data-codexthemes-theme="__THEME_ID__"] {
--ct-art: url("./assets/artwork.png");
--ct-art-position: 50% 50%;
--ct-veil: rgba(255, 250, 246, 0.34);
}
main.main-surface[data-codexthemes-page="home"] {
isolation: isolate;
background: transparent;
}
main.main-surface[data-codexthemes-page="home"]::before,
:root[data-codexthemes-background-scope="workspace"] main.main-surface[data-codexthemes-page="conversation"]::before {
content: "";
position: absolute;
inset: 0;
z-index: -2;
background: var(--ct-art) var(--ct-art-position) / cover no-repeat;
pointer-events: none;
}
main.main-surface[data-codexthemes-page="home"]::after,
:root[data-codexthemes-background-scope="workspace"] main.main-surface[data-codexthemes-page="conversation"]::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: var(--ct-veil);
pointer-events: none;
}
/* Enable these bounded surfaces only when backgroundScope is workspace. */
:root[data-codexthemes-background-scope="workspace"] main.main-surface[data-codexthemes-page="conversation"] [data-message-author-role],
:root[data-codexthemes-background-scope="workspace"] main.main-surface[data-codexthemes-page="conversation"] .composer-surface-chrome {
background: color-mix(in srgb, #fffdfb 88%, transparent);
backdrop-filter: blur(14px);
}
@media (max-width: 980px) {
:root[data-codexthemes-theme="__THEME_ID__"] { --ct-art-position: 62% 50%; }
}
@media (prefers-reduced-motion: reduce) {
main.main-surface[data-codexthemes-page="home"]::before { transition: none; }
}
assets/native-immersive.css
/* __THEME_ID__: preserve geometry while coordinating semantic materials.
Slots below map to the decoration menu in references/design-playbook.md §8.
Fill each slot for the contracted decorDensity; delete slots the contract excludes. */
:root[data-codexthemes-theme="__THEME_ID__"] {
--ct-canvas: #fffaf6;
--ct-surface: rgba(255, 253, 251, 0.91);
--ct-raised: #ffffff;
--ct-text: #3f3033;
--ct-muted: #806d72;
--ct-accent: #bd4968;
--ct-accent-soft: rgba(189, 73, 104, 0.14);
--ct-border: #ead3d9;
--ct-focus: #bd4968;
--ct-shadow: rgba(63, 48, 51, 0.12);
--ct-art: url("./assets/artwork.png");
/* Typography pairing: display stack for headings/brand moments, body stack for prose. */
--ct-font-display: "Iowan Old Style", "Songti SC", serif;
--ct-font-body: "Avenir Next", "PingFang SC", sans-serif;
/* Native token sweep (required): Codex components resolve colors from the app's
own --color-token-* variables, which follow the USER's native light/dark mode.
Override the full set so the theme renders identically on every machine.
Tune each value to the semantic palette; never delete entries. */
color-scheme: light;
/* Direct semantic bridge used by current settings, panels, and mounted roots. */
--color-background-surface: var(--ct-canvas) !important;
--color-background-surface-under: var(--ct-surface) !important;
--color-background-panel: var(--ct-surface) !important;
--color-background-control: var(--ct-raised) !important;
--color-background-control-opaque: var(--ct-raised) !important;
--color-background-editor-opaque: var(--ct-canvas) !important;
--color-background-elevated-primary: var(--ct-raised) !important;
--color-background-elevated-primary-opaque: var(--ct-raised) !important;
--color-background-elevated-secondary: var(--ct-surface) !important;
--color-background-elevated-secondary-opaque: var(--ct-surface) !important;
--color-text-foreground: var(--ct-text) !important;
--color-text-foreground-secondary: var(--ct-muted) !important;
--color-text-foreground-tertiary: var(--ct-muted) !important;
--color-icon-primary: var(--ct-text) !important;
--color-icon-secondary: var(--ct-muted) !important;
--color-icon-tertiary: var(--ct-muted) !important;
--color-border: var(--ct-border) !important;
--color-border-light: var(--ct-border) !important;
--color-border-heavy: var(--ct-border) !important;
--color-border-focus: var(--ct-focus) !important;
--color-token-bg-primary: var(--ct-canvas) !important;
--color-token-bg-secondary: var(--ct-surface) !important;
--color-token-bg-tertiary: var(--ct-raised) !important;
--color-token-main-surface-primary: var(--ct-canvas) !important;
--color-token-side-bar-background: var(--ct-surface) !important;
--color-token-foreground: var(--ct-text) !important;
--color-token-text-primary: var(--ct-text) !important;
--color-token-text-secondary: var(--ct-muted) !important;
--color-token-text-tertiary: var(--ct-muted) !important;
--color-token-description-foreground: var(--ct-muted) !important;
--color-token-icon-foreground: var(--ct-muted) !important;
--color-token-input-background: var(--ct-raised) !important;
--color-token-input-foreground: var(--ct-text) !important;
--color-token-input-placeholder-foreground: var(--ct-muted) !important;
--color-token-input-border: var(--ct-border) !important;
--color-token-border: var(--ct-border) !important;
--color-token-border-default: var(--ct-border) !important;
--color-token-border-heavy: var(--ct-border) !important;
--color-token-border-light: var(--ct-border) !important;
--color-token-list-hover-background: var(--ct-accent-soft) !important;
--color-token-list-active-selection-background: var(--ct-accent-soft) !important;
--color-token-list-active-selection-foreground: var(--ct-text) !important;
--color-token-toolbar-hover-background: var(--ct-accent-soft) !important;
--color-token-button-background: var(--ct-accent) !important;
--color-token-button-foreground: var(--ct-raised) !important;
--color-token-button-border: var(--ct-border) !important;
--color-token-link: var(--ct-accent) !important;
--color-token-text-link-foreground: var(--ct-accent) !important;
--color-token-text-link-active-foreground: var(--ct-accent) !important;
--color-token-primary: var(--ct-accent) !important;
--color-token-focus-border: var(--ct-focus) !important;
--color-token-dropdown-background: var(--ct-raised) !important;
--color-token-dropdown-foreground: var(--ct-text) !important;
--color-token-menu-background: var(--ct-raised) !important;
--color-token-menu-border: var(--ct-border) !important;
--color-token-checkbox-background: var(--ct-raised) !important;
--color-token-checkbox-border: var(--ct-border) !important;
--color-token-checkbox-foreground: var(--ct-accent) !important;
--color-token-badge-background: var(--ct-accent-soft) !important;
--color-token-badge-foreground: var(--ct-text) !important;
--color-token-scrollbar-slider-background: var(--ct-accent-soft) !important;
--color-token-scrollbar-slider-hover-background: var(--ct-accent-soft) !important;
--color-token-scrollbar-slider-active-background: var(--ct-accent-soft) !important;
--color-token-conversation-header: var(--ct-muted) !important;
--color-token-conversation-body: var(--ct-text) !important;
--color-token-conversation-summary-leading: var(--ct-muted) !important;
--color-token-conversation-summary-trailing: var(--ct-muted) !important;
--color-token-non-assistant-body-descendant: var(--ct-muted) !important;
--color-token-text-preformat-foreground: var(--ct-text) !important;
--color-token-text-preformat-background: var(--ct-surface) !important;
--color-token-text-code-block-background: var(--ct-raised) !important;
}
/* Layered canvas: gradient + artwork or texture, never one flat color. */
main.main-surface[data-codexthemes-page="home"],
main.main-surface[data-codexthemes-page="conversation"] {
color: var(--ct-text);
background: var(--ct-canvas);
}
main.main-surface[data-codexthemes-page="home"]::before,
:root[data-codexthemes-background-scope="workspace"] main.main-surface[data-codexthemes-page="conversation"]::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(rgba(255,250,246,.32), rgba(255,250,246,.52)), var(--ct-art) 50% 50% / cover no-repeat;
pointer-events: none;
}
/* Coordinated surfaces: sidebar, composer, menus, dialogs, output/diff share one material system. */
aside.app-shell-left-panel,
.composer-surface-chrome,
[role="menu"],
[role="dialog"],
[data-codexthemes-surface="output"],
[data-codexthemes-surface="diff"] {
color: var(--ct-text);
background: var(--ct-surface);
border-color: var(--ct-border);
}
/* Home stage — hero treatment (rich): frame the native heading region as a banner card.
Anchor to a verified heading container inside the home page; keep a veil gradient over
the text-safe region and keep every native control clickable. Example shape:
main.main-surface[data-codexthemes-page="home"] <verified-heading-container> {
border: 1px solid var(--ct-border);
border-radius: 24px;
background:
linear-gradient(90deg, var(--ct-canvas) 0%, rgba(255,250,246,.85) 55%, rgba(255,250,246,.1) 85%),
var(--ct-art) right center / auto 200% no-repeat;
box-shadow: 0 14px 34px var(--ct-shadow);
}
*/
/* Home stage — tagline (rich): one short themed line via a pseudo-element on a verified
non-interactive hook under the heading. Keep it pointer-events: none. */
/* Home stage — suggestion cards (balanced+): themed material, badged icon, gentle hover lift.
Anchor to the verified suggestion-card hook; keep labels readable and cards clickable.
main.main-surface[data-codexthemes-page="home"] <verified-suggestion-card> {
border: 1px solid var(--ct-border);
border-radius: 20px;
background: linear-gradient(var(--ct-raised), var(--ct-surface));
box-shadow: 0 8px 18px var(--ct-shadow);
transition: transform .2s ease, box-shadow .2s ease;
}
main.main-surface[data-codexthemes-page="home"] <verified-suggestion-card>:hover {
transform: translateY(-4px);
box-shadow: 0 14px 26px var(--ct-shadow);
}
*/
/* Chrome accents (rich): header lockup, corner emblem, ambient particles, composer marker.
Every accent is a positioned pseudo-element with pointer-events: none, below native
controls, scoped to the home marker, and dropped at narrow widths. Example ambient dot: */
main.main-surface[data-codexthemes-page="home"]::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 88% 12%, var(--ct-accent-soft) 0 2px, transparent 3px),
radial-gradient(circle at 12% 22%, var(--ct-accent-soft) 0 1px, transparent 2px);
background-size: 89px 89px, 67px 67px;
opacity: .5;
animation: ct-__THEME_ID__-drift 8s ease-in-out infinite alternate;
}
@keyframes ct-__THEME_ID__-drift {
from { opacity: .35; }
to { opacity: .6; }
}
/* Token sweep: scrollbars, selection, caret, focus — no stale native color survives. */
main.main-surface[data-codexthemes-page="home"],
main.main-surface[data-codexthemes-page="conversation"],
aside.app-shell-left-panel {
scrollbar-color: var(--ct-accent-soft) transparent;
}
[data-codex-terminal="true"] {
--vscode-terminal-background: var(--ct-canvas) !important;
--vscode-terminal-foreground: var(--ct-text) !important;
--color-background-panel: var(--ct-canvas) !important;
--color-background-editor-opaque: var(--ct-canvas) !important;
color: var(--ct-text) !important;
background: var(--ct-canvas) !important;
}
.xterm,
.xterm-viewport,
.xterm-screen,
[data-codexthemes-surface="terminal"] {
color: var(--ct-text) !important;
background: var(--ct-canvas) !important;
}
:focus-visible { outline-color: var(--ct-focus); }
@media (max-width: 980px) {
main.main-surface[data-codexthemes-page="home"]::before { background-position: 62% 50%; }
/* Drop corner emblems and ambient layers first; keep materials and palette. */
main.main-surface[data-codexthemes-page="home"]::after { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
assets/palette-only.css
/* __THEME_ID__: semantic palette only; preserve native geometry. */
:root[data-codexthemes-theme="__THEME_ID__"] {
--ct-canvas: #fffaf6;
--ct-surface: #fffdfb;
--ct-raised: #ffffff;
--ct-text: #3f3033;
--ct-muted: #806d72;
--ct-accent: #bd4968;
--ct-border: #ead3d9;
color-scheme: light;
--color-background-surface: var(--ct-canvas) !important;
--color-background-surface-under: var(--ct-surface) !important;
--color-background-panel: var(--ct-surface) !important;
--color-background-control: var(--ct-raised) !important;
--color-background-control-opaque: var(--ct-raised) !important;
--color-background-editor-opaque: var(--ct-canvas) !important;
--color-text-foreground: var(--ct-text) !important;
--color-text-foreground-secondary: var(--ct-muted) !important;
--color-text-foreground-tertiary: var(--ct-muted) !important;
--color-icon-primary: var(--ct-text) !important;
--color-icon-secondary: var(--ct-muted) !important;
--color-icon-tertiary: var(--ct-muted) !important;
--color-border: var(--ct-border) !important;
--color-border-light: var(--ct-border) !important;
--color-border-heavy: var(--ct-border) !important;
--color-border-focus: var(--ct-accent) !important;
--color-token-bg-primary: var(--ct-canvas) !important;
--color-token-foreground: var(--ct-text) !important;
--color-token-input-background: var(--ct-raised) !important;
--color-token-menu-background: var(--ct-raised) !important;
--color-token-conversation-body: var(--ct-text) !important;
--color-token-text-code-block-background: var(--ct-raised) !important;
}
main.main-surface,
aside.app-shell-left-panel {
color: var(--ct-text);
background: var(--ct-canvas);
border-color: var(--ct-border);
}
.composer-surface-chrome,
[role="menu"],
[role="dialog"],
[data-codexthemes-surface="output"],
[data-codexthemes-surface="diff"] {
color: var(--ct-text);
background: var(--ct-surface);
border-color: var(--ct-border);
}
[data-codex-terminal="true"] {
--vscode-terminal-background: var(--ct-canvas) !important;
--vscode-terminal-foreground: var(--ct-text) !important;
--color-background-panel: var(--ct-canvas) !important;
--color-background-editor-opaque: var(--ct-canvas) !important;
color: var(--ct-text) !important;
background: var(--ct-canvas) !important;
}
.xterm,
.xterm-viewport,
.xterm-screen,
[data-codexthemes-surface="terminal"] {
color: var(--ct-text) !important;
background: var(--ct-canvas) !important;
}
:focus-visible { outline-color: var(--ct-accent); }
@media (max-width: 980px) {
[data-codexthemes-decoration="optional"] { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
references/asset-rights.md
# User material and submission handoff
Use attached or otherwise user-supplied material as the requested theme input. Do not run a copyright investigation, ask the user to replace it, or block local creation, application, validation, or `.codex-theme` export based on the depicted person, game, film, anime, sport, logo, or brand.
Keep private data, credentials, and unrelated personal files out of the package. Do not claim ownership, licensing status, endorsement, or affiliation on the user's behalf.
After export, provide:
- source path: `~/.codexthemes/themes/<theme-id>/`
- package path: `~/.codexthemes/exports/<theme-id>.codex-theme`
- submission URL: `https://codexthemes.ai/submit`
- a reminder that the uploader confirms sharing permission on the submission page
This handoff is informational, not a packaging gate or legal assessment.
references/design-playbook.md
# Codex theme design playbook
## Contents
1. Design contract
2. Layout modes
3. Semantic palette
4. Page and surface scope
5. Selector discipline
6. Artwork and composition
7. Responsive and lifecycle behavior
8. Decoration menu and density coverage
## 1. Design contract
Write the contract before CSS. It must name the layout mode, background scope, decor density, mode reason, focal point, text-safe region, allowed changes, preserved native behavior, and target viewports.
Visual richness does not imply permission to restructure Codex. If a reference can be expressed through background, palette, material, and ornament changes, preserve native geometry. The reverse also holds: preserving native geometry does not mean shipping a bare background — section 8 defines how much of the decoration menu each density must cover.
## 2. Layout modes
| Mode | Geometry | Native components | Artwork |
| --- | --- | --- | --- |
| `native-background` | unchanged | unchanged except bounded readability surfaces for workspace artwork | home, or home plus verified conversations |
| `native-immersive` | unchanged | coordinated semantic materials and states | full workspace when contracted |
| `editorial-showcase` | bounded home hero may change | all native controls remain functional and coordinated | hero plus restrained task background |
| `palette-only` | unchanged | semantic colors and materials | none |
### Native background
Use when the user asks to change only the background or likes the default Codex layout. Preserve sidebar, header, suggestion cards, selector, composer, menus, settings, diffs, terminal, spacing, and interaction states. A readability veil may sit between artwork and native content. With `backgroundScope: workspace`, use bounded message and composer surfaces instead of recoloring every descendant.
### Native immersive
Keep the native information architecture and dimensions. Coordinate sidebar, cards, selector, composer, menus, settings, output/diff, terminal, focus, hover, and selected states through semantic tokens.
### Editorial showcase
Use only for a clearly requested portrait, product, campaign, or gallery composition. Bound the composition to the home hero. Do not turn a screenshot into fake controls or move native controls into decorative artwork.
### Palette only
Use for a restrained skin without dominant artwork. Do not add a hidden or decorative background merely to make the theme feel richer.
## 3. Semantic palette
Define these roles before component selectors:
- canvas
- surface
- raised surface
- input surface
- primary text
- muted text
- disabled text
- accent
- border
- focus
- success
- warning
- danger
- diff added and removed
- inline code and code block
- terminal foreground and background
Use at least three distinguishable surface elevations for immersive and showcase modes. Use accent selectively.
For light themes, do not leave settings, menus, dropdowns, output panels, changed-files cards, or the terminal host on stale navy, gray, or purple native tokens. A dark terminal is allowed only when the contract calls it an intentional contrast panel and its tab/header belongs to the same material system.
For dark themes, avoid pure black across every surface. Use distinguishable elevations and readable muted text.
### Native token sweep (required for immersive, showcase, and palette modes)
Codex resolves most component colors from its own `--color-token-*` CSS variables, and those variables follow the **user's** native light/dark appearance — not yours. A theme that recolors surfaces but leaves native tokens untouched only looks right on machines whose native mode happens to match the theme, and breaks on everyone else's (dark text on a dark canvas, stray white bands). Do not invent lookalike token names (`--background`, `--card`, shadcn-style HSL triples); they change nothing.
Inside the theme root selector, set `color-scheme` to the theme's mode and override the full native token set with `!important`, mapping every token to the semantic palette. The canonical set:
`bg-primary`, `bg-secondary`, `bg-tertiary`, `main-surface-primary`, `side-bar-background`, `foreground`, `text-primary`, `text-secondary`, `text-tertiary`, `description-foreground`, `icon-foreground`, `input-background`, `input-foreground`, `input-placeholder-foreground`, `input-border`, `border`, `border-default`, `border-heavy`, `border-light`, `list-hover-background`, `list-active-selection-background`, `list-active-selection-foreground`, `toolbar-hover-background`, `button-background`, `button-foreground`, `button-border`, `link`, `text-link-foreground`, `text-link-active-foreground`, `primary`, `focus-border`, `dropdown-background`, `dropdown-foreground`, `menu-background`, `menu-border`, `checkbox-background`, `checkbox-border`, `checkbox-foreground`, `badge-background`, `badge-foreground`, `scrollbar-slider-background`, `scrollbar-slider-hover-background`, `scrollbar-slider-active-background`, `conversation-header`, `conversation-body`, `conversation-summary-leading`, `conversation-summary-trailing`, `non-assistant-body-descendant`, `text-preformat-foreground`, `text-preformat-background`, `text-code-block-background`
(each prefixed `--color-token-`). Verify against the live DOM and add any token the current app version defines that this list misses. The finished theme must render identically whether the user's Codex starts in native light or native dark mode.
Current builds also consume a lower-level semantic bridge directly, especially in settings, panels, and dynamically mounted content. Component-changing themes must map at least `--color-background-surface`, `--color-background-surface-under`, `--color-background-panel`, `--color-background-control`, `--color-background-control-opaque`, `--color-background-editor-opaque`, both elevated background levels, `--color-text-foreground` plus secondary/tertiary, `--color-icon-primary` plus secondary/tertiary, and `--color-border` plus light/heavy/focus. Treat these as peers of the canonical token sweep, not optional VS Code compatibility aliases.
Custom properties inherit only when the child does not declare its own value. Settings and terminal roots may carry a complete native-light variable set in an inline `style`; inherited root declarations cannot win against that local island, even when the inherited declaration was important. Inspect the owning root and redeclare the relevant bridge and VS Code variables there. The terminal contract should include `[data-codex-terminal="true"]`, `.xterm`, `.xterm-viewport`, and `.xterm-screen`; the viewport can have an inline background and therefore needs a narrowly scoped important background rule. Verify ANSI foregrounds on a terminal that existed before the live theme switch as well as one mounted afterward.
## 4. Page and surface scope
Theme and test these surfaces independently:
| Surface | Required checks |
| --- | --- |
| Home | heading, suggestion cards, selector, composer, empty-state artwork |
| Conversation | prose, tool states, files, attachments, code, persistent composer |
| Sidebar | idle, hover, selected, project hover, long title, row actions, footer menu |
| Header | title, actions, side-task control, narrow width, readable solid surface |
| Settings | navigation, cards, inputs, selects, toggles, dialogs |
| Menus | profile menu, dropdown, context menu, tooltip, disabled item |
| Output/diff | file cards, changed-files list, additions, deletions, review controls |
| Terminal | tab strip, host, xterm viewport, xterm screen, selection and cursor |
| System pages | plugins/apps directory, sites, scheduled tasks: headings, descriptions, search inputs, cards — no native-mode surfaces or fades left behind |
Scope home selectors to a verified home marker. Scope task artwork to a verified conversation marker. Never use `main:not(...)`, page text, or absence of the home marker as proof that a page is a conversation. Settings and system pages require their own scope.
## 5. Selector discipline
Prefer verified stable hooks:
1. roles and test IDs
2. stable component root classes
3. narrowly anchored `:has(...)`
4. versioned fallback classes
Avoid localized text selectors and structural selectors tied to arbitrary child order.
Hard rules:
- Do not target `aside *`, `main *`, generic `svg`, or broad descendant `:is(...)` with state-changing properties.
- Do not force descendant opacity to repair contrast. Hidden and hover-only controls rely on opacity.
- Do not globally set `position`, `display`, `visibility`, `overflow`, or dimensions on native descendants.
- Do not assign the same border to both a parent and each child. Give every boundary one owner.
- Do not append repeated emergency overrides. Consolidate the original rule.
- Keep decorative pseudo-elements `pointer-events: none` and below all controls.
- Keep header controls on a solid readable surface. Put gradients behind the header, not through it.
- Reserve enough sidebar width for row actions; truncate the title before the pin/archive actions.
## 6. Artwork and composition
Record the focal point and a text-safe region. Use `object-position` or background positioning intentionally at desktop and narrow widths.
For `native-background`, artwork may be full-screen while native content remains centered and unchanged. This is a valid finished theme; do not force a showcase layout.
For `native-immersive` and `editorial-showcase`, coordinate veils and bounded content surfaces so the artwork remains visible without harming prose, code, or controls.
Artwork on conversations is opt-in through `backgroundScope: workspace`. Reduce contrast behind long-form content. Do not repeat a large hero behind every message.
Use the user's supplied artwork directly when requested. Crop or clean it only for composition, readability, private-data removal, or asset compatibility; do not replace it merely because it depicts third-party subject matter.
## 7. Responsive and lifecycle behavior
At desktop width, preserve the native relationship among heading, suggestion cards, selector, and composer unless showcase mode explicitly changes the hero. At narrow width, remove nonessential ornaments before reducing text contrast or control hit areas.
Verify cold launch. Inject base tokens and structural CSS before decoding large artwork so the page does not flash from native to themed geometry. Artwork failure must leave a readable usable app.
Verify route changes, renderer reload, theme switching, restore, and reapply. Dynamic terminal and menu portals may mount after the initial stylesheet; their semantic tokens must still resolve correctly.
## 8. Decoration menu and density coverage
A theme reads as finished when the reference's world shows up in the details, not only in the wallpaper. Build decoration from this menu. Every item is pure CSS on verified hooks — no scripts, no fake controls, `pointer-events: none` on every decorative layer, and native geometry, hit targets, and hover-only actions untouched.
### Element menu
**Materials and typography**
- Themed font pairing: one display stack for headings and brand moments, one body stack; apply through scoped selectors, never `* { font-family }`.
- Layered surfaces: gradient plus subtle texture or grain on canvas, sidebar, header, composer, and cards instead of one flat color. Keep prose and code regions calm.
- Full token sweep: recolor scrollbars, selection, caret, links, badges, checkboxes, toggles, dropdown and menu materials, code/diff panels, and terminal together with the headline surfaces so no stale native color survives.
**Home stage** (scope with `main.main-surface[data-codexthemes-page="home"]`)
- Hero treatment: frame the native heading region as a banner card — artwork on one side, a veil gradient protecting the text-safe region, a themed border and shadow.
- Tagline: a short themed line under the heading via a pseudo-element `content:` on a verified non-interactive hook.
- Suggestion cards: themed material, a circular or badged icon treatment, and a small hover lift/glow; keep label text readable and the whole card clickable.
- Project selector: themed chip material consistent with the cards.
**Chrome accents** (all `pointer-events: none`, below native controls)
- Header lockup: a small brand/title or motto treatment at a header edge that never covers native actions.
- Corner emblem: one signature ornament — a seal, sticker, framed miniature of the artwork, or mascot — anchored to a home corner and hidden at narrow widths.
- Ambient layer: sparse particles, sparkles, mist, or ink motes as positioned pseudo-elements with a gentle keyframe animation; disable the animation under `prefers-reduced-motion`.
- Composer accent: a themed frame for the composer chrome plus one small decorative marker on its edge.
- Sidebar accents: coordinated hover/selected materials and at most one small glyph flourish; never hide or displace row actions.
### Density coverage
| `decorDensity` | Required coverage |
| --- | --- |
| `minimal` | background with veil, semantic palette, readable states |
| `balanced` | minimal, plus layered surfaces, full token sweep, themed suggestion cards and composer, font pairing |
| `rich` | balanced, plus hero treatment, tagline, header lockup, one corner emblem, one ambient layer, sidebar accents |
Decoration never outranks readability: if an element fights prose, code, or control contrast at any viewport, cut the element, not the contrast. At narrow widths drop corner emblems and ambient layers first, keep materials and palette.
references/qa-checklist.md
# Codex theme QA checklist
## Evidence levels
Keep these claims separate:
1. **Designed**: source and preview exist in `~/.codexthemes/themes/<theme-id>/` (previews under its `previews/` directory), not in a workspace or staging copy.
2. **Statically valid**: `validate-theme.ts` passes through `tsx`.
3. **Applied**: the expected theme ID and version are active.
4. **Visually verified**: real Codex screenshots and geometry checks pass.
5. **Packaged**: a safe portable package matches the verified version.
Never promote one level into another without evidence.
## P0 hard failures
Reject and revise when any item occurs:
- native layout changes outside the selected layout mode
- artwork leaks from home or conversation scope into settings/system pages
- light theme contains accidental dark settings, menu, diff, output, or terminal surfaces
- theme readability depends on the user's native appearance: the native `--color-token-*` sweep is missing or partial, so the theme renders correctly only when the machine's native light/dark mode matches the theme's mode
- settings, panels, or terminal hosts retain locally declared native-light semantic variables because only inherited root tokens were changed
- text, placeholder, icon, focus ring, code, diff, or menu contrast is unreadable
- `scripts/qa-contrast.ts` reports any failure (text below 2.5:1 on a verified opaque backdrop) — run it immediately after every apply
- broad opacity rules expose hidden sidebar actions
- row title overlaps pin, archive, project, or overflow actions
- header gradient makes later controls or side-task text unreadable
- duplicate borders create double vertical or horizontal lines
- suggestion cards, project selector, composer, or task content is clipped
- terminal tab strip, `[data-codex-terminal]` host, xterm viewport, and xterm screen use unrelated theme systems or expose the host through padding/gutters
- decoration intercepts pointer or keyboard input
- cold launch visibly flashes into a different geometry
- theme works only on home while the contract requires immersive/workspace coverage
- contracted `decorDensity` coverage is missing: a `balanced` or `rich` theme ships as a background swap without the design playbook's required elements (materials, token sweep, cards, composer, and for `rich` the hero, tagline, lockup, emblem, and ambient layer)
- remote CSS, scripts, tracking, secrets, or private data are packaged
A background-only result is not a failure when the contract selected `native-background` with `minimal` density. It is a failure when component restyling or a higher density was promised but not completed.
## Required state matrix
Verify at least:
- sidebar: idle, task hover, selected task, project hover, long title, collapsed group
- header: default, narrow, side-task panel open
- composer: empty, focused, running, disabled, approval mode, microphone/send controls
- menus: personal menu, general dropdown, tooltip, disabled item
- conversation: prose, tool call, attachment, file card, changed-files card, output panel
- settings: navigation, section card, toggle, select, dialog
- system pages: plugins/apps directory, sites, scheduled — headings, search input, cards readable with no native-mode surfaces left behind
- terminal: before mount, after xterm mount, cursor, selection
- route-local contrast: rerun `qa-contrast.ts` on home, conversation, settings, and mounted terminal; one page's pass cannot stand in for another
- mode independence: switch Codex's native appearance to the opposite of the theme's mode (or clear the injected style and compare) and re-verify home, one system page, and one menu — the theme must render identically from a native-light and a native-dark start
## Geometry checks
At desktop and narrow widths, record:
- horizontal overflow: `0px`
- sidebar title/action overlap: `0px`
- header control overlap: `0px`
- clipped interactive controls: `0`
- duplicate boundary owners: `0`
Inspect computed styles rather than trusting the screenshot alone.
## Palette checks
For every changed surface, record background, foreground, border, and focus color. Verify text and icon tokens separately; pale icons often remain unreadable after prose is fixed.
When a visible color disagrees with the root token value, record the owning element's inline style and locally declared custom properties. Check `--color-background-*`, `--color-text-*`, `--color-icon-*`, `--color-border*`, and `--vscode-terminal-*` before adding a component selector. Prefer the smallest verified root that owns the stale values.
For light themes, explicitly inspect settings canvas, settings sidebar, menus, dialogs, output panels, changed-files cards, code blocks, terminal tab strip, terminal host, and xterm viewport.
## Lifecycle checks
Verify:
1. cold launch
2. home to conversation navigation
3. conversation to settings navigation
4. menu and dialog portals opened after injection
5. terminal mounted after injection
6. renderer reload
7. theme switch
8. restore to native
9. reapply after restore
## Revision protocol
When a P0 check fails:
1. Identify the selector, token, scope, or boundary owner responsible.
2. Fix the owning layer rather than appending a symptom override.
3. Remove obsolete competing rules.
4. Increment the visible theme version.
5. Rerun static validation and the complete affected matrix.
6. Capture fresh real-app evidence.
references/theme-schema.md
# Theme schema
Use UTF-8 JSON in `theme.json`.
## Managed storage contract
- Editable source: `~/.codexthemes/themes/<theme-id>/`
- Shareable package: `~/.codexthemes/exports/<theme-id>.codex-theme`
- Runtime state: `~/.codexthemes/state/`
These are mandatory defaults, not examples. A user may explicitly request a custom source or export directory. Never default to the current repository, workspace, Downloads folder, or the installed Skill directory. `CODEX_THEMES_HOME` may override the root for automation and tests; do not set it during normal creation unless the user asks for a custom managed root.
## Required fields
```json
{
"schemaVersion": 1,
"id": "safe-theme-id",
"displayName": "Theme name",
"description": "Public description",
"version": "0.1.0",
"mode": "light",
"css": "theme.css",
"art": "assets/artwork.png",
"preview": "previews/home-1440x900.png",
"design": {
"layoutMode": "native-background",
"backgroundScope": "home",
"modeReason": "Reference uses warm light paper surfaces.",
"artFocalPoint": "70% 35%",
"textSafeRegion": "left 52%",
"contrastStrategy": "Warm veil behind native text only.",
"allowedChanges": ["home background"],
"preserve": ["native geometry", "native controls", "native states"],
"verificationViewports": ["1440x900", "980x760"]
},
"palette": {
"canvas": "#fffaf6",
"surface": "#fffdfb",
"raised": "#ffffff",
"text": "#3f3033",
"muted": "#806d72",
"accent": "#bd4968",
"border": "#ead3d9",
"focus": "#bd4968",
"success": "#27785a",
"warning": "#9a671f",
"danger": "#b33d4c",
"terminalBackground": "#fffaf6",
"terminalForeground": "#3f3033"
},
"platforms": ["macos", "windows"],
"author": { "name": "Theme author" },
"homepage": "https://codexthemes.ai",
"skillUrl": "https://codexthemes.ai/SKILL.md"
}
```
Omit `art` for `palette-only`. Paths must be relative and remain inside the theme directory. Increment `version` after every visible change.
`preview` names a raster image (PNG, JPEG, or WebP) that shows the **themed workspace** — sidebar, header, and home content together — never the raw background artwork. It becomes the gallery and detail image on codexthemes.ai. When `preview` is omitted, export picks the best raster from `previews/` (files containing `1440` first); keep at least one real workspace capture there.
## Design fields
- `layoutMode`: `native-background`, `native-immersive`, `editorial-showcase`, or `palette-only`.
- `backgroundScope`: `home` or `workspace`.
- `modeReason`: why the reference and concept require light or dark.
- `allowedChanges`: exact surfaces or properties the theme may alter.
- `preserve`: native geometry, components, routes, and states that remain invariant.
- `verificationViewports`: at least one desktop and one narrow viewport.
`workspace` means home plus verified conversation shells. It never means settings or every `main` element.
## Portable package
A `.codex-theme` file is UTF-8 JSON, not a ZIP:
```json
{
"format": "codex-theme",
"schemaVersion": 1,
"exportedAt": "ISO-8601 timestamp",
"manifest": {},
"css": "complete CSS source",
"readme": "theme README",
"art": {
"filename": "artwork.png",
"mimeType": "image/png",
"base64": "..."
},
"preview": {
"filename": "home-1440x900.png",
"mimeType": "image/png",
"base64": "..."
},
"verification": {}
}
```
`art` is the theme's background artwork; `preview` is the workspace capture that codexthemes.ai shows in the gallery and on the detail page — the submit API prefers `preview` and only falls back to `art` when no preview is embedded. Normalize CSS to `theme.css` and artwork to a safe basename. Remove absolute paths and private evidence. Reject packages over 30 MB, path traversal, executable content, external CSS resources, scripts, and unsupported asset MIME types.
scripts/apply-theme.ts
#!/usr/bin/env -S npx tsx
import { closeSync, openSync } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execFile, spawn } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { validateTheme } from './validate-theme.ts';
import { runtimeStatePath, stateRoot } from './paths.ts';
type Command = 'apply' | 'restore' | 'status';
interface Options {
command: Command;
themeDir?: string;
port: number;
launch: boolean;
/** Force a clean quit+relaunch even when a debugging endpoint is already live. */
relaunch: boolean;
app?: string;
/** Internal: run the quit/relaunch/inject sequence inline (used by the detached helper). */
worker: boolean;
}
interface Target {
id: string;
title: string;
type: string;
url: string;
webSocketDebuggerUrl: string;
}
interface Registration {
targetId: string;
identifier: string;
}
interface RuntimeState {
port: number;
themeId: string;
registrations?: Registration[];
/** Legacy single-target fields kept for compatibility with older applies. */
targetId?: string;
scriptIdentifier?: string;
}
const execFileAsync = promisify(execFile);
const statePath = runtimeStatePath();
const legacyStatePath = path.join(os.homedir(), '.codexthemes', 'runtime.json');
const defaultPorts = [9335, 9222, 9223];
function parseArgs(argv: string[]): Options {
const command = argv.shift() as Command | undefined;
if (!command || !['apply', 'restore', 'status'].includes(command)) {
throw new Error('Usage: apply-theme.ts <apply THEME_DIR|restore|status> [--port 9335] [--launch] [--relaunch] [--app /Applications/Codex.app]');
}
const options: Options = { command, port: 9335, launch: false, relaunch: false, worker: false };
if (command === 'apply' && argv[0] && !argv[0].startsWith('--')) {
options.themeDir = path.resolve(argv.shift()!);
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--port') {
const port = Number(argv[++index]);
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('--port must be between 1024 and 65535');
options.port = port;
} else if (arg === '--launch') {
options.launch = true;
} else if (arg === '--relaunch') {
options.relaunch = true;
} else if (arg === '--launch-worker') {
options.worker = true;
} else if (arg === '--app') {
const app = argv[++index];
if (!app) throw new Error('--app requires a path');
options.app = path.resolve(app);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (command === 'apply' && !options.themeDir) throw new Error('apply requires an absolute theme directory');
return options;
}
async function readJson<T>(filename: string): Promise<T | undefined> {
try {
return JSON.parse(await fs.readFile(filename, 'utf8')) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
async function writeState(state: RuntimeState): Promise<void> {
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
}
async function clearState(): Promise<void> {
await fs.rm(statePath, { force: true });
await fs.rm(legacyStatePath, { force: true });
}
async function readRuntimeState(): Promise<RuntimeState | undefined> {
return (await readJson<RuntimeState>(statePath)) ?? (await readJson<RuntimeState>(legacyStatePath));
}
async function targetsAt(port: number): Promise<Target[]> {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(800) });
if (!response.ok) return [];
return (await response.json()) as Target[];
} catch {
return [];
}
}
function appPages(targets: Target[]): Target[] {
return targets.filter((target) => target.type === 'page' && target.url.startsWith('app://'));
}
async function locateTargets(preferredPort: number): Promise<{ port: number; targets: Target[] } | undefined> {
for (const port of [...new Set([preferredPort, ...defaultPorts])]) {
const pages = appPages(await targetsAt(port));
if (pages.length > 0) return { port, targets: pages };
}
return undefined;
}
function stateRegistrations(state: RuntimeState | undefined): Registration[] {
if (!state) return [];
if (state.registrations?.length) return state.registrations;
if (state.targetId && state.scriptIdentifier) return [{ targetId: state.targetId, identifier: state.scriptIdentifier }];
return [];
}
async function detectMacApp(explicit?: string): Promise<string> {
const candidates = explicit ? [explicit] : ['/Applications/Codex.app', '/Applications/ChatGPT.app'];
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch { /* try the next candidate */ }
}
throw new Error('Cannot find Codex.app or ChatGPT.app; pass --app with the application path');
}
async function detectWindowsApp(explicit?: string): Promise<string> {
if (explicit) {
await fs.access(explicit);
return explicit;
}
// Prefer the path of a running instance — it works for every install kind.
for (const name of ['Codex', 'ChatGPT']) {
try {
const { stdout } = await execFileAsync('powershell', [
'-NoProfile', '-Command',
`(Get-Process -Name ${name} -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1).Path`,
]);
const found = stdout.trim();
if (found) return found;
} catch { /* try the next name */ }
}
const local = process.env.LOCALAPPDATA;
const candidates = local ? [
path.join(local, 'Programs', 'Codex', 'Codex.exe'),
path.join(local, 'Programs', 'ChatGPT', 'ChatGPT.exe'),
path.join(local, 'Microsoft', 'WindowsApps', 'Codex.exe'),
path.join(local, 'Microsoft', 'WindowsApps', 'ChatGPT.exe'),
] : [];
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch { /* try the next candidate */ }
}
throw new Error('Cannot find the Codex/ChatGPT executable; pass --app with the full path to the .exe');
}
async function mainProcessRunning(app: string): Promise<boolean> {
if (process.platform === 'win32') {
const image = path.basename(app);
try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${image}`]);
return stdout.toLowerCase().includes(image.toLowerCase());
} catch {
return false;
}
}
try {
await execFileAsync('pgrep', ['-f', `${app}/Contents/MacOS/`]);
return true;
} catch {
return false;
}
}
async function quitAndWait(app: string): Promise<void> {
const appName = path.basename(app, process.platform === 'win32' ? '.exe' : '.app');
if (process.platform === 'win32') {
// Graceful close (no /F): the app must exit cleanly, not be killed.
await execFileAsync('taskkill', ['/IM', path.basename(app)]).catch(() => undefined);
} else {
await execFileAsync('osascript', ['-e', `tell application ${JSON.stringify(appName)} to quit`]).catch(() => undefined);
}
// The debugging flags only take effect on a fresh instance. If the old
// instance is still shutting down it holds the Chromium profile singleton
// lock, and the relaunched instance silently defers to it and exits, so the
// app comes back WITHOUT the debugging endpoint. Wait for a real exit.
const deadline = Date.now() + 45_000;
while (Date.now() < deadline) {
if (!(await mainProcessRunning(app))) return;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`${appName} did not quit within 45s. Quit it manually, then rerun apply with --launch.`);
}
const debugFlags = (port: number) => ['--remote-debugging-address=127.0.0.1', `--remote-debugging-port=${port}`];
async function launchWithDebugging(port: number, explicitApp?: string): Promise<string> {
if (process.platform === 'darwin') {
const app = await detectMacApp(explicitApp);
await quitAndWait(app);
const child = spawn('open', ['-na', app, '--args', ...debugFlags(port)], { detached: true, stdio: 'ignore' });
child.unref();
return app;
}
if (process.platform === 'win32') {
const app = await detectWindowsApp(explicitApp);
await quitAndWait(app);
const child = spawn(app, debugFlags(port), { detached: true, stdio: 'ignore' });
child.unref();
return app;
}
throw new Error('--launch supports macOS and Windows. Start Codex with --remote-debugging-address=127.0.0.1 and --remote-debugging-port manually, then rerun without --launch.');
}
async function mainProcessCommandLine(app: string): Promise<string> {
try {
if (process.platform === 'win32') {
const image = path.basename(app);
const { stdout } = await execFileAsync('powershell', [
'-NoProfile', '-Command',
`(Get-CimInstance Win32_Process -Filter "Name='${image}'" | Select-Object -First 1).CommandLine`,
]);
return stdout.trim();
}
const { stdout } = await execFileAsync('pgrep', ['-fl', `${app}/Contents/MacOS/`]);
return stdout.split('\n')[0] ?? '';
} catch {
return '';
}
}
/** Turn a silent endpoint timeout into a definite, actionable diagnosis. */
async function launchDiagnostics(app: string): Promise<string> {
if (!(await mainProcessRunning(app))) {
return 'Diagnosis: the app is NOT running after the relaunch — launching it failed. Verify the application path (pass --app explicitly) and launch it manually once to check it starts at all.';
}
const commandLine = await mainProcessCommandLine(app);
if (commandLine.includes('--remote-debugging-port')) {
return 'Diagnosis: the app is running WITH the debugging flags on its command line but never opened the endpoint — this build ignores or strips --remote-debugging-port (common for store-packaged builds). This installation cannot be themed; report that limitation plainly instead of retrying.';
}
return 'Diagnosis: the app is running WITHOUT the debugging flags — an old instance held the profile singleton lock during the relaunch and the flagged instance deferred to it and exited. Retry with: apply ... --launch --relaunch';
}
async function waitForTargetsOrDiagnose(app: string, port: number): Promise<{ port: number; targets: Target[] }> {
try {
return await waitForTargets(port);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`${message}\n${await launchDiagnostics(app)}`);
}
}
async function waitForTargets(port: number, timeoutMs = 30_000): Promise<{ port: number; targets: Target[] }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const found = await locateTargets(port);
if (found) return found;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Codex did not expose a debuggable app page on 127.0.0.1:${port}. ` +
'If the app is running, either an old instance was still holding the profile lock during relaunch, or this build ignores --remote-debugging-port.',
);
}
class CdpClient {
private nextId = 1;
private pending = new Map<number, { resolve: (value: any) => void; reject: (reason: Error) => void }>();
private constructor(private socket: WebSocket) {
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (!message.id) return;
const waiter = this.pending.get(message.id);
if (!waiter) return;
this.pending.delete(message.id);
if (message.error) waiter.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
else waiter.resolve(message.result ?? {});
};
socket.onerror = () => {
for (const waiter of this.pending.values()) waiter.reject(new Error('CDP WebSocket failed'));
this.pending.clear();
};
}
static async connect(url: string): Promise<CdpClient> {
const socket = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error('Cannot connect to the Codex renderer'));
});
return new CdpClient(socket);
}
call(method: string, params: Record<string, unknown> = {}): Promise<any> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.send(JSON.stringify({ id, method, params }));
});
}
close(): void {
this.socket.close();
}
}
function mimeType(filename: string): string {
const extension = path.extname(filename).toLowerCase();
if (extension === '.png') return 'image/png';
if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg';
if (extension === '.webp') return 'image/webp';
if (extension === '.svg') return 'image/svg+xml';
if (extension === '.woff2') return 'font/woff2';
throw new Error(`Unsupported local CSS asset: ${extension || filename}`);
}
export async function inlineLocalAssets(css: string, themeDir: string): Promise<string> {
const matches = [...css.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/g)];
let output = css;
for (const match of matches) {
const reference = match[2]!.trim();
if (/^(?:data:|blob:|#)/i.test(reference)) continue;
if (/^(?:https?:|\/\/|file:)/i.test(reference)) throw new Error(`External CSS asset is not allowed: ${reference}`);
const filename = path.resolve(themeDir, reference);
if (!filename.startsWith(`${themeDir}${path.sep}`)) throw new Error(`CSS asset escapes the theme directory: ${reference}`);
const data = await fs.readFile(filename);
const dataUrl = `data:${mimeType(filename)};base64,${data.toString('base64')}`;
output = output.replaceAll(match[0], `url("${dataUrl}")`);
}
return output;
}
export function runtimeSource(themeId: string, backgroundScope: string, css: string): string {
const config = JSON.stringify({ themeId, backgroundScope, css });
return `(() => {
const config = ${config};
const key = '__codexThemesRuntime';
const styleId = 'codexthemes-runtime-style';
const clear = () => {
const previous = globalThis[key];
if (previous?.observer) previous.observer.disconnect();
if (previous?.frame) cancelAnimationFrame(previous.frame);
document.getElementById(styleId)?.remove();
delete document.documentElement.dataset.codexthemesTheme;
delete document.documentElement.dataset.codexthemesBackgroundScope;
document.querySelectorAll('[data-codexthemes-page]').forEach((node) => node.removeAttribute('data-codexthemes-page'));
};
const install = () => {
clear();
const root = document.documentElement;
root.dataset.codexthemesTheme = config.themeId;
root.dataset.codexthemesBackgroundScope = config.backgroundScope;
const style = document.createElement('style');
style.id = styleId;
style.dataset.codexthemesOwned = 'true';
style.textContent = config.css;
(document.head || root).appendChild(style);
const classify = () => {
const main = document.querySelector('main.main-surface');
document.querySelectorAll('main[data-codexthemes-page]').forEach((node) => {
if (node !== main) node.removeAttribute('data-codexthemes-page');
});
if (!main) return;
const hasConversation = Boolean(main.querySelector('[data-thread-user-message-navigation-item-id]'));
const hasComposer = Boolean(main.querySelector('[data-composer-navigation-target]'));
main.dataset.codexthemesPage = hasConversation ? 'conversation' : hasComposer ? 'home' : 'system';
};
const state = { observer: undefined, frame: 0 };
const schedule = () => {
if (state.frame) return;
state.frame = requestAnimationFrame(() => { state.frame = 0; classify(); });
};
state.observer = new MutationObserver(schedule);
state.observer.observe(document.documentElement, { childList: true, subtree: true });
globalThis[key] = state;
classify();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install, { once: true });
else install();
})();`;
}
const restoreSource = `(() => {
const state = globalThis.__codexThemesRuntime;
if (state?.observer) state.observer.disconnect();
if (state?.frame) cancelAnimationFrame(state.frame);
delete globalThis.__codexThemesRuntime;
document.getElementById('codexthemes-runtime-style')?.remove();
delete document.documentElement.dataset.codexthemesTheme;
delete document.documentElement.dataset.codexthemesBackgroundScope;
document.querySelectorAll('[data-codexthemes-page]').forEach((node) => node.removeAttribute('data-codexthemes-page'));
})()`;
async function connect(options: Options): Promise<{ port: number; targets: Target[] } | undefined> {
let found = await locateTargets(options.port);
if (!found && options.launch) {
const app = await launchWithDebugging(options.port, options.app);
found = await waitForTargetsOrDiagnose(app, options.port);
}
return found;
}
async function injectIntoTarget(target: Target, previous: Registration[], source: string): Promise<Registration> {
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
try {
for (const registration of previous) {
if (registration.targetId === target.id) {
await client.call('Page.removeScriptToEvaluateOnNewDocument', { identifier: registration.identifier }).catch(() => undefined);
}
}
await client.call('Page.enable');
const registration = await client.call('Page.addScriptToEvaluateOnNewDocument', { source });
await client.call('Runtime.evaluate', { expression: source, awaitPromise: true });
return { targetId: target.id, identifier: registration.identifier };
} finally {
client.close();
}
}
export function launchLogPath(): string {
return path.join(stateRoot(), 'launch.log');
}
/**
* Run the quit → relaunch → inject sequence in a detached helper process.
* When the applying agent is hosted inside Codex itself, quitting Codex kills
* the agent's tool call (and any process in its group) before the relaunch
* can happen. The helper starts its own session, so it survives the restart
* and finishes the injection on its own; its output lands in launch.log.
*/
async function scheduleDetachedLaunch(options: Options): Promise<void> {
await fs.mkdir(stateRoot(), { recursive: true });
const logPath = launchLogPath();
const logFd = openSync(logPath, 'w');
const args = [
...process.execArgv,
process.argv[1]!,
'apply', options.themeDir!,
'--launch', '--launch-worker',
...(options.relaunch ? ['--relaunch'] : []),
'--port', String(options.port),
...(options.app ? ['--app', options.app] : []),
];
const child = spawn(process.execPath, args, { detached: true, stdio: ['ignore', logFd, logFd] });
child.unref();
closeSync(logFd);
console.log(JSON.stringify({
status: 'scheduled',
note: 'Codex is restarting with the debugging endpoint; a detached helper that survives the restart will inject the theme. This tool call may be interrupted by the restart. Afterwards verify with: apply-theme.ts status',
logPath,
}, null, 2));
}
async function apply(options: Options): Promise<void> {
const themeDir = options.themeDir!;
const validation = await validateTheme(themeDir);
if (!validation.valid) throw new Error(`Theme validation failed:\n${validation.errors.join('\n')}`);
const manifest = JSON.parse(await fs.readFile(path.join(themeDir, 'theme.json'), 'utf8')) as {
id: string; css: string; design: { backgroundScope: string };
};
const css = await inlineLocalAssets(await fs.readFile(path.join(themeDir, manifest.css), 'utf8'), themeDir);
// --relaunch forces a clean quit+relaunch even when an endpoint is live: it
// evicts stale sessions from earlier tasks whose on-new-document
// registrations keep re-injecting an old theme that a hot swap cannot
// remove (their identifiers belong to other CDP sessions).
const live = await locateTargets(options.port);
if (options.launch && !options.worker && (!live || options.relaunch)) {
await scheduleDetachedLaunch(options);
return;
}
if (options.relaunch && !options.launch) {
throw new Error('--relaunch also requires --launch (and the user\'s explicit restart permission).');
}
const connection = options.worker && options.relaunch
? await (async () => {
const app = await launchWithDebugging(options.port, options.app);
return waitForTargetsOrDiagnose(app, options.port);
})()
: await connect(options);
if (!connection) {
throw new Error('No debuggable Codex renderer found. With explicit restart permission, rerun with --launch; no external theme program is required.');
}
const source = runtimeSource(manifest.id, manifest.design.backgroundScope, css);
const previous = stateRegistrations(await readRuntimeState());
const registrations: Registration[] = [];
// Theme every app page, not just the first one /json/list happens to return;
// a single-window assumption previously themed one window while status
// probed another, producing conflicting evidence.
for (const target of connection.targets) {
registrations.push(await injectIntoTarget(target, previous, source));
}
await writeState({
port: connection.port,
themeId: manifest.id,
registrations,
...(registrations[0] ? { targetId: registrations[0].targetId, scriptIdentifier: registrations[0].identifier } : {}),
});
console.log(JSON.stringify({
status: 'active',
themeId: manifest.id,
port: connection.port,
pagesThemed: registrations.length,
warnings: validation.warnings,
}, null, 2));
}
async function restore(options: Options): Promise<void> {
const state = await readRuntimeState();
const connection = await locateTargets(state?.port ?? options.port);
if (!connection) {
await clearState();
console.log(JSON.stringify({ status: 'inactive', note: 'Codex was not running; local runtime state was cleared.' }, null, 2));
return;
}
const previous = stateRegistrations(state);
for (const target of connection.targets) {
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
try {
for (const registration of previous) {
if (registration.targetId === target.id) {
await client.call('Page.removeScriptToEvaluateOnNewDocument', { identifier: registration.identifier }).catch(() => undefined);
}
}
await client.call('Runtime.evaluate', { expression: restoreSource, awaitPromise: true });
} finally {
client.close();
}
}
await clearState();
console.log(JSON.stringify({ status: 'inactive', restoredThemeId: state?.themeId, pagesRestored: connection.targets.length }, null, 2));
}
async function status(options: Options): Promise<void> {
const state = await readRuntimeState();
const found = await locateTargets(state?.port ?? options.port);
const pages: Array<{ targetId: string; themeId: string | null }> = [];
if (found) {
for (const target of found.targets) {
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
try {
const evaluated = await client.call('Runtime.evaluate', {
expression: "document.documentElement.dataset.codexthemesTheme ?? ''",
returnByValue: true,
});
pages.push({ targetId: target.id, themeId: evaluated.result?.value || null });
} finally {
client.close();
}
}
}
const themed = pages.filter((page) => page.themeId);
const themeIds = [...new Set(themed.map((page) => page.themeId))];
console.log(JSON.stringify({
status: themed.length === pages.length && pages.length > 0 && themeIds.length === 1 ? 'active' : themed.length > 0 ? 'partial' : 'inactive',
themeId: themeIds.length === 1 ? themeIds[0] : themeIds.length > 1 ? themeIds : null,
pages,
debugEndpoint: found ? `127.0.0.1:${found.port}` : null,
recordedState: state ?? null,
launchLog: launchLogPath(),
}, null, 2));
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
if (options.command === 'apply') await apply(options);
else if (options.command === 'restore') await restore(options);
else await status(options);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
scripts/export-theme.ts
#!/usr/bin/env -S npx tsx
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { exportsRoot } from './paths.ts';
import { validateTheme } from './validate-theme.ts';
interface ExportOptions {
themeDir: string;
outputDir: string;
}
interface ThemeManifest {
id: string;
version: string;
css: string;
art?: string;
preview?: string;
[key: string]: unknown;
}
interface EmbeddedAsset {
filename: string;
mimeType: string;
base64: string;
}
const maxPackageBytes = 30 * 1024 * 1024;
function parseArgs(argv: string[]): ExportOptions {
const themeArg = argv.shift();
if (!themeArg) throw new Error('Usage: export-theme.ts /absolute/theme-directory [--output /absolute/export-directory]');
const options: ExportOptions = {
themeDir: path.resolve(themeArg),
outputDir: exportsRoot(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg !== '--output') throw new Error(`Unknown argument: ${arg}`);
const value = argv[++index];
if (!value) throw new Error('--output requires a directory');
options.outputDir = path.resolve(value);
}
return options;
}
function assetMimeType(filename: string): string {
const extension = path.extname(filename).toLowerCase();
if (extension === '.png') return 'image/png';
if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg';
if (extension === '.webp') return 'image/webp';
throw new Error(`Unsupported artwork type: ${extension || filename}`);
}
async function readOptionalText(filename: string): Promise<string> {
try {
return await fs.readFile(filename, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
throw error;
}
}
async function readOptionalJson(filename: string): Promise<Record<string, unknown>> {
const source = await readOptionalText(filename);
return source ? JSON.parse(source) as Record<string, unknown> : {};
}
async function readEmbeddedAsset(assetPath: string): Promise<EmbeddedAsset> {
return {
filename: path.basename(assetPath),
mimeType: assetMimeType(assetPath),
base64: (await fs.readFile(assetPath)).toString('base64'),
};
}
/**
* The package preview is the gallery/detail image on codexthemes.ai — it must
* show the themed workspace (sidebar, header, home content), never the raw
* artwork. Resolution order: manifest.preview, then the largest-looking raster
* in previews/ (files containing "1440" first).
*/
async function resolvePreviewPath(themeDir: string, manifest: ThemeManifest): Promise<string | null> {
if (manifest.preview) {
const previewPath = path.resolve(themeDir, manifest.preview);
if (!previewPath.startsWith(`${themeDir}${path.sep}`)) throw new Error('Preview path escapes the theme directory');
await fs.access(previewPath);
return previewPath;
}
let entries: string[];
try {
entries = await fs.readdir(path.join(themeDir, 'previews'));
} catch {
return null;
}
const rasters = entries.filter((name) => /\.(png|jpe?g|webp)$/i.test(name)).sort();
if (rasters.length === 0) return null;
const preferred = rasters.find((name) => name.includes('1440')) ?? rasters[0]!;
return path.join(themeDir, 'previews', preferred);
}
export async function exportTheme(themeDirectory: string, outputDirectory = exportsRoot()): Promise<string> {
const themeDir = path.resolve(themeDirectory);
const validation = await validateTheme(themeDir);
if (!validation.valid) throw new Error(`Theme validation failed:\n${validation.errors.join('\n')}`);
const manifest = JSON.parse(await fs.readFile(path.join(themeDir, 'theme.json'), 'utf8')) as ThemeManifest;
const css = await fs.readFile(path.join(themeDir, manifest.css), 'utf8');
const readme = await readOptionalText(path.join(themeDir, 'README.md'));
const verification = await readOptionalJson(path.join(themeDir, 'state', 'verification.json'));
let art: EmbeddedAsset | undefined;
if (manifest.art) {
const artPath = path.resolve(themeDir, manifest.art);
if (!artPath.startsWith(`${themeDir}${path.sep}`)) throw new Error('Artwork path escapes the theme directory');
art = await readEmbeddedAsset(artPath);
}
let preview: EmbeddedAsset | undefined;
const previewPath = await resolvePreviewPath(themeDir, manifest);
if (previewPath) preview = await readEmbeddedAsset(previewPath);
const portable = {
format: 'codex-theme',
schemaVersion: 1,
exportedAt: new Date().toISOString(),
manifest,
css,
readme,
...(art ? { art } : {}),
...(preview ? { preview } : {}),
verification,
};
const body = `${JSON.stringify(portable, null, 2)}\n`;
if (Buffer.byteLength(body) > maxPackageBytes) throw new Error('Portable theme package exceeds 30 MB');
const outputDir = path.resolve(outputDirectory);
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, `${manifest.id}.codex-theme`);
await fs.writeFile(outputPath, body, 'utf8');
return outputPath;
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const outputPath = await exportTheme(options.themeDir, options.outputDir);
console.log(JSON.stringify({ status: 'exported', outputPath }, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
scripts/paths.ts
import os from 'node:os';
import path from 'node:path';
export function codexThemesHome(): string {
const configured = process.env.CODEX_THEMES_HOME?.trim();
return path.resolve(configured || path.join(os.homedir(), '.codexthemes'));
}
export function themesRoot(): string {
return path.join(codexThemesHome(), 'themes');
}
export function exportsRoot(): string {
return path.join(codexThemesHome(), 'exports');
}
export function stateRoot(): string {
return path.join(codexThemesHome(), 'state');
}
export function runtimeStatePath(): string {
return path.join(stateRoot(), 'runtime.json');
}
scripts/qa-contrast.ts
#!/usr/bin/env -S npx tsx
import { pathToFileURL } from 'node:url';
import {
decodePng,
judgeSamples,
textSamplerSource,
type ReadabilityResult,
type TextSample,
} from './readability.ts';
interface Target {
id: string;
title: string;
type: string;
url: string;
webSocketDebuggerUrl: string;
}
interface RawSample {
text: string;
path: string;
color: string;
stack: string[];
image: boolean;
size: number;
}
export interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
export interface ContrastSample {
text: string;
path: string;
ratio: number;
color: string;
background: string;
/** True when the backdrop is a verified opaque solid color (no artwork, no transparency gap). */
verified: boolean;
size: number;
}
const defaultPorts = [9335, 9222, 9223];
// Below ~2.5:1 text is effectively unreadable regardless of taste; 4.5:1 is
// the WCAG AA floor for normal text. Elements over artwork only hard-fail.
const failRatio = 2.5;
const warnRatio = 4.5;
export function parseColor(value: string): Rgba | null {
const match = value.match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const parts = match[1]!.split(',').map((part) => Number(part.trim()));
if (parts.length < 3 || parts.some((part) => Number.isNaN(part))) return null;
return { r: parts[0]!, g: parts[1]!, b: parts[2]!, a: parts.length > 3 ? parts[3]! : 1 };
}
export function blend(top: Rgba, bottom: Rgba): Rgba {
const alpha = top.a + bottom.a * (1 - top.a);
if (alpha === 0) return { r: 255, g: 255, b: 255, a: 0 };
return {
r: (top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / alpha,
g: (top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / alpha,
b: (top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / alpha,
a: alpha,
};
}
function luminance(color: Rgba): number {
const channel = (value: number) => {
const scaled = value / 255;
return scaled <= 0.03928 ? scaled / 12.92 : Math.pow((scaled + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
}
export function contrastRatio(a: Rgba, b: Rgba): number {
const l1 = luminance(a);
const l2 = luminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/**
* Composite the ancestor background stack (innermost first). Starts from a
* fully transparent base: if the composited result is not opaque, the real
* backdrop is unknown (it is painted by pseudo-elements, siblings, or
* artwork), and the sample must not be hard-judged.
*/
export function effectiveBackground(stack: string[]): Rgba {
let background: Rgba = { r: 255, g: 255, b: 255, a: 0 };
for (let index = stack.length - 1; index >= 0; index -= 1) {
const layer = parseColor(stack[index]!);
if (layer && layer.a > 0) background = blend(layer, background);
}
return background;
}
export function evaluateSamples(raw: RawSample[]): ContrastSample[] {
const results: ContrastSample[] = [];
for (const sample of raw) {
const textColor = parseColor(sample.color);
if (!textColor) continue;
const background = effectiveBackground(sample.stack);
const verified = background.a >= 0.99 && !sample.image;
const opaque = background.a >= 0.99 ? background : blend(background, { r: 255, g: 255, b: 255, a: 1 });
const composedText = textColor.a < 1 ? blend(textColor, opaque) : textColor;
results.push({
text: sample.text,
path: sample.path,
ratio: Math.round(contrastRatio(composedText, opaque) * 100) / 100,
color: sample.color,
background: `rgb(${Math.round(opaque.r)}, ${Math.round(opaque.g)}, ${Math.round(opaque.b)})`,
verified,
size: sample.size,
});
}
return results.sort((a, b) => a.ratio - b.ratio);
}
const samplerSource = `(() => {
const isVisible = (el) => {
const rect = el.getBoundingClientRect();
if (rect.width < 5 || rect.height < 5) return false;
if (rect.bottom < 0 || rect.top > innerHeight) return false;
const style = getComputedStyle(el);
return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.15;
};
const hasOwnText = (el) => [...el.childNodes].some((node) => node.nodeType === 3 && node.textContent.trim().length > 1);
const describe = (el) => {
const parts = [];
let node = el;
for (let depth = 0; node && depth < 3; depth += 1) {
let part = node.tagName.toLowerCase();
const cls = [...node.classList].slice(0, 2).join('.');
if (cls) part += '.' + cls;
parts.unshift(part);
node = node.parentElement;
}
return parts.join(' > ');
};
const samples = [];
const elements = document.querySelectorAll('main *, aside *, header *, [role="menu"] *, [role="dialog"] *');
for (const el of elements) {
if (samples.length >= 500) break;
if (!hasOwnText(el) || !isVisible(el)) continue;
const style = getComputedStyle(el);
const stack = [];
let image = false;
let node = el;
while (node) {
const nodeStyle = node === el ? style : getComputedStyle(node);
if (nodeStyle.backgroundImage && nodeStyle.backgroundImage !== 'none') image = true;
for (const pseudo of ['::before', '::after']) {
const pseudoStyle = getComputedStyle(node, pseudo);
if (pseudoStyle.content !== 'none' &&
((pseudoStyle.backgroundImage && pseudoStyle.backgroundImage !== 'none') ||
(pseudoStyle.backgroundColor && pseudoStyle.backgroundColor !== 'transparent' && pseudoStyle.backgroundColor !== 'rgba(0, 0, 0, 0)'))) {
image = true;
}
}
const bg = nodeStyle.backgroundColor;
if (bg && bg !== 'transparent') {
stack.push(bg);
const alphaMatch = bg.match(/rgba\\([^)]*,\\s*([0-9.]+)\\)/);
if (!alphaMatch || Number(alphaMatch[1]) >= 0.99) break;
}
node = node.parentElement;
}
samples.push({
text: [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(' ').slice(0, 40),
path: describe(el),
color: style.color,
stack,
image,
size: parseFloat(style.fontSize) || 0,
});
}
return JSON.stringify(samples);
})()`;
async function targetsAt(port: number): Promise<Target[]> {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(800) });
if (!response.ok) return [];
return (await response.json()) as Target[];
} catch {
return [];
}
}
async function locateTargets(preferredPort: number): Promise<{ port: number; targets: Target[] } | undefined> {
for (const port of [...new Set([preferredPort, ...defaultPorts])]) {
const pages = (await targetsAt(port)).filter((target) => target.type === 'page' && target.url.startsWith('app://'));
if (pages.length > 0) return { port, targets: pages };
}
return undefined;
}
class CdpSocket {
private nextId = 1;
private constructor(private socket: WebSocket, private pending = new Map<number, { resolve: (value: any) => void; reject: (reason: Error) => void }>()) {
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (!message.id) return;
const waiter = this.pending.get(message.id);
if (!waiter) return;
this.pending.delete(message.id);
if (message.error) waiter.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
else waiter.resolve(message.result ?? {});
};
}
static async connect(url: string): Promise<CdpSocket> {
const socket = new WebSocket(url);
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve();
socket.onerror = () => reject(new Error('Cannot connect to the Codex renderer'));
});
return new CdpSocket(socket);
}
call(method: string, params: Record<string, unknown> = {}): Promise<any> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.send(JSON.stringify({ id, method, params }));
});
}
close(): void {
this.socket.close();
}
}
async function sampleTarget(target: Target): Promise<{ raw: RawSample[]; pixel: ReadabilityResult[] }> {
const client = await CdpSocket.connect(target.webSocketDebuggerUrl);
try {
const styled = await client.call('Runtime.evaluate', { expression: samplerSource, returnByValue: true });
const raw = JSON.parse(styled.result.value) as RawSample[];
// Pixel pass: screenshot-based measurement judges text over artwork and
// transparent layers that computed-style composition cannot verify.
let pixel: ReadabilityResult[] = [];
try {
const sampled = await client.call('Runtime.evaluate', { expression: textSamplerSource, returnByValue: true });
const parsed = JSON.parse(sampled.result.value) as { samples: TextSample[]; viewportWidth: number };
const shot = await client.call('Page.captureScreenshot', { format: 'png' });
const image = decodePng(Buffer.from(shot.data, 'base64'));
pixel = judgeSamples(image, parsed.samples.filter((sample) => sample.size >= 9), parsed.viewportWidth);
} catch { /* pixel pass is best-effort; the style pass still runs */ }
return { raw, pixel };
} finally {
client.close();
}
}
async function main(): Promise<void> {
let port = 9335;
const argv = process.argv.slice(2);
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--port') port = Number(argv[++index]);
else throw new Error(`Unknown argument: ${argv[index]}`);
}
const found = await locateTargets(port);
if (!found) {
console.error('No debuggable Codex renderer found; apply the theme first, then rerun.');
process.exitCode = 2;
return;
}
const pages: Array<{
targetId: string;
sampled: number;
failures: ContrastSample[];
warnings: ContrastSample[];
pixelFailures: ReadabilityResult[];
}> = [];
for (const target of found.targets) {
const { raw, pixel } = await sampleTarget(target);
const samples = evaluateSamples(raw);
const verified = samples.filter((sample) => sample.verified);
pages.push({
targetId: target.id,
sampled: samples.length,
failures: verified.filter((sample) => sample.ratio < failRatio),
warnings: verified.filter((sample) => sample.ratio >= failRatio && sample.ratio < warnRatio),
pixelFailures: pixel.filter((result) => result.ratio < failRatio),
});
}
const failed = pages.some((page) => page.failures.length > 0 || page.pixelFailures.length >= 2);
console.log(JSON.stringify({
status: failed ? 'fail' : 'pass',
thresholds: { fail: `< ${failRatio}:1`, warn: `< ${warnRatio}:1` },
note: 'failures come from computed-style composition over verified opaque backdrops; pixelFailures come from the real screenshot pixels and also catch text over artwork.',
pages: pages.map((page) => ({
targetId: page.targetId,
sampled: page.sampled,
failureCount: page.failures.length,
failures: page.failures.slice(0, 12),
warningCount: page.warnings.length,
warnings: page.warnings.slice(0, 8),
pixelFailureCount: page.pixelFailures.length,
pixelFailures: page.pixelFailures.slice(0, 12),
})),
}, null, 2));
if (failed) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
});
}
scripts/readability.ts
import zlib from 'node:zlib';
/**
* Pixel-truth readability checks. Computed-style analysis cannot judge text
* that sits over artwork or transparent layers, so we screenshot the real
* page and measure the actual backdrop pixels around each text element.
*/
export interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
export interface DecodedImage {
width: number;
height: number;
/** RGBA, 4 bytes per pixel. */
pixels: Uint8Array;
}
export interface TextSample {
text: string;
path: string;
color: string;
size: number;
rect: { x: number; y: number; width: number; height: number };
}
export interface ReadabilityResult {
text: string;
path: string;
ratio: number;
color: string;
backdrop: string;
size: number;
}
export function parseColor(value: string): Rgba | null {
const match = value.match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const parts = match[1]!.split(',').map((part) => Number(part.trim()));
if (parts.length < 3 || parts.some((part) => Number.isNaN(part))) return null;
return { r: parts[0]!, g: parts[1]!, b: parts[2]!, a: parts.length > 3 ? parts[3]! : 1 };
}
function luminance(color: Rgba): number {
const channel = (value: number) => {
const scaled = value / 255;
return scaled <= 0.03928 ? scaled / 12.92 : Math.pow((scaled + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
}
export function contrastRatio(a: Rgba, b: Rgba): number {
const l1 = luminance(a);
const l2 = luminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function paeth(a: number, b: number, c: number): number {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
/** Minimal PNG decoder for Chromium screenshots: 8-bit RGB/RGBA, non-interlaced. */
export function decodePng(buffer: Buffer): DecodedImage {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (buffer.length < 8 || signature.some((byte, index) => buffer[index] !== byte)) {
throw new Error('Not a PNG file');
}
let offset = 8;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = 0;
const idat: Buffer[] = [];
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
const data = buffer.subarray(offset + 8, offset + 8 + length);
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8]!;
colorType = data[9]!;
if (data[12] !== 0) throw new Error('Interlaced PNG is not supported');
} else if (type === 'IDAT') {
idat.push(Buffer.from(data));
} else if (type === 'IEND') {
break;
}
offset += 12 + length;
}
if (bitDepth !== 8 || (colorType !== 6 && colorType !== 2)) {
throw new Error(`Unsupported PNG format (bit depth ${bitDepth}, color type ${colorType})`);
}
const bpp = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * bpp;
const pixels = new Uint8Array(width * height * 4);
const previous = new Uint8Array(stride);
const current = new Uint8Array(stride);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (stride + 1);
const filter = raw[rowStart]!;
for (let x = 0; x < stride; x += 1) {
const value = raw[rowStart + 1 + x]!;
const left = x >= bpp ? current[x - bpp]! : 0;
const up = previous[x]!;
const upLeft = x >= bpp ? previous[x - bpp]! : 0;
let decoded = value;
if (filter === 1) decoded = (value + left) & 0xff;
else if (filter === 2) decoded = (value + up) & 0xff;
else if (filter === 3) decoded = (value + Math.floor((left + up) / 2)) & 0xff;
else if (filter === 4) decoded = (value + paeth(left, up, upLeft)) & 0xff;
else if (filter !== 0) throw new Error(`Unsupported PNG filter ${filter}`);
current[x] = decoded;
}
for (let x = 0; x < width; x += 1) {
const source = x * bpp;
const target = (y * width + x) * 4;
pixels[target] = current[source]!;
pixels[target + 1] = current[source + 1]!;
pixels[target + 2] = current[source + 2]!;
pixels[target + 3] = bpp === 4 ? current[source + 3]! : 255;
}
previous.set(current);
}
return { width, height, pixels };
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)] ?? 0;
}
/**
* Median color of a ring of pixels just outside the element's box — an
* approximation of the backdrop the text is read against that is robust to
* glyph pixels inside the box.
*/
export function ringBackdrop(
image: DecodedImage,
rect: { x: number; y: number; width: number; height: number },
scale: number,
pad = 3,
): Rgba | null {
const points: Array<[number, number]> = [];
const left = Math.round((rect.x - pad) * scale);
const right = Math.round((rect.x + rect.width + pad) * scale);
const top = Math.round((rect.y - pad) * scale);
const bottom = Math.round((rect.y + rect.height + pad) * scale);
const stepX = Math.max(1, Math.floor((right - left) / 24));
const stepY = Math.max(1, Math.floor((bottom - top) / 12));
for (let x = left; x <= right; x += stepX) {
points.push([x, top], [x, bottom]);
}
for (let y = top; y <= bottom; y += stepY) {
points.push([left, y], [right, y]);
}
const reds: number[] = [];
const greens: number[] = [];
const blues: number[] = [];
for (const [x, y] of points) {
if (x < 0 || y < 0 || x >= image.width || y >= image.height) continue;
const index = (y * image.width + x) * 4;
reds.push(image.pixels[index]!);
greens.push(image.pixels[index + 1]!);
blues.push(image.pixels[index + 2]!);
}
if (reds.length < 8) return null;
return { r: median(reds), g: median(greens), b: median(blues), a: 1 };
}
/** Judge sampled text elements against the real screenshot pixels. */
export function judgeSamples(
image: DecodedImage,
samples: TextSample[],
viewportWidth: number,
): ReadabilityResult[] {
const scale = image.width / viewportWidth;
const results: ReadabilityResult[] = [];
for (const sample of samples) {
const textColor = parseColor(sample.color);
if (!textColor) continue;
const backdrop = ringBackdrop(image, sample.rect, scale);
if (!backdrop) continue;
const composedText = textColor.a < 1
? {
r: textColor.r * textColor.a + backdrop.r * (1 - textColor.a),
g: textColor.g * textColor.a + backdrop.g * (1 - textColor.a),
b: textColor.b * textColor.a + backdrop.b * (1 - textColor.a),
a: 1,
}
: textColor;
results.push({
text: sample.text,
path: sample.path,
ratio: Math.round(contrastRatio(composedText, backdrop) * 100) / 100,
color: sample.color,
backdrop: `rgb(${backdrop.r}, ${backdrop.g}, ${backdrop.b})`,
size: sample.size,
});
}
return results.sort((a, b) => a.ratio - b.ratio);
}
/** In-page collector: visible text elements with their rects and colors. */
export const textSamplerSource = `(() => {
const isVisible = (el) => {
const rect = el.getBoundingClientRect();
if (rect.width < 5 || rect.height < 5) return false;
if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) return false;
const style = getComputedStyle(el);
return style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.15;
};
const hasOwnText = (el) => [...el.childNodes].some((node) => node.nodeType === 3 && node.textContent.trim().length > 1);
const describe = (el) => {
const parts = [];
let node = el;
for (let depth = 0; node && depth < 3; depth += 1) {
let part = node.tagName.toLowerCase();
const cls = [...node.classList].slice(0, 2).join('.');
if (cls) part += '.' + cls;
parts.unshift(part);
node = node.parentElement;
}
return parts.join(' > ');
};
const samples = [];
const elements = document.querySelectorAll('main *, aside *, header *, [role="menu"] *, [role="dialog"] *');
for (const el of elements) {
if (samples.length >= 200) break;
if (!hasOwnText(el) || !isVisible(el)) continue;
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
samples.push({
text: [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(' ').slice(0, 40),
path: describe(el),
color: style.color,
size: parseFloat(style.fontSize) || 0,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
});
}
return JSON.stringify({ samples, viewportWidth: innerWidth });
})()`;
scripts/scaffold-theme.ts
#!/usr/bin/env -S npx tsx
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { themesRoot } from './paths.ts';
export type LayoutMode =
| 'native-background'
| 'native-immersive'
| 'editorial-showcase'
| 'palette-only';
export type BackgroundScope = 'home' | 'workspace';
export interface ScaffoldOptions {
id: string;
name: string;
layoutMode: LayoutMode;
backgroundScope: BackgroundScope;
output: string;
art?: string;
}
export interface ScaffoldResult {
created: true;
themeDir: string;
}
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const skillDir = path.dirname(scriptDir);
const layoutModes: LayoutMode[] = [
'native-background',
'native-immersive',
'editorial-showcase',
'palette-only',
];
function nextValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value) throw new Error(`${flag} requires a value`);
return value;
}
export function parseScaffoldArgs(argv: string[]): ScaffoldOptions {
const values: Partial<ScaffoldOptions> = {
layoutMode: 'native-background',
backgroundScope: 'home',
output: themesRoot(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--id') values.id = nextValue(argv, index++, arg);
else if (arg === '--name') values.name = nextValue(argv, index++, arg);
else if (arg === '--layout-mode') values.layoutMode = nextValue(argv, index++, arg) as LayoutMode;
else if (arg === '--background-scope') values.backgroundScope = nextValue(argv, index++, arg) as BackgroundScope;
else if (arg === '--output') values.output = path.resolve(nextValue(argv, index++, arg));
else if (arg === '--art') values.art = path.resolve(nextValue(argv, index++, arg));
else throw new Error(`Unknown argument: ${arg}`);
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(values.id ?? '')) throw new Error('--id must be a lowercase safe slug');
if (!layoutModes.includes(values.layoutMode as LayoutMode)) throw new Error('--layout-mode is invalid');
if (!['home', 'workspace'].includes(values.backgroundScope ?? '')) {
throw new Error('--background-scope must be home or workspace');
}
if (values.layoutMode === 'native-background' && !values.art) throw new Error('native-background requires --art');
if (values.layoutMode === 'palette-only' && values.art) throw new Error('palette-only does not accept --art');
return {
id: values.id!,
name: values.name ?? values.id!,
layoutMode: values.layoutMode as LayoutMode,
backgroundScope: values.backgroundScope as BackgroundScope,
output: values.output!,
...(values.art ? { art: values.art } : {}),
};
}
export async function scaffoldTheme(options: ScaffoldOptions): Promise<ScaffoldResult> {
const themeDir = path.join(options.output, options.id);
try {
await fs.access(themeDir);
throw new Error(`Refusing to overwrite ${themeDir}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
await fs.mkdir(path.join(themeDir, 'assets'), { recursive: true });
await fs.mkdir(path.join(themeDir, 'previews'), { recursive: true });
await fs.mkdir(path.join(themeDir, 'state'), { recursive: true });
const templateName = `${options.layoutMode}.css`;
let css = await fs.readFile(path.join(skillDir, 'assets', templateName), 'utf8');
css = css.replaceAll('__THEME_ID__', options.id);
let art: string | undefined;
if (options.art) {
const extension = path.extname(options.art).toLowerCase();
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(extension)) throw new Error('--art must be PNG, JPEG, or WebP');
art = `assets/artwork${extension}`;
await fs.copyFile(options.art, path.join(themeDir, art));
css = css.replaceAll('./assets/artwork.png', `./${art}`);
}
await fs.writeFile(path.join(themeDir, 'theme.css'), css, 'utf8');
const preserve = ['native controls', 'native states'];
if (options.layoutMode !== 'editorial-showcase') preserve.unshift('native geometry');
const allowedChanges: Record<LayoutMode, string[]> = {
'native-background': options.backgroundScope === 'workspace'
? ['home background', 'conversation background', 'bounded reading surfaces']
: ['home background'],
'native-immersive': ['semantic palette', 'native materials', 'scoped artwork', 'decoration'],
'editorial-showcase': ['bounded home hero', 'semantic palette', 'native materials', 'decoration'],
'palette-only': ['semantic palette', 'native materials'],
};
const manifest = {
schemaVersion: 1,
id: options.id,
displayName: options.name,
description: `A Codex desktop theme named ${options.name}.`,
version: '0.1.0',
mode: 'light',
css: 'theme.css',
...(art ? { art } : {}),
design: {
layoutMode: options.layoutMode,
backgroundScope: options.backgroundScope,
modeReason: 'Replace with the explicit reason derived from the brief or reference.',
artFocalPoint: '50% 50%',
textSafeRegion: 'Replace with the verified text-safe region.',
contrastStrategy: 'Replace with the intended veil and bounded-surface strategy.',
allowedChanges: allowedChanges[options.layoutMode],
preserve,
verificationViewports: ['1440x900', '980x760'],
},
palette: {
canvas: '#fffaf6', surface: '#fffdfb', raised: '#ffffff', text: '#3f3033',
muted: '#806d72', accent: '#bd4968', border: '#ead3d9', focus: '#bd4968',
success: '#27785a', warning: '#9a671f', danger: '#b33d4c',
terminalBackground: '#fffaf6', terminalForeground: '#3f3033',
},
platforms: ['macos', 'windows'],
author: { name: 'Theme author' },
homepage: 'https://codexthemes.ai',
skillUrl: 'https://codexthemes.ai/SKILL.md',
};
await fs.writeFile(path.join(themeDir, 'theme.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
await fs.writeFile(
path.join(themeDir, 'README.md'),
`# ${options.name}\n\nCreated with [CodexThemes](https://codexthemes.ai).\n\nAfter real-app verification, export the theme and submit it at https://codexthemes.ai/submit.\n`,
'utf8',
);
await fs.writeFile(
path.join(themeDir, 'state', 'verification.json'),
`${JSON.stringify({ status: 'not-verified' }, null, 2)}\n`,
'utf8',
);
return { created: true, themeDir };
}
async function main(): Promise<void> {
const result = await scaffoldTheme(parseScaffoldArgs(process.argv.slice(2)));
console.log(JSON.stringify(result, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
scripts/self-test.ts
#!/usr/bin/env -S npx tsx
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseScaffoldArgs, scaffoldTheme, type LayoutMode } from './scaffold-theme.ts';
import { inlineLocalAssets, runtimeSource } from './apply-theme.ts';
import { contrastRatio, effectiveBackground, evaluateSamples, parseColor } from './qa-contrast.ts';
import { exportTheme } from './export-theme.ts';
import { exportsRoot, runtimeStatePath, themesRoot } from './paths.ts';
import { validateTheme } from './validate-theme.ts';
import { validateSkill } from './validate-skill.ts';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const skillDir = path.dirname(scriptDir);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexthemes-skill-'));
const artPath = path.join(tempDir, 'art.png');
await fs.writeFile(artPath, Buffer.from('89504e470d0a1a0a', 'hex'));
try {
const skillResult = await validateSkill(skillDir);
assert.equal(skillResult.valid, true, skillResult.errors.join('\n'));
assert.equal(parseScaffoldArgs(['--id', 'default-path', '--name', 'Default path', '--layout-mode', 'palette-only']).output, themesRoot());
assert.match(exportsRoot(), /\.codexthemes\/exports$/);
assert.match(runtimeStatePath(), /\.codexthemes\/state\/runtime\.json$/);
const modes: LayoutMode[] = [
'native-background',
'native-immersive',
'editorial-showcase',
'palette-only',
];
for (const mode of modes) {
const id = `test-${mode}`;
await scaffoldTheme({
id,
name: `Test ${mode}`,
layoutMode: mode,
backgroundScope: 'home',
output: tempDir,
...(mode === 'palette-only' ? {} : { art: artPath }),
});
const result = await validateTheme(path.join(tempDir, id));
assert.equal(result.valid, true, JSON.stringify(result, null, 2));
}
const nativeDir = path.join(tempDir, 'test-native-background');
const inlined = await inlineLocalAssets(await fs.readFile(path.join(nativeDir, 'theme.css'), 'utf8'), nativeDir);
assert.match(inlined, /data:image\/png;base64,/);
assert.doesNotMatch(inlined, /dream-home|dream-conversation/i);
const runtime = runtimeSource('test-native-background', 'home', inlined);
assert.match(runtime, /data-thread-user-message-navigation-item-id/);
assert.match(runtime, /data-composer-navigation-target/);
assert.match(runtime, /codexthemes-runtime-style/);
const exportDir = path.join(tempDir, 'exports');
const packagePath = await exportTheme(nativeDir, exportDir);
assert.equal(packagePath, path.join(exportDir, 'test-native-background.codex-theme'));
const portable = JSON.parse(await fs.readFile(packagePath, 'utf8')) as Record<string, unknown>;
assert.equal(portable.format, 'codex-theme');
assert.equal((portable.manifest as Record<string, unknown>).id, 'test-native-background');
const badDir = path.join(tempDir, 'test-native-immersive');
const paletteDir = path.join(tempDir, 'test-palette-only');
const paletteCssPath = path.join(paletteDir, 'theme.css');
const paletteCss = await fs.readFile(paletteCssPath, 'utf8');
await fs.writeFile(paletteCssPath, paletteCss.replaceAll('--color-background-panel', '--missing-background-panel'));
const missingBridge = await validateTheme(paletteDir);
assert.equal(missingBridge.valid, false);
assert.match(missingBridge.errors.join('\n'), /native compatibility signals.*color-background-panel/);
await fs.writeFile(paletteCssPath, paletteCss);
await fs.writeFile(paletteCssPath, paletteCss.replace('[data-codex-terminal="true"]', '[data-unverified-terminal="true"]'));
const missingTerminalRoot = await validateTheme(paletteDir);
assert.equal(missingTerminalRoot.valid, false);
assert.match(missingTerminalRoot.errors.join('\n'), /verified \[data-codex-terminal\] root/);
await fs.writeFile(paletteCssPath, paletteCss);
await fs.appendFile(path.join(badDir, 'theme.css'), '\nmain * { opacity: 1; }\n');
const bad = await validateTheme(badDir);
assert.equal(bad.valid, false);
assert.match(bad.errors.join('\n'), /broad descendant state override/);
assert.deepEqual(parseColor('rgb(255, 250, 236)'), { r: 255, g: 250, b: 236, a: 1 });
assert.deepEqual(parseColor('rgba(43, 41, 37, 0.5)'), { r: 43, g: 41, b: 37, a: 0.5 });
assert.equal(parseColor('transparent'), null);
const white = { r: 255, g: 255, b: 255, a: 1 };
const black = { r: 0, g: 0, b: 0, a: 1 };
assert.ok(Math.abs(contrastRatio(white, black) - 21) < 0.1);
assert.ok(contrastRatio(white, white) === 1);
assert.equal(effectiveBackground(['rgba(255, 250, 236, 0.5)', 'rgb(0, 0, 0)']).a, 1);
assert.ok(effectiveBackground(['rgba(255, 250, 236, 0.5)']).a < 0.99);
const judged = evaluateSamples([
{ text: 'washed out', path: 'main > p', color: 'rgba(255, 255, 255, 0.75)', stack: ['rgb(247, 238, 217)'], image: false, size: 14 },
{ text: 'readable', path: 'main > p', color: 'rgb(43, 41, 37)', stack: ['rgb(247, 238, 217)'], image: false, size: 14 },
{ text: 'over artwork', path: 'main > h1', color: 'rgb(255, 255, 255)', stack: [], image: true, size: 24 },
]);
const washedOut = judged.find((sample) => sample.text === 'washed out')!;
assert.ok(washedOut.ratio < 2.5 && washedOut.verified);
assert.ok(judged.find((sample) => sample.text === 'readable')!.ratio > 4.5);
assert.equal(judged.find((sample) => sample.text === 'over artwork')!.verified, false);
console.log('All TypeScript codex-theme-creator skill tests passed.');
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
scripts/validate-skill.ts
#!/usr/bin/env -S npx tsx
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export interface SkillValidationResult {
valid: boolean;
skillDir: string;
errors: string[];
}
function parseSimpleYaml(block: string): Record<string, string> {
const values: Record<string, string> = {};
for (const rawLine of block.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const separator = line.indexOf(':');
if (separator < 1) throw new Error(`Invalid YAML line: ${rawLine}`);
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim().replace(/^(["'])(.*)\1$/, '$2');
values[key] = value;
}
return values;
}
export async function validateSkill(skillDirectory: string): Promise<SkillValidationResult> {
const skillDir = path.resolve(skillDirectory);
const errors: string[] = [];
let source = '';
try {
source = await fs.readFile(path.join(skillDir, 'SKILL.md'), 'utf8');
} catch (error) {
return {
valid: false,
skillDir,
errors: [`Cannot read SKILL.md: ${error instanceof Error ? error.message : String(error)}`],
};
}
const match = source.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
if (!match) {
errors.push('SKILL.md must start with YAML frontmatter');
return { valid: false, skillDir, errors };
}
let frontmatter: Record<string, string> = {};
try {
frontmatter = parseSimpleYaml(match[1]!);
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
const keys = Object.keys(frontmatter);
for (const key of keys) {
if (!['name', 'description'].includes(key)) errors.push(`Unsupported frontmatter field: ${key}`);
}
if (!frontmatter.name) errors.push('frontmatter.name is required');
if (!frontmatter.description) errors.push('frontmatter.description is required');
if (frontmatter.name && !/^[a-z0-9-]{1,64}$/.test(frontmatter.name)) {
errors.push('frontmatter.name must use lowercase letters, digits, and hyphens only');
}
if (frontmatter.name && path.basename(skillDir) !== frontmatter.name) {
errors.push('skill directory name must match frontmatter.name');
}
if ((frontmatter.description?.length ?? 0) > 1024) errors.push('frontmatter.description is too long');
if (source.split('\n').length > 500) errors.push('SKILL.md must stay under 500 lines');
const agentYamlPath = path.join(skillDir, 'agents', 'openai.yaml');
try {
const agentYaml = await fs.readFile(agentYamlPath, 'utf8');
for (const field of ['display_name:', 'short_description:', 'default_prompt:']) {
if (!agentYaml.includes(field)) errors.push(`agents/openai.yaml is missing ${field.slice(0, -1)}`);
}
if (!agentYaml.includes(`$${frontmatter.name}`)) errors.push('default_prompt must reference the skill name');
} catch (error) {
errors.push(`Cannot read agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`);
}
return { valid: errors.length === 0, skillDir, errors };
}
async function main(): Promise<void> {
const skillDir = process.argv[2];
if (!skillDir) {
console.error('Usage: npx tsx scripts/validate-skill.ts /absolute/skill-directory');
process.exitCode = 2;
return;
}
const result = await validateSkill(skillDir);
console.log(JSON.stringify(result, null, 2));
if (!result.valid) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
});
}
scripts/validate-theme.ts
#!/usr/bin/env -S npx tsx
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { BackgroundScope, LayoutMode } from './scaffold-theme.ts';
interface ThemeDesign {
layoutMode?: LayoutMode;
backgroundScope?: BackgroundScope;
modeReason?: string;
contrastStrategy?: string;
allowedChanges?: string[];
preserve?: string[];
verificationViewports?: string[];
}
interface ThemeManifest {
id?: string;
displayName?: string;
description?: string;
version?: string;
mode?: string;
css?: string;
art?: string;
platforms?: string[];
design?: ThemeDesign;
palette?: Record<string, unknown>;
}
export interface ValidationResult {
valid: boolean;
theme?: string;
version?: string;
layoutMode?: LayoutMode;
backgroundScope?: BackgroundScope;
errors: string[];
warnings: string[];
}
interface CssRule {
selector: string;
body: string;
}
const allowedModes: LayoutMode[] = [
'native-background',
'native-immersive',
'editorial-showcase',
'palette-only',
];
export async function validateTheme(themeDirectory: string): Promise<ValidationResult> {
const themeDir = path.resolve(themeDirectory);
const errors: string[] = [];
const warnings: string[] = [];
const requireString = (value: unknown, label: string): string => {
if (typeof value !== 'string' || !value.trim()) {
errors.push(`${label} must be a non-empty string`);
return '';
}
return value.trim();
};
const requireArray = (value: unknown, label: string): void => {
if (!Array.isArray(value) || value.length === 0) errors.push(`${label} must be a non-empty array`);
};
let manifest: ThemeManifest;
try {
manifest = JSON.parse(await fs.readFile(path.join(themeDir, 'theme.json'), 'utf8')) as ThemeManifest;
} catch (error) {
return {
valid: false,
errors: [`Cannot read theme.json: ${error instanceof Error ? error.message : String(error)}`],
warnings,
};
}
requireString(manifest.id, 'id');
requireString(manifest.displayName, 'displayName');
requireString(manifest.description, 'description');
requireString(manifest.version, 'version');
if (!/^[a-z0-9][a-z0-9-]*$/.test(manifest.id ?? '')) errors.push('id must be a lowercase safe slug');
if (!['light', 'dark'].includes(manifest.mode ?? '')) errors.push('mode must be light or dark');
requireArray(manifest.platforms, 'platforms');
const design = manifest.design ?? {};
if (!allowedModes.includes(design.layoutMode as LayoutMode)) errors.push('design.layoutMode is invalid');
if (!['home', 'workspace'].includes(design.backgroundScope ?? '')) {
errors.push('design.backgroundScope must be home or workspace');
}
for (const key of ['modeReason', 'contrastStrategy'] as const) requireString(design[key], `design.${key}`);
requireArray(design.allowedChanges, 'design.allowedChanges');
requireArray(design.preserve, 'design.preserve');
if (!Array.isArray(design.verificationViewports) || design.verificationViewports.length < 2) {
errors.push('design.verificationViewports must include desktop and narrow viewports');
}
const palette = manifest.palette ?? {};
for (const key of ['canvas', 'surface', 'raised', 'text', 'muted', 'accent', 'border', 'focus']) {
requireString(palette[key], `palette.${key}`);
}
const cssRel = requireString(manifest.css, 'css') || 'theme.css';
const cssPath = path.resolve(themeDir, cssRel);
if (!cssPath.startsWith(`${themeDir}${path.sep}`)) errors.push('css path must remain inside the theme directory');
let css = '';
try {
css = await fs.readFile(cssPath, 'utf8');
} catch (error) {
errors.push(`cannot read CSS: ${error instanceof Error ? error.message : String(error)}`);
}
if (manifest.art) {
const artPath = path.resolve(themeDir, manifest.art);
if (!artPath.startsWith(`${themeDir}${path.sep}`)) errors.push('art path must remain inside the theme directory');
try { await fs.access(artPath); } catch { errors.push(`declared artwork does not exist: ${manifest.art}`); }
} else if (design.layoutMode === 'native-background') {
errors.push('native-background requires local artwork');
}
if (design.layoutMode === 'palette-only' && manifest.art) errors.push('palette-only must not declare dominant artwork');
const cleanCss = css.replace(/\/\*[\s\S]*?\*\//g, '');
if (/@import\s|expression\s*\(|javascript\s*:|url\(\s*["']?(?:https?:|\/\/)/i.test(cleanCss)) {
errors.push('CSS contains an external or executable resource');
}
if (/__THEME_[A-Z0-9_]+__/.test(cleanCss)) errors.push('CSS still contains scaffold placeholders');
const rules: CssRule[] = [...cleanCss.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => ({
selector: match[1]!.trim(),
body: match[2]!,
}));
if (design.layoutMode !== 'native-background') {
const compatibilitySignals = [
'--color-token-bg-primary',
'--color-token-foreground',
'--color-token-input-background',
'--color-token-menu-background',
'--color-token-conversation-body',
'--color-token-text-code-block-background',
'--color-background-surface',
'--color-background-surface-under',
'--color-background-panel',
'--color-background-control',
'--color-background-control-opaque',
'--color-background-editor-opaque',
'--color-text-foreground',
'--color-text-foreground-secondary',
'--color-text-foreground-tertiary',
'--color-icon-primary',
'--color-icon-secondary',
'--color-icon-tertiary',
'--color-border',
'--color-border-light',
'--color-border-heavy',
'--color-border-focus',
];
const missingSignals = compatibilitySignals.filter((signal) => !cleanCss.includes(signal));
if (missingSignals.length) {
errors.push(`component-changing theme lacks native compatibility signals: ${missingSignals.join(', ')}`);
}
if (!/\[data-codex-terminal(?:=|\])/.test(cleanCss)) {
errors.push('component-changing theme must override the verified [data-codex-terminal] root');
}
const importantViewport = rules.some(({ selector, body }) =>
selector.includes('.xterm-viewport') && /background(?:-color)?\s*:[^;]+!important/i.test(body));
if (!importantViewport) {
errors.push('xterm viewport needs a scoped important background to override mounted inline colors');
}
if (!cleanCss.includes('--vscode-terminal-background')) {
errors.push('component-changing theme must map --vscode-terminal-background on its terminal contract');
}
}
for (const { selector, body } of rules) {
const normalized = selector.replace(/\s+/g, ' ');
const broad = /\b(?:aside|main)\b[^,{]*\*/i.test(normalized) || /(^|,)\s*svg\s*(?:,|$)/i.test(normalized);
const stateProperty = /\b(?:opacity|display|visibility|position|overflow|color)\s*:/i.test(body);
if (broad && stateProperty) errors.push(`broad descendant state override: ${normalized}`);
if (/\bheader\b/i.test(normalized) && /background(?:-image)?\s*:[^;]*gradient/i.test(body)) {
warnings.push(`header gradient requires real-app contrast evidence: ${normalized}`);
}
}
if (!/@media\s*\(/.test(cleanCss)) errors.push('CSS needs a narrow-window media query');
if (!/@media\s*\(prefers-reduced-motion:\s*reduce\)/.test(cleanCss)) warnings.push('CSS has no reduced-motion rule');
if ((cleanCss.match(/pointer-events\s*:\s*none/g) ?? []).length < 1 && manifest.art) {
errors.push('artwork themes need pointer-events: none on decorative layers');
}
const homeScoped = /data-codexthemes-page=["']home/.test(cleanCss);
const conversationScoped = /data-codexthemes-page=["']conversation/.test(cleanCss);
const conversationArtworkGated = /data-codexthemes-background-scope=["']workspace/.test(cleanCss);
if (manifest.art && !homeScoped) errors.push('artwork is not scoped to a verified home marker');
if (design.backgroundScope === 'workspace' && !conversationScoped) {
errors.push('workspace background lacks a verified conversation scope');
}
if (design.backgroundScope === 'home' && conversationScoped && !conversationArtworkGated) {
warnings.push('home-only theme contains conversation selectors; confirm they do not render artwork');
}
if (/main\s*:not\(/.test(cleanCss)) errors.push('do not infer conversation state with main:not(...)');
if (design.layoutMode === 'native-background') {
const forbidden = /\b(?:width|height|min-height|max-height|flex-basis|grid-template|order)\s*:/i;
for (const { selector, body } of rules) {
if (forbidden.test(body) && !/::(?:before|after)/.test(selector)) {
errors.push(`native-background changes native geometry: ${selector.replace(/\s+/g, ' ')}`);
}
}
for (const invariant of ['native geometry', 'native controls', 'native states']) {
if (!design.preserve?.includes(invariant)) errors.push(`native-background must preserve ${invariant}`);
}
}
if (manifest.mode === 'light' && design.layoutMode !== 'native-background') {
for (const key of ['terminalBackground', 'terminalForeground']) requireString(palette[key], `palette.${key}`);
for (const signal of ['role="menu"', 'role="dialog"', 'xterm']) {
if (!cleanCss.includes(signal)) warnings.push(`light immersive theme has no explicit ${signal} surface rule`);
}
}
return {
valid: errors.length === 0,
...(manifest.id === undefined ? {} : { theme: manifest.id }),
...(manifest.version === undefined ? {} : { version: manifest.version }),
...(design.layoutMode === undefined ? {} : { layoutMode: design.layoutMode }),
...(design.backgroundScope === undefined ? {} : { backgroundScope: design.backgroundScope }),
errors: [...new Set(errors)],
warnings: [...new Set(warnings)],
};
}
async function main(): Promise<void> {
const themeDir = process.argv[2];
if (!themeDir) {
console.error('Usage: npx tsx scripts/validate-theme.ts /absolute/theme-directory');
process.exitCode = 2;
return;
}
const result = await validateTheme(themeDir);
console.log(JSON.stringify(result, null, 2));
if (!result.valid) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
});
}
SKILL.md
---
name: codex-theme-creator
description: Create, redesign, validate, preview, package, and prepare cohesive themes for the official Codex desktop app from a visual brief or reference image. Use when a user asks for a new Codex theme, wants to preserve or adapt the native Codex layout, wants artwork on the home or conversation page, reports inconsistent themed surfaces, or needs a portable .codex-theme submission for CodexThemes.
---
# Create a Codex theme
Create a reversible decorative theme without modifying `app.asar`, the signed app bundle, WindowsApps, user tasks, or authentication data. Preserve native layout and interaction behavior unless the chosen layout mode explicitly permits a bounded composition change.
## Authority boundary
Use this skill as the sole design and QA authority for a new Codex theme. Do not read, copy, or combine another installed theme skill, finished theme manifest, finished theme CSS, or local theme collection unless the user explicitly names that source. Do not describe the workflow as combining this skill with “local theme conventions.” Start from this skill's matching skeleton and the user's brief or reference image.
This skill is standalone. Its TypeScript scripts own scaffolding, validation, reversible session application, and restore. Never detect, invoke, read configuration from, or claim a dependency on CodeDrobe, Dream/Fiona, or another theme injector unless the user explicitly asks to use that product. Do not reuse their marker names or runtime data. The only required local tools are Node.js, `npx`, and the official Codex desktop app.
## Managed storage
Keep every generated theme file in the managed library `~/.codexthemes` unless the user explicitly requests another location:
- editable source and artwork: `~/.codexthemes/themes/<theme-id>/`
- previews: `~/.codexthemes/themes/<theme-id>/previews/`
- shareable packages: `~/.codexthemes/exports/`
- runtime state: `~/.codexthemes/state/`
Never create a staging, scratch, or temporary copy of theme files in or near the current workspace (for example a `.codexthemes-stage` directory). If the sandbox or permission mode blocks writing to `~/.codexthemes`, ask the user to approve writing there instead of writing somewhere else first and moving files later.
## Read the required references
Before creating or substantially redesigning a theme, read these files completely:
- `references/design-playbook.md`: layout modes, semantic palette, selectors, and surface coverage.
- `references/qa-checklist.md`: real-app acceptance matrix and hard failures.
- `references/theme-schema.md`: manifest and portable package contract.
- `references/asset-rights.md`: user-material handling and submission handoff.
Do not begin artwork or CSS until the design contract below is written.
## Gate 1: write the design contract
Record:
- `layoutMode`
- `backgroundScope`
- `decorDensity`
- light or dark mode and the reason
- artwork focal point and text-safe region
- semantic palette
- surfaces allowed to change
- native geometry and states that must remain unchanged
- target desktop and narrow viewports
Choose the layout mode in this order:
1. Use `native-immersive` as the default when the user supplies a reference image or names a visual world (a game, film, anime, brand, season, or other strong art direction): keep the native layout and deliver coordinated sidebar, card, menu, composer, settings, diff, and terminal materials plus the decoration menu in `references/design-playbook.md`.
2. Use `native-background` only when the user explicitly restricts the theme to a background change.
3. Use `editorial-showcase` only when the reference clearly requires a bounded portrait, product, or campaign hero with a different home composition.
4. Use `palette-only` when no dominant artwork is required.
Record `decorDensity` as `minimal`, `balanced`, or `rich`. Default to `rich` when the reference has a strong art direction and `balanced` otherwise; use `minimal` only when the user explicitly asks for a background-only or quiet theme. The design playbook defines the element coverage each density requires. A theme that ships one background image and a veil does not satisfy `balanced` or `rich`.
`backgroundScope` decides where the dominant background artwork appears. It is not a runtime toggle — it is baked into the CSS selectors, so changing it later means rebuilding the theme. Because most users do not know this option exists, surface it explicitly instead of deciding silently:
- When the user has not stated a preference, ask once in plain language before building, for example: "Should the background artwork appear on the home page only (recommended -- keeps conversation text and code clearest), or on conversation pages too?" Recommend `home` as the readability-safe default.
- Only pick `workspace` when the user chooses it or clearly asks for artwork on task/conversation pages.
State the chosen scope in the design contract you report back, so the user knows what they are getting. Never infer a conversation page merely because the page is not home; settings and system pages must not inherit task artwork. With `workspace`, put a stronger veil behind conversation text so long-form content and code stay readable.
Do not default to dark mode. Match an explicit request or the reference luminance. Ambiguous editorial, floral, portrait, stationery, lifestyle, productivity, and pastel concepts default to light.
## Gate 2: capture the native contract
Inspect the unmodified live app at the same viewport and record evidence for:
- home heading, native suggestion cards, project selector, and composer
- populated conversation and activity rows
- settings and profile pages
- personal menu, dropdown, dialog, and tooltip
- attachments, file cards, changed-files/diff, and output panels
- terminal before and after xterm mounts
- sidebar idle, hover, selected, project hover, long titles, and row actions
- header actions at narrow and wide widths
Treat geometry, visibility, hit targets, keyboard focus, and hover-only actions as invariants unless the design contract explicitly permits a change.
## Gate 3: scaffold the correct skeleton
Run from the installed skill directory:
```bash
npx tsx scripts/scaffold-theme.ts \
--id <safe-slug> \
--name "<display name>" \
--layout-mode <native-background|native-immersive|editorial-showcase|palette-only> \
--background-scope <home|workspace> \
[--art /absolute/artwork.png]
```
The default source location is `~/.codexthemes/themes/<safe-slug>/`. Always use that managed library unless the user explicitly requests another location; only then pass `--output /absolute/theme-parent`. Never use the current workspace merely because it is writable.
Never start by copying a finished theme that uses another layout mode.
## Gate 4: implement in controlled layers
Build CSS in this order:
1. semantic tokens
2. shell surfaces
3. exact route-scoped backgrounds
4. native component roots
5. interaction states
6. non-interactive decoration
7. responsive and reduced-motion rules
Restraint applies to geometry and interaction, not to coverage: implement every surface material and decoration element the contracted `decorDensity` requires (see the playbook's decoration menu). A theme that only swaps the background and leaves native cards, composer, sidebar, header, typography, and scrollbars untouched is unfinished at `balanced` or `rich` density. Use stable roles, test IDs, verified component classes, and narrowly anchored `:has(...)`. Avoid localized text selectors when a structural hook exists.
Never apply broad `opacity`, `display`, `visibility`, `position`, `overflow`, or `color` overrides to `aside *`, `main *`, generic `svg`, or broad descendant groups. Never use descendant `opacity: 1` to repair contrast; it exposes hidden row actions.
Define one owner for every divider, outline, sidebar edge, card boundary, and header boundary. Use a solid readable header surface; keep gradients and artwork behind content rather than behind low-contrast toolbar controls. Keep all decoration below native controls with `pointer-events: none`.
For light themes, keep settings, menus, dialogs, output/diff panels, code surfaces, and terminal hosts in the same light semantic system unless the contract deliberately defines a contrast panel. Theme terminal host, xterm viewport, and xterm screen together.
Changing a surface's background does not restyle its text. The native app (especially in dark mode) sets **explicit** light text colors on many descendants, and an inherited `color` on `main`, `aside`, or `header` never overrides an explicit descendant color. Whenever a background flips luminance (a light theme over the native dark mode, or the reverse), override the text tokens on the same descendants that carry them — message bodies, markdown content, list items, buttons, labels — not just the container. This is the single most common way a theme ships unreadable.
Install the stylesheet before asynchronously decoding large artwork so cold launch does not flash the native page and then jump to a different layout.
Increment the manifest version after every visible change.
## Gate 5: validate before application
Run:
```bash
npx tsx scripts/validate-theme.ts /absolute/theme-directory
```
Fix every error. Treat warnings as unresolved until checked against the design contract. Static validation does not replace visual verification.
Generate a self-contained preview at both 1440x900 and a narrow viewport. Save previews in `~/.codexthemes/themes/<theme-id>/previews/` (the scaffold already creates this directory); never write them to the workspace or a staging directory. Label each one as a design preview, never as a verified Codex screenshot.
Every preview must show the full themed workspace — sidebar, header, and home content in one frame — never the background artwork alone. Keep the primary desktop preview as a raster file (PNG, JPEG, or WebP; for example `previews/home-1440x900.png`): it becomes the theme's gallery and detail image on codexthemes.ai. After Gate 6 verification, replace it with a real screenshot of the applied theme.
## Gate 6: apply and verify the real app
Ask for explicit permission before applying a theme or restarting Codex. Use this skill's own reversible TypeScript runtime; do not search for or prefer an external theme program.
If Codex is already exposing a local debugging endpoint, apply without restarting:
```bash
npx tsx scripts/apply-theme.ts apply /absolute/theme-directory
```
If no endpoint exists, ask specifically for permission to restart Codex, then use the standalone launcher:
```bash
npx tsx scripts/apply-theme.ts apply /absolute/theme-directory --launch
```
When no endpoint is live, `--launch` prints `{"status": "scheduled"}` and hands the quit → relaunch → inject sequence to a detached helper that survives the restart. This is required because an agent hosted inside Codex dies together with Codex; expect the current tool call (and possibly the session) to be interrupted by the restart. Never work around the restart yourself: do not write shell wrappers, launchd or scheduled tasks, copies of the script, or any other relaunch mechanism — the `--launch` helper already survives the restart.
The launcher supports macOS and Windows (on Windows it locates the Codex/ChatGPT executable, closes it gracefully with `taskkill` — never `/F` — and relaunches it with the debugging flags; pass `--app` with the full `.exe` path if detection fails). If the endpoint never appears after a Windows relaunch, that installed build (for example a Microsoft Store package) drops the debugging flags — report that limitation plainly and finish source, preview, validation, and packaging instead; never modify files under `WindowsApps`.
`"scheduled"` is not success. After Codex is back, verify before reporting anything:
```bash
npx tsx scripts/apply-theme.ts status
```
`status` probes every live Codex page and reports `"active"` with the injected theme id only when the style element is really in the DOM. If it reports `"inactive"`, read `~/.codexthemes/state/launch.log` for the helper's result and error.
If an old theme keeps re-appearing after a successful apply (a stale session from an earlier task is still re-injecting it), get the user's restart permission and force a clean relaunch with `apply ... --launch --relaunch` — never ask the user to quit the app by hand and never invent your own restart mechanism.
For later one-command switching between finished themes, hand off to the `codex-theme-switcher` skill; this skill's apply runtime is for creation-time QA.
The launcher binds debugging to `127.0.0.1`, injects only an owned `<style>` element and CodexThemes page markers, does not modify the signed application bundle, and keeps the theme active across SPA route changes and renderer reloads for the current app session. A full application quit requires reapplying the theme (again with `--launch`). Restore at any time with:
```bash
npx tsx scripts/apply-theme.ts restore
```
If standalone application is unavailable on the current platform, finish the source, preview, validation, and package. State the application limitation plainly; never silently fall back to another installed injector.
Immediately after every apply — hot swap or post-restart — run the automated readability gate before anything else:
```bash
npx tsx scripts/qa-contrast.ts
```
It hard-fails (exit 1) when visible text is unreadable, using two independent measurements: computed-style composition over verified opaque backdrops, and **real screenshot pixels** (`pixelFailures`), which also judge text sitting over artwork and transparent layers. A `fail` means the theme is unreadable right now: fix the text tokens or artwork veils (or switch back to the previous theme) before doing anything else, and never leave a failing theme active or report it as applied. A `pass` is necessary but not sufficient; continue with the full matrix:
`qa-contrast.ts` is deliberately route-local: it samples only what is mounted and visible when it runs. A pass on home says nothing about settings or a terminal that has not mounted yet. For component-changing themes, navigate and rerun it on home, one conversation with representative content, settings with cards/selects visible, and the terminal after xterm mounts. Record each route separately; never reuse one route's pass as evidence for the matrix.
When a root token appears correct but a component still uses the native palette, inspect the component root's inline `style` and locally declared custom properties. Current Codex builds create local token islands for settings controls and terminals; a declaration on the component itself beats an inherited root token. Fix the verified component root (for example `[data-codex-terminal="true"]`) and any inline-colored viewport with a narrowly scoped `!important`, then retest both an existing mounted instance and a newly mounted one.
Verify independently:
- structure: native sidebar, cards, project selector, composer, task content, settings, and terminal work
- scope: home and conversation artwork appears only where contracted
- palette: every changed surface belongs to one semantic system
- mode independence: the full native `--color-token-*` sweep is present, so the theme renders identically whether the user's Codex starts in native light or native dark mode (see the playbook's native token sweep)
- state: idle, hover, selected, disabled, focus-visible, loading, running, expanded, and open
- geometry: no clipping, overflow, duplicate borders, text/icon overlap, or sidebar action collision
- lifecycle: cold launch, route change, renderer reload, theme switch, restore, and reapply
Capture real screenshots and computed-style/geometry evidence. A successful build, injection marker, static mockup, or one home screenshot is not completion. When a P0 check fails, fix selector or token ownership and rerun the complete matrix instead of adding an emergency override.
## Gate 7: package only after signoff
Package only verified local files. A `.codex-theme` package must be UTF-8 JSON, contain no absolute paths, external CSS resources, scripts, tracking, secrets, or private references, and stay under 30 MB. Follow `references/theme-schema.md`.
Export the shareable package with:
```bash
npx tsx scripts/export-theme.ts ~/.codexthemes/themes/<theme-id>
```
The default output is `~/.codexthemes/exports/<theme-id>.codex-theme`. Do not place export packages in a project workspace or inside the editable theme source. Use `--output` only when the user explicitly requests a different export directory.
The export embeds a workspace preview (manifest `preview`, or the best raster in `previews/`) that codexthemes.ai uses as the gallery and detail image. Before exporting, confirm that image shows the themed workspace with the sidebar — not the raw artwork — and re-export after replacing it with a verified screenshot.
Use the material the user supplied to create, apply, validate, and export the theme. Do not investigate ownership, demand replacement artwork, or block local packaging because the subject is a game, film, anime, public figure, brand, or other third-party work. Do not make legal conclusions or claim that the user owns the material.
After export, offer the user both submission paths:
1. Manual: submit the `.codex-theme` package and a clear preview at `https://codexthemes.ai/submit`.
2. Agent-assisted: tell the user they can reply with a prompt like `Use $codex-theme-submitter to submit ~/.codexthemes/exports/<theme-id>.codex-theme to codexthemes.ai`, and that if `$codex-theme-submitter` is not installed they can add it with `npx skills add codexthemes/skills --skill codex-theme-submitter -g -a codex`.
State that submission asks the uploader to confirm they may share the material; leave that confirmation to the user and the submission workflow. Do not run the submission yourself unless the user asks for it.
## Completion standard
Reject an almost-complete theme. A polished home page does not compensate for broken conversations, settings, menus, terminal, sidebar states, narrow windows, or cold launch. Report separately what was designed, statically validated, applied, visually verified, and packaged. Always report the canonical source and export paths.