Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions pages/code-editor/cloudscape-theme.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';

import CodeEditor, { CodeEditorProps } from '~components/code-editor';
import SpaceBetween from '~components/space-between';

import ScreenshotArea from '../utils/screenshot-area';
import { i18nStrings } from './base-props';
import { sayHelloSample } from './code-samples';

import 'ace-builds/css/ace.css';

// WIP (v0) demo of the opt-in Cloudscape theme. Its colors are token-driven, so
// the same theme is used for both light and dark visual modes and adapts
// automatically. Toggle the surrounding page to dark mode to see it adapt.
const cloudscapeThemes: CodeEditorProps.AvailableThemes = {
light: ['cloudscape', 'dawn'],
dark: ['cloudscape', 'tomorrow_night_bright'],
};

function DemoCodeEditor({ ace, loading }: { ace: any; loading: boolean }) {
const [preferences, setPreferences] = useState<Partial<CodeEditorProps.Preferences>>({
theme: 'cloudscape',
wrapLines: true,
});
return (
<CodeEditor
ace={ace}
value={sayHelloSample}
language="javascript"
preferences={preferences}
onPreferencesChange={e => setPreferences(e.detail)}
loading={loading}
themes={cloudscapeThemes}
i18nStrings={i18nStrings}
/>
);
}

