Requirements
User Story
As a web app user, I want to choose from multiple available themes — sometimes light, other times dark.
Work Order
Develop a TypeScript-based API that provides helper functions or classes for building a theme picker in web applications. The API should be framework‑agnostic, while optionally offering convenient integration points for Angular applications.
Functional Requirements
- Support both light and dark themes.
- Support more than two themes — at least three themes must be available.
- Support commonly used prebuilt themes, optionally combined with an app‑specific color stylesheet such as
colors.css, with an optional dark‑mode variant likecolors-dark.css. - Support dynamic switching between themes at runtime.
- When the same web app/site is opened in another browser tab, the explicitly selected theme should be preserved and applied.
Technical Requirements
- Reusable across multiple applications.
- Minimal API surface to ensure easy customization and easy usage.
- Neutral with respect to specific UI design choices.
- Must be efficient and avoid visual flicker during startup and theme switching.
- Usable in both SPA and PWA.
- Fully functional in PWAs, offline usage, and intranet environments.
- Adjustable after build, bundling, and deployment. For example, an admin should be able to change the number and order of available themes, and modify app‑specific color files.
- Themes may be hosted locally or on a CDN.
- Selecting a theme that is already loaded should not trigger a reload of the theme.
- Core theme management must be separated from the theme‑picker UI.
- The config settings must be loaded during app startup before any component is rendered.
Using the requirements as prompt, I asked M365 Copilot to generate sourcecode, and the following is the result.
Below is a framework‑agnostic TypeScript API (with an optional Angular PWA integration) that lets you configure, preload, and switch among multiple themes (≥3), with instant cross‑tab sync, no FOUC/flicker, offline/PWA support, and post‑deployment reconfigurability.
What you get
- ✅ Core theme management in a standalone, reusable class (
ThemeManager) - ✅ Neutral API (no opinionated UI) — you plug it into any UI (React, Vue, Angular, plain HTML)
- ✅ Support for light & dark + any number of additional themes
- ✅ Prebuilt theme CSS + app‑specific colors.css (and colors-dark.css)
- ✅ Runtime switching with no flicker and no redundant reloads
- ✅ Cross‑tab persistence (Storage + BroadcastChannel)
- ✅ Config from JSON (editable after deployment) or embedded
<script>JSON - ✅ Local or CDN hosting, optional SRI/CORS
- ✅ SPA & PWA ready with early-boot snippet to avoid flash
- ✅ Optional: Angular Service + APP_INITIALIZER + ngsw-config hints
1) Core Types
// src/theme.types.ts
export type ThemeMode = 'light' | 'dark';
export interface ThemeEntry {
/** Unique theme identifier (e.g., 'light', 'dark', 'nord') */
id: string;
/** Human-readable name for pickers ("Light", "Dark", "Nord") */
label: string;
/** Light or dark; used for system color-scheme and optional color variants */
mode: ThemeMode;
/** URL for the base/prebuilt theme stylesheet (CDN or local) */
css: string;
/**
* App-specific color overrides for light mode (optional).
* e.g., '/assets/colors.css' or 'https://cdn/.../colors.css'
*/
colors?: string;
/**
* App-specific color overrides for dark mode (optional).
* If absent and mode === 'dark', `colors` is used (if present).
*/
colorsDark?: string;
/** Optional Subresource Integrity and CORS for CDN-hosted CSS */
integrity?: string;
crossorigin?: 'anonymous' | 'use-credentials';
/** Preload this theme’s CSS at startup (non-blocking) */
preload?: boolean;
}
export interface ThemeConfig {
/** Order matters; you can re-order after deployment */
themes: ThemeEntry[];
/**
* Default theme id to apply on first load if nothing is stored.
* If omitted, first theme in `themes` is used.
*/
defaultThemeId?: string;
}
export interface ThemeManagerOptions {
/** Key names for storage (allow multiple apps on same origin) */
storageKeyActiveId?: string; // default: 'theme.active'
storageKeyLinks?: string; // default: 'theme.links'
broadcastChannelName?: string; // default: 'theme-channel'
/** Optional element IDs if you want fixed link IDs */
coreLinkId?: string; // default: 'tm-core'
colorsLinkId?: string; // default: 'tm-colors'
}
2) Core Theme Manager (framework‑agnostic)
// src/theme.manager.ts
import { ThemeConfig, ThemeEntry, ThemeManagerOptions } from './theme.types';
type Unsubscribe = () => void;
export class ThemeManager {
private config: ThemeConfig | null = null;
private activeThemeId: string | null = null;
private listeners = new Set<(id: string) => void>();
private readonly storageKeyActiveId: string;
private readonly storageKeyLinks: string;
private readonly broadcastChannelName: string;
private channel: BroadcastChannel | null = null;
private readonly coreLinkId: string;
private readonly colorsLinkId: string;
private initializing = false;
constructor(opts: ThemeManagerOptions = {}) {
this.storageKeyActiveId = opts.storageKeyActiveId ?? 'theme.active';
this.storageKeyLinks = opts.storageKeyLinks ?? 'theme.links';
this.broadcastChannelName = opts.broadcastChannelName ?? 'theme-channel';
this.coreLinkId = opts.coreLinkId ?? 'tm-core';
this.colorsLinkId = opts.colorsLinkId ?? 'tm-colors';
// Cross-tab sync via BroadcastChannel + storage events
try {
this.channel = new BroadcastChannel(this.broadcastChannelName);
this.channel.onmessage = (ev) => {
if (ev?.data?.type === 'theme-change' && ev?.data?.id) {
if (ev.data.id !== this.activeThemeId) {
// Don’t await to keep handler responsive
this.applyTheme(ev.data.id).catch(console.error);
}
}
};
} catch {
// BroadcastChannel may not exist — storage event fallback below
this.channel = null;
}
window.addEventListener('storage', (e) => {
if (e.key === this.storageKeyActiveId && typeof e.newValue === 'string') {
const id = e.newValue;
if (id && id !== this.activeThemeId) {
this.applyTheme(id).catch(console.error);
}
}
});
}
/** Initialize from one of: inline script JSON, direct config, or URL */
async init(options: {
embeddedScriptId?: string; // <script type="application/json" id="theme-config">…</script>
config?: ThemeConfig;
configUrl?: string; // '/theme.config.json'
} = {}): Promise<void> {
if (this.initializing) return;
this.initializing = true;
// Load configuration
if (options.config) {
this.setConfig(options.config);
} else if (options.embeddedScriptId) {
const el = document.getElementById(options.embeddedScriptId);
if (!el) throw new Error(`Theme config script#${options.embeddedScriptId} not found`);
const json = el.textContent?.trim() || '{}';
this.setConfig(JSON.parse(json));
} else if (options.configUrl) {
// Fetch at runtime → adjustable after deployment (works offline with SW)
const resp = await fetch(options.configUrl, { cache: 'no-cache' });
if (!resp.ok) throw new Error(`Failed to fetch theme config from ${options.configUrl}`);
this.setConfig(await resp.json());
} else {
throw new Error('ThemeManager.init requires one of: config, embeddedScriptId, or configUrl');
}
// Determine initial theme: stored → config.default → first theme
const storedId = localStorage.getItem(this.storageKeyActiveId);
const initialId =
(storedId && this.findTheme(storedId) ? storedId : null) ||
this.config!.defaultThemeId ||
this.config!.themes[0].id;
await this.applyTheme(initialId, { skipIfActive: true, fastPathIfCached: true });
// Optional: preload other themes marked with `preload: true`
this.preloadMarkedThemes().catch(console.error);
this.initializing = false;
}
/** In-memory config update (e.g., admin can swap order after deployment) */
setConfig(config: ThemeConfig) {
if (!config?.themes?.length) {
throw new Error('ThemeConfig must include at least one theme');
}
this.config = {
...config,
themes: config.themes.map(t => ({ ...t })),
};
}
getThemes(): ReadonlyArray<Pick<ThemeEntry, 'id' | 'label' | 'mode'>> {
if (!this.config) return [];
return this.config.themes.map(({ id, label, mode }) => ({ id, label, mode }));
}
getActiveThemeId(): string | null {
return this.activeThemeId;
}
/** Subscribe to theme changes */
onChange(cb: (id: string) => void): Unsubscribe {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
/** Apply a theme by id */
async applyTheme(id: string, opts: { skipIfActive?: boolean; forceReload?: boolean; fastPathIfCached?: boolean } = {}): Promise<void> {
if (!this.config) throw new Error('ThemeManager not initialized');
const theme = this.findTheme(id);
if (!theme) throw new Error(`Unknown theme id: ${id}`);
if (opts.skipIfActive && this.activeThemeId === id) return;
// If already active and not forced → no-op
if (!opts.forceReload && this.activeThemeId === id) return;
const { coreHref, colorsHref } = this.resolveHrefs(theme);
// If already loaded with same hrefs → no-op
const current = this.readCurrentLinkHrefs();
if (!opts.forceReload && current.coreHref === coreHref && current.colorsHref === colorsHref) {
if (this.activeThemeId !== id) {
this.activeThemeId = id;
this.afterApplied(theme);
}
return;
}
// Load core CSS first (swap seamlessly), then colors if provided
await this.swapStylesheet(this.coreLinkId, coreHref, theme.integrity, theme.crossorigin, { fastPathIfCached: opts.fastPathIfCached });
if (colorsHref) {
await this.swapStylesheet(this.colorsLinkId, colorsHref, theme.integrity, theme.crossorigin, { fastPathIfCached: opts.fastPathIfCached });
} else {
// Remove colors link if it exists
const colorsEl = document.getElementById(this.colorsLinkId);
if (colorsEl?.parentNode) colorsEl.parentNode.removeChild(colorsEl);
}
this.activeThemeId = id;
this.persist(id, coreHref, colorsHref);
this.afterApplied(theme);
this.broadcast(id);
}
/** Preload all themes flagged with `preload: true` */
async preloadMarkedThemes(): Promise<void> {
if (!this.config) return;
const tasks: Promise<unknown>[] = [];
for (const t of this.config.themes) {
if (t.preload) {
const { coreHref, colorsHref } = this.resolveHrefs(t);
tasks.push(this.preloadStylesheet(coreHref, t.integrity, t.crossorigin));
if (colorsHref) tasks.push(this.preloadStylesheet(colorsHref, t.integrity, t.crossorigin));
}
}
await Promise.allSettled(tasks);
}
destroy() {
this.channel?.close();
this.listeners.clear();
}
// ===== Internals =====
private findTheme(id: string): ThemeEntry | undefined {
return this.config!.themes.find(t => t.id === id);
}
private resolveHrefs(theme: ThemeEntry): { coreHref: string; colorsHref?: string } {
const coreHref = theme.css;
let colorsHref: string | undefined;
if (theme.mode === 'dark') {
colorsHref = theme.colorsDark || theme.colors; // Prefer dark override
} else {
colorsHref = theme.colors;
}
return { coreHref, colorsHref };
}
private afterApplied(theme: ThemeEntry) {
// Set DOM attributes for CSS hooks and accessibility
document.documentElement.setAttribute('data-theme', theme.id);
document.documentElement.setAttribute('data-theme-mode', theme.mode);
// Hint to UA for form controls, etc.
const colorScheme = theme.mode === 'dark' ? 'dark' : 'light';
let meta = document.querySelector('meta[name="color-scheme"]') as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.name = 'color-scheme';
document.head.appendChild(meta);
}
meta.content = colorScheme;
// Notify listeners
for (const cb of this.listeners) {
try { cb(theme.id); } catch (e) { console.error(e); }
}
}
private persist(id: string, coreHref: string, colorsHref?: string) {
localStorage.setItem(this.storageKeyActiveId, id);
localStorage.setItem(this.storageKeyLinks, JSON.stringify({ coreHref, colorsHref: colorsHref || '' }));
}
private broadcast(id: string) {
try { this.channel?.postMessage({ type: 'theme-change', id }); } catch {}
}
private readCurrentLinkHrefs(): { coreHref: string; colorsHref: string } {
const coreEl = document.getElementById(this.coreLinkId) as HTMLLinkElement | null;
const colorsEl = document.getElementById(this.colorsLinkId) as HTMLLinkElement | null;
return {
coreHref: coreEl?.href || '',
colorsHref: colorsEl?.href || '',
};
// Note: .href returns absolute URL; comparisons still work because we set via .href later.
}
private async swapStylesheet(
linkId: string,
href: string,
integrity?: string,
crossorigin?: 'anonymous' | 'use-credentials',
opts: { fastPathIfCached?: boolean } = {}
): Promise<void> {
const existing = document.getElementById(linkId) as HTMLLinkElement | null;
// If existing already points to same URL → no-op
if (existing && sameUrl(existing.href, href)) return;
// Create new link (preload then promote to stylesheet to avoid flash)
const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'style';
preload.href = href;
preload.id = `${linkId}-preload`;
if (integrity) preload.integrity = integrity;
if (crossorigin) preload.crossOrigin = crossorigin;
const whenLoaded = new Promise<void>((resolve, reject) => {
preload.addEventListener('load', () => resolve());
preload.addEventListener('error', () => reject(new Error(`Failed to preload ${href}`)));
});
document.head.appendChild(preload);
// If CSS is cached, browsers often fulfill immediately (fast path).
if (opts.fastPathIfCached) {
// small microtask delay to allow load handler; then continue
await Promise.race([whenLoaded, microDelay(12)]);
} else {
await whenLoaded;
}
// Promote to real stylesheet
const next = document.createElement('link');
next.rel = 'stylesheet';
next.href = href;
next.id = linkId;
if (integrity) next.integrity = integrity;
if (crossorigin) next.crossOrigin = crossorigin;
// Ensure new stylesheet is ready before removing the old one
const onReady = new Promise<void>((resolve, reject) => {
next.addEventListener('load', () => resolve());
next.addEventListener('error', () => reject(new Error(`Failed to load ${href}`)));
});
document.head.appendChild(next);
await onReady;
// Remove preload and previous link
safeRemove(preload);
if (existing) safeRemove(existing);
}
private async preloadStylesheet(href: string, integrity?: string, crossorigin?: 'anonymous' | 'use-credentials') {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = href;
if (integrity) link.integrity = integrity;
if (crossorigin) link.crossOrigin = crossorigin;
link.setAttribute('data-theme-preload', 'true');
const whenLoaded = new Promise<void>((resolve, reject) => {
link.addEventListener('load', () => resolve());
link.addEventListener('error', () => reject(new Error(`Failed to preload ${href}`)));
});
document.head.appendChild(link);
return whenLoaded.finally(() => safeRemove(link));
}
}
function sameUrl(a: string, b: string): boolean {
try {
return new URL(a, document.baseURI).href === new URL(b, document.baseURI).href;
} catch {
return a === b;
}
}
function safeRemove(node: Element | null) {
if (node && node.parentNode) node.parentNode.removeChild(node);
}
function microDelay(ms: number) {
return new Promise<void>(r => setTimeout(r, ms));
}
3) Configuration (editable after deployment)
You can deliver the theme list in any of these ways (choose one):
- External JSON file (
/theme.config.json) — easy for admins to replace without rebuilding - Inline JSON inside an HTML
<script type="application/json" id="theme-config"> - Direct object in code (less flexible after deployment)
Example: /theme.config.json
{
"defaultThemeId": "light",
"themes": [
{
"id": "light",
"label": "Light",
"mode": "light",
"css": "/themes/prebuilt/light.css",
"colors": "/themes/app/colors.css",
"preload": true
},
{
"id": "dark",
"label": "Dark",
"mode": "dark",
"css": "/themes/prebuilt/dark.css",
"colors": "/themes/app/colors.css",
"colorsDark": "/themes/app/colors-dark.css",
"preload": true
},
{
"id": "nord",
"label": "Nord",
"mode": "dark",
"css": "https://cdn.example.com/themes/nord.min.css",
"colors": "/themes/app/colors.css",
"colorsDark": "/themes/app/colors-dark.css",
"integrity": "sha384-...your-sri-hash...",
"crossorigin": "anonymous"
}
]
}
Local or CDN: URLs can be relative or absolute. SRI/CORS fields support CDN integrity checks.
4) Early‑Boot No‑Flicker Snippet (insert in <head>)
This tiny, blocking snippet applies the last chosen theme before first paint, avoiding any “flash of unthemed content (FOUC)”.\
It uses only what’s in localStorage (no network):
This ensures the exact same CSS URLs as last time are used immediately (usually from cache), eliminating flicker.\
The fullThemeManagerwill load/validate config after your app boots and keep everything in sync.
5) Vanilla Usage (no framework)
// main.ts
import { ThemeManager } from './theme.manager';
const theme = new ThemeManager();
await theme.init({
// Pick one:
configUrl: '/theme.config.json',
// or embeddedScriptId: 'theme-config',
// or config: { ... }
});
// Wire up your UI picker (example)
const select = document.getElementById('theme-select') as HTMLSelectElement;
for (const t of theme.getThemes()) {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.label;
select.appendChild(opt);
}
select.value = theme.getActiveThemeId() || '';
select.addEventListener('change', () => theme.applyTheme(select.value));
// React to changes made in other tabs
theme.onChange((id) => {
select.value = id;
});
HTML example:
6) Optional Angular Integration (PWA‑friendly)
Angular Service (wrapper)
// theme.service.ts
import { Injectable, Inject } from '@angular/core';
import { ThemeManager } from './theme.manager';
import { ThemeConfig } from './theme.types';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private manager = new ThemeManager();
async initFromUrl(configUrl: string) {
await this.manager.init({ configUrl });
}
async initFromScript(scriptId: string) {
await this.manager.init({ embeddedScriptId: scriptId });
}
async initFromConfig(config: ThemeConfig) {
await this.manager.init({ config });
}
themes() { return this.manager.getThemes(); }
activeId() { return this.manager.getActiveThemeId(); }
onChange(cb: (id: string) => void) { return this.manager.onChange(cb); }
set(id: string) { return this.manager.applyTheme(id); }
}
Ensure config loads before first component renders
// app.config.ts (Angular standalone) or app.module.ts providers
import { APP_INITIALIZER, Provider } from '@angular/core';
import { ThemeService } from './theme.service';
export function initTheme(theme: ThemeService) {
return () => theme.initFromUrl('/theme.config.json'); // or initFromScript('theme-config')
}
export const THEME_INIT_PROVIDER: Provider = {
provide: APP_INITIALIZER,
multi: true,
useFactory: initTheme,
deps: [ThemeService]
};
Add THEME_INIT_PROVIDER to your bootstrap providers.
Simple Angular Picker Component
// theme-picker.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-theme-picker',
template: `
<label>
Theme
<select [value]="activeId" (change)="onSelect($any($event.target).value)">
<option *ngFor="let t of themes" [value]="t.id">{{ t.label }}</option>
</select>
</label>
`
})
export class ThemePickerComponent implements OnInit, OnDestroy {
themes: { id: string; label: string; mode: 'light'|'dark' }[] = [];
activeId = '';
private unsub: () => void = () => {};
constructor(private theme: ThemeService) {}
ngOnInit() {
this.themes = this.theme.themes() as any;
this.activeId = this.theme.activeId() || '';
this.unsub = this.theme.onChange(id => this.activeId = id);
}
ngOnDestroy() { this.unsub(); }
onSelect(id: string) { this.theme.set(id); }
}
Angular Service Worker (offline) caching of theme CSS
In ngsw-config.json, add your theme assets so they’re available offline:
{
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/index.html",
"/*.css",
"/*.js",
"/theme.config.json",
"/themes/**"
]
}
}
]
}
Rebuild with ng add @angular/pwa if you haven’t, then deploy.\
Now PWAs work offline and theme CSS stays cached.
7) Non‑Angular PWA (Workbox) hint
If you use Workbox:
// sw.js (simplified)
workbox.routing.registerRoute(
({url}) => url.pathname.startsWith('/themes/'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'theme-css',
plugins: [
new workbox.expiration.ExpirationPlugin({ maxEntries: 30, purgeOnQuotaError: true })
]
})
);
workbox.routing.registerRoute(
({url}) => url.pathname === '/theme.config.json',
new workbox.strategies.NetworkFirst({ cacheName: 'theme-config' })
);
8) Design Notes vs Requirements
- Light & Dark:
ThemeEntry.modeensures both are supported. - ≥3 themes: Config supports any number; example shows 3.
- Prebuilt + app colors: Use
css+ optionalcolors/colorsDark. - Runtime switching:
applyTheme(id)dynamically swaps stylesheets (preload → promote). - Cross‑tab persistence:
localStorage+BroadcastChannel. - Reusable, minimal, neutral: One core class, no UI opinions.
- No flicker: Early‑boot snippet + preloading + seamless swap.
- SPA & PWA: Works with any SPA; SW examples for offline; Angular APP_INITIALIZER.
- Adjustable post‑deploy: Load
/theme.config.jsonor editable inline JSON script. - Local/CDN: URLs are free-form; optional
integrity+crossorigin. - No redundant reloads: Skips if same theme/URLs are active.
- Core separated from UI: Manager has no UI; simple examples for any framework.
- Startup config before rendering: Angular
APP_INITIALIZER, or callinit()before bootstrapping; plus early‑boot snippet ensures visual consistency even before init completes.
9) CSS authoring tips (optional but helpful)
- Define CSS variables in your prebuilt theme files, and scope app styles to
:rootor[data-theme="id"]for per‑theme overrides. - To suppress transition “flashes” during theme switch, you may temporarily disable transitions while swapping:
/* Disable transitions during theme change (optional) */
html.theme-switching * { transition: none !important; }
And briefly add/remove that class around applyTheme if you like.
10) Security with CDN (SRI)
When hosting styles on a CDN, add Subresource Integrity (integrity) and, if needed, crossorigin: "anonymous" in the theme config. ThemeManager will place these attributes on the <link> elements.
11) Testing Checklist
- [ ] Switching themes updates
<html data-theme>and metacolor-scheme - [ ] No stylesheet reload when reselecting current theme
- [ ] Cross-tab/theme persists instantly
- [ ] Offline usage still loads previously used theme (via early-boot + SW cache)
- [ ] Config edits (file replaced) change available themes/order without rebuild
- [ ] CDN-hosted themes load with SRI/CORS
- [ ] Angular boot waits for config (APP_INITIALIZER)
- [ ] No flash/flicker on startup and on theme switch
12) Next steps
If you’d like, tell me:
- Do you prefer inline JSON or a separate
/theme.config.jsonfile? - Which prebuilt theme(s) and app color files do you plan to use?
- Should I generate a ready-to-drop Angular demo (module + component + config), or a plain TS+Vite starter?
I can tailor the code scaffolding to your stack and deliver a small sample repo layout.
Analysis
I have crafted one from scratch based on the same functional requirements and technical requirements, conforming to my design principles for UI, UX and Developer Experience:
Comparing with my hand-crafted sourcecode, this one generated is much more complex, while I haven't tested if the sourcecode can be built successfully and executed properly.
And comparing with "Use Windows Copilot to Craft Web Theme Loader", the requirements has one more technical requirement:
- The config settings must be loaded during app startup before any component is rendered.
And the high level requirement "The implementation may provide friendly integrations for Angular or React" has been changed to "The implementation may provide friendly integrations for Angular".
Both AI code generators have defined class ThemeManager to contain the core algorithm. Windows Copilot crafted 162 lines, and M365 Copilot crafted 327 lines with overall more complex design.
Despite the technical requirement "The config settings must be loaded during app startup before any component is rendered", M365 Copilot still use JSON for config, obviously don't understand that the implicit technical context: a JSON file is inherently loaded asynchronously, and only JSON object in JavaScript can be loaded synchronously so to become the startup settings of the Web app.
I realize that the prompt presented by the requirements does not explicitly state the need for the config to be loaded as JSON object in JavaScript referenced in index.html.
As an experienced developer, when writing codes, I have many implicit contexts in my mind, and have muscle memory of writing codes according to contents and contexts explicit and implicit, along with many background knowledge in mind or in subconscious.
Often only when writing codes, many more prompts could be popping up from my mind. Without writing codes, I could write very little prompts, mostly based on explicit knowledge and visual hints. If I have to write sufficient prompts for the AI code generator to write quality code, my time spent on writing the prompt may be longer than hand-crafting code.
Nevertheless, I will be looking forward to trying again with some dedicated AI code generators, like Claude.AI.
Top comments (0)