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
62 changes: 43 additions & 19 deletions build-tools/tasks/generate-environment.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,69 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Generates the `internal/environment` files for each theme.
// For every theme we write the same set of values in three forms:
// - environment.json raw values
// - environment.js runtime module, e.g. `export var THEME = "default";`
// - environment.d.ts type declarations, e.g. `export const THEME: string;`

const path = require('path');
const { writeFile } = require('../utils/files');
const themes = require('../utils/themes');
const workspace = require('../utils/workspace');

const ALWAYS_VISUAL_REFRESH = process.env.ALWAYS_VISUAL_REFRESH === 'true';
const INCLUDE_ONE_THEME = process.env.INCLUDE_ONE_THEME === 'true';

function writeEnvironmentFile(theme) {
const filepath = 'internal/environment';
const values = {
// The build-time constants exposed to the component source for a given theme.
function getEnvironmentValues(theme) {
return {
PACKAGE_SOURCE: workspace.packageSource,
PACKAGE_VERSION: workspace.packageVersion,
GIT_SHA: workspace.gitCommitVersion,
THEME: theme.name,
SYSTEM: 'core',
ALWAYS_VISUAL_REFRESH: !!theme.alwaysVisualRefresh || ALWAYS_VISUAL_REFRESH,
INCLUDE_ONE_THEME: INCLUDE_ONE_THEME,
INCLUDED_THEMES: theme.includedThemes ?? [],
};
const basePath = path.join(theme.outputPath, filepath);
}

function toTypeScriptType(value) {
if (Array.isArray(value)) {
return 'string[]';
}
if (typeof value === 'boolean') {
return 'boolean';
}
return 'string';
}

// Source for environment.js, e.g. `export var THEME = "default";`
function toModuleSource(values) {
return Object.entries(values)
.map(([name, value]) => `export var ${name} = ${JSON.stringify(value)};`)
.join('\n');
}

// Source for environment.d.ts, e.g. `export const THEME: string;`
function toDeclarationsSource(values) {
return Object.entries(values)
.map(([name, value]) => `export const ${name}: ${toTypeScriptType(value)};`)
.join('\n');
}

function writeEnvironmentFile(theme) {
const values = getEnvironmentValues(theme);
const basePath = path.join(theme.outputPath, 'internal/environment');

console.log('Environment values\n', JSON.stringify(values, null, 2));

writeFile(`${basePath}.json`, JSON.stringify(values, null, 2));
writeFile(
`${basePath}.js`,
Object.entries(values)
.map(([key, value]) => `export var ${key} = ${JSON.stringify(value)};`)
.join('\n')
);
writeFile(
`${basePath}.d.ts`,
Object.keys(values)
.map(key => `export const ${key}: string;`)
.join('\n')
);
writeFile(`${basePath}.js`, toModuleSource(values));
writeFile(`${basePath}.d.ts`, toDeclarationsSource(values));
}

module.exports = function generateEnvironment() {
themes.forEach(theme => writeEnvironmentFile(theme));
themes.forEach(writeEnvironmentFile);

return Promise.resolve();
};
37 changes: 32 additions & 5 deletions build-tools/utils/themes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,36 @@
const path = require('path');
const workspace = require('./workspace');

const INCLUDE_ONE_THEME = process.env.INCLUDE_ONE_THEME === 'true';
// Secondary themes are layered on top of the primary theme as opt-in overrides. Each entry maps a
// theme id to its style-dictionary entry point. To add a new theme, register it here, then include
// it via the THEMES env var.
const SECONDARY_THEME_PATHS = {
'visual-refresh': './visual-refresh-secondary/index.js',
'one-theme': './one-theme/index.js',
};

// Secondary themes included when THEMES is not set. Keeps visual refresh in, one-theme opt-in.
const DEFAULT_THEMES = 'visual-refresh';

// Resolves which secondary themes to compile from the comma-separated THEMES env var, e.g.
// `THEMES=visual-refresh,one-theme`. Throws if a requested theme is not registered above.
function resolveIncludedThemes() {
const themeIds = (process.env.THEMES ?? DEFAULT_THEMES)
.split(',')
.map(id => id.trim())
.filter(Boolean);

for (const id of themeIds) {
if (!SECONDARY_THEME_PATHS[id]) {
const availableThemes = Object.keys(SECONDARY_THEME_PATHS).join(', ');
throw new Error(`Unknown theme "${id}" in THEMES env var. Available themes: ${availableThemes}.`);
}
}

return themeIds;
}

const includedThemes = resolveIncludedThemes();

const themes = [
// This is the default Cloudscape theme, which is best used with Visual Refresh enabled (by default)
Expand All @@ -15,10 +44,8 @@ const themes = [
designTokensPackageJson: { name: '@cloudscape-design/design-tokens' },
outputPath: path.join(workspace.targetPath, 'components'),
primaryThemePath: './classic/index.js',
secondaryThemePaths: [
'./visual-refresh-secondary/index.js',
...(INCLUDE_ONE_THEME ? ['./one-theme/index.js'] : []),
],
includedThemes,
secondaryThemePaths: includedThemes.map(id => SECONDARY_THEME_PATHS[id]),
},
];

Expand Down
3 changes: 1 addition & 2 deletions pages/app/app-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface AppUrlParams {
density: Density;
direction: 'ltr' | 'rtl';
visualRefresh: boolean;
oneTheme: boolean;
theme?: string;
motionDisabled: boolean;
appLayoutWidget: boolean;
mode?: Mode;
Expand All @@ -33,7 +33,6 @@ const appContextDefaults: AppContextType = {
density: Density.Comfortable,
direction: 'ltr',
visualRefresh: THEME === 'default',
oneTheme: false,
motionDisabled: false,
appLayoutWidget: false,
},
Expand Down
6 changes: 4 additions & 2 deletions pages/app/components/header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import clsx from 'clsx';

import AppContext from '../app-context';
Expand All @@ -11,11 +11,13 @@ import styles from './header.scss';

export default function Header({ sticky }: { sticky?: boolean }) {
const { mode } = useContext(AppContext);
// Preserve the query string (theme, density, motion, …) when navigating back to the index.
const { search } = useLocation();
return (
<>
{/* #h selector for compatibility with global navigation */}
<header id="h" className={clsx(styles.header, sticky && styles['header-sticky'])}>
<Link to={`/${mode}/`}>Demo Assets</Link>
<Link to={{ pathname: `/${mode}/`, search }}>Demo Assets</Link>
<ThemeSwitcher />
</header>
</>
Expand Down
6 changes: 4 additions & 2 deletions pages/app/components/index-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';

import { PACKAGE_VERSION } from '~components/internal/environment';
import AwsUiLink from '~components/link';
Expand Down Expand Up @@ -38,10 +38,12 @@ function createPagesTree(pages: string[]) {
}

function TreeItemView({ item }: { item: TreeItem }) {
// Preserve the query string (theme, density, motion, …) when navigating to a page.
const { search } = useLocation();
return (
<li>
{item.href ? (
<Link to={item.href} component={AwsUiLink}>
<Link to={{ pathname: item.href, search }} component={AwsUiLink}>
{item.name}
</Link>
) : (
Expand Down
57 changes: 26 additions & 31 deletions pages/app/components/theme-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,47 @@ import React, { useContext } from 'react';

import { Density, Mode } from '@cloudscape-design/global-styles';

import { ALWAYS_VISUAL_REFRESH, INCLUDE_ONE_THEME } from '~components/internal/environment';
import { ALWAYS_VISUAL_REFRESH } from '~components/internal/environment';
import SpaceBetween from '~components/space-between';

import AppContext from '../app-context';
import { CLASSIC_THEME_ID, includedThemes, resolveActiveTheme } from '../theme-config';

export default function ThemeSwitcher() {
const { mode, urlParams, setUrlParams, setMode } = useContext(AppContext);

function activateTheme(theme: 'visualRefresh' | 'oneTheme' | 'classic') {
setUrlParams({
visualRefresh: theme === 'visualRefresh',
oneTheme: theme === 'oneTheme',
});
const activeTheme = resolveActiveTheme(urlParams.theme, Boolean(urlParams.visualRefresh));

function activateTheme(themeId: string) {
setUrlParams({ theme: themeId });
window.location.reload();
}

const vrSwitchProps: React.InputHTMLAttributes<HTMLInputElement> = {
id: 'visual-refresh-toggle',
type: 'checkbox',
};

if (ALWAYS_VISUAL_REFRESH) {
vrSwitchProps.checked = true;
vrSwitchProps.readOnly = true;
} else {
vrSwitchProps.checked = urlParams.visualRefresh && !urlParams.oneTheme;
vrSwitchProps.onChange = event => activateTheme(event.target.checked ? 'visualRefresh' : 'classic');
}
// Built from the themes compiled into this build. Classic is the baseline, except in
// always-visual-refresh builds where visual refresh is forced on and classic isn't selectable.
const themeOptions = [
...(ALWAYS_VISUAL_REFRESH ? [] : [{ id: CLASSIC_THEME_ID, label: 'Classic' }]),
...includedThemes,
];
const selectedThemeId = activeTheme?.id ?? (ALWAYS_VISUAL_REFRESH ? 'visual-refresh' : CLASSIC_THEME_ID);

return (
<SpaceBetween direction="horizontal" size="xs">
<label>
<input {...vrSwitchProps} />
Visual refresh
Theme{' '}
<select
id="theme-selector"
value={selectedThemeId}
disabled={ALWAYS_VISUAL_REFRESH}
onChange={event => activateTheme(event.target.value)}
>
{themeOptions.map(theme => (
<option key={theme.id} value={theme.id}>
{theme.label}
</option>
))}
</select>
</label>
{INCLUDE_ONE_THEME && (
<label>
<input
id="one-theme-toggle"
type="checkbox"
checked={urlParams.oneTheme}
onChange={event => activateTheme(event.target.checked ? 'oneTheme' : 'classic')}
/>
One theme
</label>
)}
<label>
<input
id="mode-toggle"
Expand Down
19 changes: 10 additions & 9 deletions pages/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Header from './components/header';
import IndexPage from './components/index-page';
import PageView from './components/page-view';
import StrictModeWrapper from './components/strict-mode-wrapper';
import { includedThemes, resolveActiveTheme } from './theme-config';

// import font-size reset and Ember font
import '@cloudscape-design/global-styles/index.css';
Expand Down Expand Up @@ -95,12 +96,12 @@ function App() {
}

const history = createHashHistory();
const { direction, visualRefresh, oneTheme, appLayoutWidget, appLayoutToolbar, appLayoutDelayedWidget } = parseQuery(
history.location.search
);
const query = parseQuery(history.location.search);
const { direction, appLayoutWidget, appLayoutToolbar, appLayoutDelayedWidget } = query;

const activeTheme = resolveActiveTheme(query.theme, Boolean(query.visualRefresh));

// The VR class needs to be set before any React rendering occurs.
window[awsuiVisualRefreshFlag] = () => visualRefresh && !oneTheme;
window[awsuiVisualRefreshFlag] = () => activeTheme?.id === 'visual-refresh';
if (!window[awsuiGlobalFlagsSymbol]) {
window[awsuiGlobalFlagsSymbol] = {};
}
Expand All @@ -111,10 +112,10 @@ window[awsuiGlobalFlagsSymbol].appLayoutWidget = appLayoutWidget;
window[awsuiGlobalFlagsSymbol].appLayoutToolbar = appLayoutToolbar;
window[awsuiCustomFlagsSymbol].appLayoutDelayedWidget = appLayoutDelayedWidget;

// Visual Refresh and One Theme are mutually exclusive — manage both classes here so they never coexist.
// useRuntimeVisualRefresh() detects .awsui-visual-refresh on body and short-circuits before its Symbol fallback.
document.body.classList.toggle('awsui-one-theme', oneTheme);
document.body.classList.toggle('awsui-visual-refresh', visualRefresh && !oneTheme);
// Apply only the active theme's body class so themes don't coexist.
for (const theme of includedThemes) {
document.body.classList.toggle(theme.bodyClass, theme.id === activeTheme?.id);
}

// Apply the direction value to the HTML element dir attribute
document.documentElement.setAttribute('dir', direction);
Expand Down
36 changes: 36 additions & 0 deletions pages/app/theme-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { INCLUDED_THEMES } from '~components/internal/environment';

export interface SelectableTheme {
id: string;
label: string;
bodyClass: string;
}

const THEME_METADATA: Record<string, { label?: string }> = {
'visual-refresh': { label: 'Visual refresh' },
'one-theme': { label: 'One theme' },
};

// The classic baseline (no secondary theme) used as the `theme` URL param value.
export const CLASSIC_THEME_ID = 'classic';

export const includedThemes: SelectableTheme[] = INCLUDED_THEMES.map(id => ({
id,
label: THEME_METADATA[id]?.label ?? id,
bodyClass: `awsui-${id}`,
}));

function findIncludedTheme(id: string): SelectableTheme | null {
return includedThemes.find(theme => theme.id === id) ?? null;
}

// Resolves the active theme, preferring the canonical `theme` URL param. Falls back to the legacy
// `visualRefresh` boolean param, kept for backwards compatibility.
export function resolveActiveTheme(themeParam: string | undefined, isVisualRefresh: boolean): SelectableTheme | null {
if (themeParam) {
return themeParam === CLASSIC_THEME_ID ? null : findIncludedTheme(themeParam);
}
return isVisualRefresh ? findIncludedTheme('visual-refresh') : null;
}
2 changes: 2 additions & 0 deletions src/internal/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export const PACKAGE_VERSION: string;
export const GIT_SHA: string;
/** Indicates that the current theme is always in visual refresh mode. */
export const ALWAYS_VISUAL_REFRESH: boolean;
/** Secondary themes compiled into this build (controlled by the THEMES env var). */
export const INCLUDED_THEMES: string[];
Loading