export default function CloudscapeThemePage() {
const [ace, setAce] = useState<any>();

useEffect(() => {
import('ace-builds').then(loadedAce => {
loadedAce.config.set('basePath', './ace/');
loadedAce.config.set('themePath', './ace/');
loadedAce.config.set('modePath', './ace/');
loadedAce.config.set('workerPath', './ace/');
loadedAce.config.set('useStrictCSP', true);
setAce(loadedAce);
});
}, []);

return (
<article>
<h1>Code editor - Cloudscape theme (WIP)</h1>
<p>
The <code>cloudscape</code> theme derives its colors from Cloudscape design tokens and adapts to the active
visual mode. Switch the page to dark mode to see the same theme adapt.
</p>
<ScreenshotArea style={{ maxWidth: 960 }}>
<SpaceBetween size="xs">
<DemoCodeEditor ace={ace} loading={!ace} />
</SpaceBetween>
</ScreenshotArea>
</article>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8702,6 +8702,7 @@ The event \`detail\` contains the value of all the preferences as submitted by t
"twilight",
"vibrant_ink",
"cloud_editor_dark",
"cloudscape",
],
},
"name": "theme",
Expand Down Expand Up @@ -9192,7 +9193,9 @@ If set to \`undefined\`, the component uses the following default value:
}
\`\`\`

You can use any theme provided by Ace.",
You can use any theme provided by Ace. Additionally, you can set \`theme\` to
\`'cloudscape'\` to use the opt-in Cloudscape theme, whose colors are derived
from Cloudscape design tokens and adapt automatically to the active visual mode.",
"inlineType": {
"name": "Partial<CodeEditorProps.Preferences>",
"properties": [
Expand Down Expand Up @@ -9241,6 +9244,7 @@ You can use any theme provided by Ace.",
"twilight",
"vibrant_ink",
"cloud_editor_dark",
"cloudscape",
],
},
"name": "theme",
Expand Down
55 changes: 55 additions & 0 deletions src/code-editor/__tests__/cloudscape-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import {
CLOUDSCAPE_ACE_THEME_CSS_CLASS,
CLOUDSCAPE_ACE_THEME_ID,
defineCloudscapeAceTheme,
isCloudscapeAceTheme,
} from '../../../lib/components/code-editor/cloudscape-theme';

describe('isCloudscapeAceTheme', () => {
it('returns true only for the cloudscape theme id', () => {
expect(isCloudscapeAceTheme(CLOUDSCAPE_ACE_THEME_ID)).toBe(true);
expect(isCloudscapeAceTheme('cloudscape')).toBe(true);
});

it('returns false for other themes and undefined', () => {
expect(isCloudscapeAceTheme('dawn')).toBe(false);
expect(isCloudscapeAceTheme('cloud_editor_dark')).toBe(false);
expect(isCloudscapeAceTheme(undefined)).toBe(false);
});
});

describe('defineCloudscapeAceTheme', () => {
it('registers an ace/theme/cloudscape module with token-driven (empty) cssText', () => {
const ace = { define: jest.fn() };
defineCloudscapeAceTheme(ace);

expect(ace.define).toHaveBeenCalledTimes(1);
const [moduleId, deps, factory] = ace.define.mock.calls[0];
expect(moduleId).toBe('ace/theme/cloudscape');
expect(deps).toEqual(expect.arrayContaining(['require', 'exports', 'module']));

const exports: Record<string, unknown> = {};
factory(() => undefined, exports, {});
expect(exports.cssClass).toBe(CLOUDSCAPE_ACE_THEME_CSS_CLASS);
expect(exports.isDark).toBe(false);
// Colors are supplied by the Cloudscape stylesheet, so no inline CSS.
expect(exports.cssText).toBe('');
expect(exports.$id).toBe('ace/theme/cloudscape');
});

it('registers the theme at most once per ace instance', () => {
const ace = { define: jest.fn() };
defineCloudscapeAceTheme(ace);
defineCloudscapeAceTheme(ace);
expect(ace.define).toHaveBeenCalledTimes(1);
});

it('is a no-op for an ace instance without a define function', () => {
expect(() => defineCloudscapeAceTheme({})).not.toThrow();
expect(() => defineCloudscapeAceTheme(null)).not.toThrow();
expect(() => defineCloudscapeAceTheme(undefined)).not.toThrow();
});
});
5 changes: 5 additions & 0 deletions src/code-editor/__tests__/code-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ describe('Code editor component', () => {
expect(editorMock.setTheme).toHaveBeenLastCalledWith('ace/theme/cloud_editor_dark');
});

it('applies the opt-in Cloudscape theme when selected via preferences', () => {
renderCodeEditor({ preferences: { theme: 'cloudscape', wrapLines: true } });
expect(editorMock.setTheme).toHaveBeenLastCalledWith('ace/theme/cloudscape');
});

it('detects alternative dark mode class', () => {
render(
<div className="awsui-dark-mode">
Expand Down
124 changes: 124 additions & 0 deletions src/code-editor/cloudscape-ace-theme.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

/*
WIP (v0): Cloudscape-aligned Ace theme.

All colors are driven by Cloudscape design tokens. Because these tokens resolve
to CSS custom properties that change with the active visual mode, this single
theme automatically adapts to both light and dark mode. The `.ace-cloudscape`
class is applied by Ace when the `cloudscape` theme is selected (see
`cloudscape-theme.ts`).

Syntax colors use the Cloudscape charts palette because it provides a set of
perceptually distinct, visual-mode-aware hues. This is a first-cut mapping;
a dedicated code-editor syntax token set is tracked as follow-up work.
*/

@use '../internal/styles/tokens' as awsui;

/* stylelint-disable selector-combinator-disallowed-list, @cloudscape-design/no-implicit-descendant */

.code-editor :global .ace_editor.ace-cloudscape {
background-color: awsui.$color-background-container-content;
color: awsui.$color-text-body-default;

.ace_gutter {
background-color: awsui.$color-background-code-editor-gutter-default;
color: awsui.$color-text-code-editor-gutter-default;
}

.ace_print-margin {
inline-size: 1px;
background-color: awsui.$color-border-divider-default;
}

.ace_cursor {
color: awsui.$color-text-body-default;
}

.ace_marker-layer .ace_selection {
background: awsui.$color-background-item-selected;
}

.ace_marker-layer .ace_selected-word {
border-block: 1px solid awsui.$color-border-item-focused;
border-inline: 1px solid awsui.$color-border-item-focused;
}

// Keep the active-line border adaptive across visual modes (the shared
// ace-editor.scss active-line border relies on the `ace_dark` marker class,
// which this token-driven theme does not set).
.ace_marker-layer > .ace_active-line {
border-block-start: 1px solid awsui.$color-border-divider-default;
border-block-end: 1px solid awsui.$color-border-divider-default;
}

.ace_indent-guide {
border-inline-end: 1px solid awsui.$color-border-divider-default;
}

/* Syntax tokens */
.ace_comment {
color: awsui.$color-text-body-secondary;
font-style: italic;
}

.ace_keyword,
.ace_storage,
.ace_meta {
color: awsui.$color-charts-purple-700;
}

.ace_keyword.ace_operator,
.ace_punctuation,
.ace_punctuation.ace_operator {
color: awsui.$color-text-body-secondary;
}

.ace_string,
.ace_string.ace_regexp {
color: awsui.$color-charts-green-700;
}

.ace_constant,
.ace_constant.ace_numeric,
.ace_constant.ace_language,
.ace_constant.ace_character,
.ace_constant.ace_other {
color: awsui.$color-charts-orange-700;
}

.ace_support.ace_function,
.ace_entity.ace_name.ace_function {
color: awsui.$color-charts-blue-1-700;
}

.ace_variable {
color: awsui.$color-text-body-default;
}

.ace_support.ace_type,
.ace_support.ace_class,
.ace_storage.ace_type,
.ace_entity.ace_name.ace_class {
color: awsui.$color-charts-teal-700;
}

.ace_entity.ace_name.ace_tag,
.ace_meta.ace_tag {
color: awsui.$color-charts-red-700;
}

.ace_entity.ace_other.ace_attribute-name {
color: awsui.$color-charts-blue-2-700;
}

.ace_invalid {
color: awsui.$color-text-status-error;
}
}

/* stylelint-enable selector-combinator-disallowed-list, @cloudscape-design/no-implicit-descendant */
72 changes: 72 additions & 0 deletions src/code-editor/cloudscape-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* WIP (v0): Cloudscape-aligned Ace theme.
*
* Instead of shipping yet another Ace theme with hard-coded hex colors, this
* module registers a lightweight Ace theme whose colors are driven entirely by
* Cloudscape design tokens. The theme registers an empty `cssText` and relies
* on a `cssClass` (`ace-cloudscape`) that the Cloudscape stylesheet
* (`cloudscape-ace-theme.scss`) targets. Because the token custom properties
* change with the active visual mode, the same single theme automatically
* adapts to light and dark mode instead of requiring separate light/dark
* variants.
*
* This is additive and opt-in: consumers get it only when they select the
* `'cloudscape'` theme (via `preferences.theme`) and, if they want it available
* in the preferences modal, add it to their `themes` prop.
*/

// The Ace theme id (used as `ace/theme/cloudscape`) and the value accepted by
// `CodeEditorProps.Preferences.theme`.
export const CLOUDSCAPE_ACE_THEME_ID = 'cloudscape';

// The CSS class Ace applies to the editor container for this theme. The
// Cloudscape stylesheet keys all token-driven colors off this class.
export const CLOUDSCAPE_ACE_THEME_CSS_CLASS = 'ace-cloudscape';

const ACE_MODULE_ID = `ace/theme/${CLOUDSCAPE_ACE_THEME_ID}`;

// Track which Ace instances we've already registered the theme on. Consumers
// provide their own `ace` object, so this keeps registration idempotent per
// instance without leaking across (potentially multiple) Ace builds.
const registeredInstances = new WeakSet<object>();

/**
* Registers the Cloudscape Ace theme on the provided Ace instance. Safe to call
* multiple times; registration happens at most once per Ace instance. Must be
* called before `editor.setTheme('ace/theme/cloudscape')` so Ace can resolve
* the module synchronously.
*/
export function defineCloudscapeAceTheme(ace: any): void {
if (!ace || typeof ace.define !== 'function') {
return;
}
if (registeredInstances.has(ace)) {
return;
}

ace.define(
ACE_MODULE_ID,
['require', 'exports', 'module'],
function (_require: unknown, exports: Record<string, unknown>) {
// `isDark` only controls whether Ace adds the `ace_dark` marker class.
// Our colors come from visual-mode-aware tokens, so we don't depend on it.
exports.isDark = false;
exports.cssClass = CLOUDSCAPE_ACE_THEME_CSS_CLASS;
// Colors are provided by the Cloudscape stylesheet, so no inline CSS here.
exports.cssText = '';
exports.$id = ACE_MODULE_ID;
}
);

registeredInstances.add(ace);
}

/**
* Returns true when the given theme value is the opt-in Cloudscape theme.
*/
export function isCloudscapeAceTheme(theme: string | undefined): boolean {
return theme === CLOUDSCAPE_ACE_THEME_ID;
}
2 changes: 1 addition & 1 deletion src/code-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const CodeEditor = forwardRef((props: CodeEditorProps, ref: React.Ref<CodeEditor
useSyncEditorWrapLines(editor, preferences?.wrapLines);

const defaultTheme = getDefaultTheme(mode, themes);
useSyncEditorTheme(editor, preferences?.theme ?? defaultTheme);
useSyncEditorTheme(ace, editor, preferences?.theme ?? defaultTheme);

// Change listeners
useChangeEffect(editor, props.onChange, props.onDelayedChange);
Expand Down
12 changes: 10 additions & 2 deletions src/code-editor/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export interface CodeEditorProps extends BaseComponentProps, FormFieldControlPro
* }
* ```
*
* You can use any theme provided by Ace.
* You can use any theme provided by Ace. Additionally, you can set `theme` to
* `'cloudscape'` to use the opt-in Cloudscape theme, whose colors are derived
* from Cloudscape design tokens and adapt automatically to the active visual mode.
*/
preferences?: Partial<CodeEditorProps.Preferences>;

Expand Down Expand Up @@ -132,7 +134,13 @@ type BuiltInLanguage = (typeof AceModes)[number]['value'];

export namespace CodeEditorProps {
export type Language = LiteralUnion<BuiltInLanguage, string>;
export type Theme = (typeof LightThemes)[number]['value'] | (typeof DarkThemes)[number]['value'];
export type BuiltInTheme = (typeof LightThemes)[number]['value'] | (typeof DarkThemes)[number]['value'];
/**
* The opt-in Cloudscape theme. Its colors are derived from Cloudscape design
* tokens and adapt automatically to the active light/dark visual mode.
*/
export type CloudscapeTheme = 'cloudscape';
export type Theme = BuiltInTheme | CloudscapeTheme;

export interface AvailableThemes {
light: ReadonlyArray<string>;
Expand Down
Loading
Loading