diff --git a/packages/base/brand-guide.gts b/packages/base/brand-guide.gts index f84f2e19722..5a18bda1e7f 100644 --- a/packages/base/brand-guide.gts +++ b/packages/base/brand-guide.gts @@ -19,7 +19,6 @@ import { BasicFitted, CopyButton, Swatch, - Button, FieldContainer, GridContainer, } from '@cardstack/boxel-ui/components'; @@ -48,6 +47,8 @@ import { ThemeDashboardHeader, NavSection, PreviewPills, + FontsPreview, + ThemeSpecimens, CardContainerCss, ThemeImporter, ResetButton, @@ -223,47 +224,13 @@ class BrandGuideIsolated extends Component { {{/unless}} <@fields.typography class='brand-typography-preview' /> + {{else if (eq section.id 'fonts')}} + {{else if (eq section.id 'ui-components')}} - - -
- -
-
- -
- -
-
- -
- -
-
-
- - -
-

Lorem ipsum dolor sit amet, consectetur adipiscing - elit, sed do eiusmod tempor incididunt ut labore et - dolore magna aliqua. Ut enim ad minim veniam, quis - nostrud exercitation ullamco laboris nisi ut aliquip - ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu - fugiat nulla pariatur. Excepteur sint occaecat - cupidatat non proident, sunt in culpa qui officia - deserunt mollit anim id est laborum. -

-
-
- - + {{else if (eq section.id 'visual-dna')}}
{{#if this.editMode}} @@ -344,7 +311,10 @@ class BrandGuideIsolated extends Component {
{{else if (eq section.id 'card-container-css')}} {{#if @model.cssVariables}} - +

Brand Guide-Specific Variables

@@ -641,46 +611,13 @@ class BrandGuideIsolated extends Component { :deep(h3) { font-size: var(--boxel-font-size-md); } - .cta-grid { - margin-bottom: var(--boxel-sp-xl); - grid-template-columns: repeat(3, 1fr); - } - .preview-container { - display: flex; - align-items: center; - justify-content: center; - max-width: 100%; - background-color: var(--muted); - color: var(--dsr-foreground); - border-radius: var(--boxel-border-radius); - overflow: hidden; - } - .cta-preview-container { - min-height: 7.5rem; - } - .ui-grid { - grid-template-columns: repeat(2, 1fr); - } - .ui-preview-container { - height: 11.25rem; - align-items: flex-start; - padding: var(--boxel-sp-xxl); - overflow: auto; - } - .photo-container { - background-image: url('https://app-assets-cardstack.s3.us-east-1.amazonaws.com/%40cardstack/boxel/images/placeholders/photo-placeholder.png'); - background-position: center; - background-repeat: no-repeat; - background-size: cover; - } /* typography section */ .typography-grid { grid-template-columns: 1fr 1fr; } @container (width <= 768px) { - .typography-grid, - .cta-grid { + .typography-grid { grid-template-columns: 1fr; } } @@ -1010,6 +947,11 @@ class BrandGuideIsolated extends Component { title: 'Typography', fieldName: 'typography', }, + { + id: 'fonts', + navTitle: 'Fonts', + title: 'Fonts', + }, { id: 'mark-usage', navTitle: 'Mark Usage', @@ -1043,7 +985,8 @@ class BrandGuideIsolated extends Component { this.sections.filter( (section) => section.id !== 'card-container-css' && - section.id !== 'ui-components', + section.id !== 'ui-components' && + section.id !== 'fonts', ), this.hasThemeCss, ); @@ -1063,6 +1006,10 @@ class BrandGuideIsolated extends Component { return Boolean(this.args.model.cssVariables); } + if (section.id === 'fonts') { + return this.hasFontsContent; + } + if (section.id === 'custom-css') { return this.hasCustomVariables; } @@ -1097,6 +1044,14 @@ class BrandGuideIsolated extends Component { }); } + private get hasFontsContent() { + let model = this.args.model; + return Boolean( + model.fontStacksFor?.(this.isDarkMode).some((font) => font.stack) || + model.cssImports?.length, + ); + } + private get hasBrandPaletteContent() { return ( this.args.model?.brandColorPalette?.length || diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 3050ff9f99a..71e4b6c6610 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -3426,26 +3426,65 @@ export class CSSField extends TextAreaField { --border, color-mix(in oklab, var(--field-fg) 20%, var(--field-bg)) ); + --field-fade: 1.5rem; position: relative; + background-color: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius, var(--boxel-border-radius)); + overflow: hidden; } .css-field-copy-button { position: absolute; top: var(--boxel-sp-xs); right: var(--boxel-sp-xs); + z-index: 1; } .css-field { margin-block: 0; padding: var(--boxel-sp); - background-color: var(--field-bg); - border: 1px solid var(--field-border); - border-radius: var(--radius, var(--boxel-border-radius)); color: var(--field-fg); font-family: var( --font-mono, var(--boxel-monospace-font-family, monospace) ); font-size: var(--boxel-font-size-xs); - overflow-x: auto; + max-height: var(--css-field-max-height, none); + overflow: auto; + /* iOS-style edge fades: text dissolves into the background at an + edge only while more content lies beyond it. Each mask layer is + taller than the box by the fade height, so shifting it up by that + amount parks its fade offscreen; the scroll timeline slides them + into view. Base positions show no fade, which is also what + browsers without scroll-driven animations render. */ + mask-image: + linear-gradient(to bottom, transparent, black var(--field-fade)), + linear-gradient(to top, transparent, black var(--field-fade)); + mask-size: 100% calc(100% + var(--field-fade)); + mask-repeat: no-repeat; + mask-composite: intersect; + mask-position: + 0 calc(-1 * var(--field-fade)), + 0 0; + animation: css-field-edge-fade linear both; + animation-timeline: scroll(self); + } + @keyframes css-field-edge-fade { + 0% { + mask-position: + 0 calc(-1 * var(--field-fade)), + 0 calc(-1 * var(--field-fade)); + } + 8%, + 92% { + mask-position: + 0 0, + 0 calc(-1 * var(--field-fade)); + } + 100% { + mask-position: + 0 0, + 0 0; + } } .css-field::placeholder { opacity: 0.5; diff --git a/packages/base/default-templates/theme-dashboard.gts b/packages/base/default-templates/theme-dashboard.gts index 3a83d5b44ad..b576acfa172 100644 --- a/packages/base/default-templates/theme-dashboard.gts +++ b/packages/base/default-templates/theme-dashboard.gts @@ -1,6 +1,6 @@ import { tracked } from '@glimmer/tracking'; import GlimmerComponent from '@glimmer/component'; -import { fn } from '@ember/helper'; +import { concat, fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { action } from '@ember/object'; import { guidFor } from '@ember/object/internals'; @@ -23,7 +23,10 @@ import { BoxelInput, Menu as BoxelMenu, Pill, + ProgressBar, Switch, + CopyButton, + BoxelSelect, } from '@cardstack/boxel-ui/components'; import { and, @@ -31,6 +34,7 @@ import { cn, eq, MenuItem, + sanitizeHtmlSafe, themeScope, themeScopedCss, } from '@cardstack/boxel-ui/helpers'; @@ -38,6 +42,7 @@ import { import { DEFAULT_THEME_SCALE, FontPreviews, + TokenPill, } from '../structured-theme-variables'; import CardInfoTemplates from '../default-templates/card-info'; @@ -245,6 +250,7 @@ export class ThemeImporter extends GlimmerComponent<{ export class CardContainerCss extends GlimmerComponent<{ Args: { cssVariables: string; + isDarkMode?: boolean; }; Element: HTMLElement; }> { @@ -364,6 +370,8 @@ export class CardContainerCss extends GlimmerComponent<{ '--boxel-subheading', '--boxel-section-heading', '--boxel-caption', + '--boxel-ui-label', + '--boxel-eyebrow', ]); } @@ -415,6 +423,10 @@ export class CardContainerCss extends GlimmerComponent<{ --boxel-* internals with Boxel defaults as fallbacks.

+

Spacing

@@ -480,6 +492,8 @@ export class CardContainerCss extends GlimmerComponent<{
--boxel-subheading-*
h3
--boxel-body-*
p
--boxel-caption-*
small
+
--boxel-ui-label-*
UI labels, control text
+
--boxel-eyebrow-*
kicker above a title
@@ -1052,12 +1066,16 @@ export class NavBar extends GlimmerComponent<{ max-height: 50vh; overflow-y: auto; } + /* the bar spans the card; its items keep to the content measure */ .nav-container { position: relative; flex-grow: 1; display: flex; gap: var(--boxel-sp-xs); overflow: hidden; + width: 100%; + max-width: var(--dsr-content-max-width); + margin: 0 auto; padding-inline: var(--boxel-sp-xl); } @@ -1123,40 +1141,42 @@ export class ThemeDashboardHeader extends GlimmerComponent<{ class='theme-dashboard-header {{if @mode @mode "isolated"}}' ...attributes > - {{#if (and (eq @mode 'edit') (bool @model) (bool @fields))}} - - - <@fields.version /> - - {{else}} - {{#if (has-block 'meta')}} - {{yield to='meta'}} +
+ {{#if (and (eq @mode 'edit') (bool @model) (bool @fields))}} + + + <@fields.version /> + {{else}} -
- - {{if @metaLabel @metaLabel 'Style Guide'}} - - - Version - {{if @version @version '1.0'}} - -
- {{/if}} -

{{@title}}

- {{#if @description}} -

{{@description}}

+ {{#if (has-block 'meta')}} + {{yield to='meta'}} + {{else}} +
+ + {{if @metaLabel @metaLabel 'Style Guide'}} + + + Version + {{if @version @version '1.0'}} + +
+ {{/if}} +

{{@title}}

+ {{#if @description}} +

{{@description}}

+ {{/if}} {{/if}} - {{/if}} - {{yield}} + {{yield}} +
+ +} + +const SPECIMEN_TABS = [ + { id: 'surfaces', label: 'Surfaces & Ink' }, + { id: 'controls', label: 'Controls' }, + { id: 'dashboard', label: 'Dashboard' }, + { id: 'reading', label: 'Editorial' }, +] as const; + +type SpecimenTabId = (typeof SPECIMEN_TABS)[number]['id']; + +const SPECIMEN_ROWS = [ + { name: 'Website redesign', status: 'success', label: 'On track' }, + { name: 'Quarterly report', status: 'warning', label: 'At risk' }, + { name: 'Onboarding guide', status: 'info', label: 'In review' }, + { name: 'Vendor contract', status: 'attention', label: 'Needs sign-off' }, +]; + +const INKS = [ + 'primary', + 'secondary', + 'accent', + 'destructive', + 'success', + 'warning', + 'info', + 'attention', +]; + +const SELECT_OPTIONS = ['Draft', 'In review', 'Published', 'Archived']; + +const CHART_BARS = [62, 84, 45, 91, 70].map((height, index) => ({ + className: `sp-bar sp-bar--${index + 1}`, + style: sanitizeHtmlSafe(`height: ${height}%`), +})); + +// Prerendered HTML is cached, must be deterministic for unchanged content, and +// can be inserted into a document more than once, so it carries no ids: the +// panels are named by aria-label alone and the fragment is inert anyway. Live +// renders link tabs to panels with a random per-instance token, unique even +// across the copies of one card open in different stacks. The host's render +// page marks its output container with `data-prerender`. +function isPrerendering(el: Element): boolean { + return Boolean(el.closest('[data-prerender]')); +} + +// Realistic scenes built from boxel-ui, so a theme is judged on a page rather +// than on isolated swatches. Every panel stays in the DOM (inactive ones are +// hidden) so tests and prerender see the full set. +export class ThemeSpecimens extends GlimmerComponent<{ + Element: HTMLElement; +}> { + @tracked private activeTab: SpecimenTabId = 'surfaces'; + private uid = `specimens-${Math.random().toString(36).slice(2, 10)}`; + + // ids are stamped after insert rather than rendered, so the template output + // stays id-free where it will be serialized + private linkPanel = modifier((el: HTMLElement, [key]: [SpecimenTabId]) => { + if (!isPrerendering(el)) { + el.id = `${this.uid}-${key}`; + } + }); + + private linkTab = modifier((el: HTMLElement, [key]: [SpecimenTabId]) => { + if (!isPrerendering(el)) { + el.setAttribute('aria-controls', `${this.uid}-${key}`); + } + }); + @tracked private switchOn = true; + @tracked private selected: string | null = SELECT_OPTIONS[1]; + private selectOptions = SELECT_OPTIONS; + private tabs = SPECIMEN_TABS; + private rows = SPECIMEN_ROWS; + private inks = INKS; + private bars = CHART_BARS; + + private isActive = (id: SpecimenTabId) => this.activeTab === id; + private isHidden = (id: SpecimenTabId) => this.activeTab !== id; + + @action private selectTab(id: SpecimenTabId) { + this.activeTab = id; + } + + @action private toggleSwitch(isEnabled: boolean) { + this.switchOn = isEnabled; + } + + @action private select(option: string | null) { + this.selected = option; + } + + +} + +// Font previews beside the CSS import list, used by the visualizer and by the +// Brand Guide's Fonts section +export class FontsPreview extends GlimmerComponent<{ + Args: { + fontStack?: { label: string; stack?: string }[]; + cssImports?: string[] | null; + }; + Element: HTMLElement; +}> { + +} + export class ThemeVisualizer extends GlimmerComponent<{ Args: { fontStack?: { label: string; stack?: string }[]; @@ -1329,37 +2349,10 @@ export class ThemeVisualizer extends GlimmerComponent<{ {{else if this.showFontsSection}}

Fonts

- - {{#if @cssImports.length}} - - - - {{else if this.hasNoImports}} -

No web fonts referenced.

- {{/if}} +
{{/if}} {{#if (has-block 'typography')}} @@ -1368,48 +2361,10 @@ export class ThemeVisualizer extends GlimmerComponent<{ {{yield to='typography'}}
{{/if}} - {{#unless @editMode}} -
-

Components

-
- - - - - -

Sample Card

-

- Card component showcasing background, borders, and shadows - from the theme system. -

-
-
-
-
- {{/unless}} +
+

Components

+ +
@@ -1456,57 +2411,10 @@ export class ThemeVisualizer extends GlimmerComponent<{ border-bottom: var(--boxel-border); margin-bottom: var(--boxel-sp-xl); } - .structured-theme-component-samples { - display: flex; - flex-wrap: wrap; - gap: var(--boxel-sp); - align-items: flex-start; - } .visualizer-preview-pills { margin-bottom: var(--boxel-sp); } } - .structured-theme-component-sample-card { - background-color: var(--card); - color: var(--card-foreground); - } - .css-imports-preview { - margin-top: var(--boxel-sp-xl); - } - .font-import-links { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--boxel-sp-2xs); - } - .font-import-link { - display: flex; - align-items: baseline; - gap: var(--boxel-sp-2xs); - padding: var(--boxel-sp-2xs) var(--boxel-sp-xs); - background-color: var(--muted); - color: var(--muted-foreground); - border: 1px solid var(--border); - border-radius: var(--boxel-border-radius-sm); - font-family: var(--font-mono); - font-size: var(--boxel-font-size-xs); - text-decoration: none; - overflow-wrap: anywhere; - } - .font-import-link:hover, - .font-import-link:focus-visible { - text-decoration: underline; - } - .font-import-link-icon { - flex-shrink: 0; - align-self: center; - } - .font-import-empty { - margin: var(--boxel-sp) 0 0; - color: var(--muted-foreground); - } } @@ -1554,6 +2462,7 @@ export class ThemeDashboard extends GlimmerComponent<{ data-theme-dashboard data-boxel-theme-scope={{if @themeCss this.themeScopeId}} ...attributes + data-test-theme-dashboard > {{#if @themeCss}} {{! template-lint-disable require-scoped-style }} @@ -1598,6 +2507,11 @@ export class ThemeDashboard extends GlimmerComponent<{ height: 100%; } .detailed-style-reference { + /* one measure for the header, content, and footer columns; the + theme card opens in the wide stack format, so this is sized for it */ + --dsr-content-max-width: 90rem; + /* long generated CSS scrolls inside its block instead of stretching the page */ + --css-field-max-height: 40vh; display: flex; flex-direction: column; height: 100%; @@ -1623,7 +2537,7 @@ export class ThemeDashboard extends GlimmerComponent<{ display: flex; flex-direction: column; width: 100%; - max-width: 56rem; + max-width: var(--dsr-content-max-width); margin: 0 auto; padding: var(--boxel-sp-3xl) var(--boxel-sp-xl); counter-reset: section; @@ -1637,7 +2551,7 @@ export class ThemeDashboard extends GlimmerComponent<{ color: var(--muted-foreground); } .footer-content { - max-width: 56rem; + max-width: var(--dsr-content-max-width); margin: 0 auto; text-align: center; } diff --git a/packages/base/detailed-style-reference.gts b/packages/base/detailed-style-reference.gts index 556df320a90..861d6571638 100644 --- a/packages/base/detailed-style-reference.gts +++ b/packages/base/detailed-style-reference.gts @@ -320,7 +320,10 @@ class Isolated extends Component { {{else if (eq section.id 'card-container-css')}} {{#if @model.cssVariables}} - + {{/if}} {{else if (eq section.id 'import-css')}} {{! the cardInfo editor in the header owns the name and diff --git a/packages/base/structured-theme-variables.gts b/packages/base/structured-theme-variables.gts index 643c41f55bc..cbfeb8da363 100644 --- a/packages/base/structured-theme-variables.gts +++ b/packages/base/structured-theme-variables.gts @@ -1,7 +1,13 @@ +import { schedule } from '@ember/runloop'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; + import { CopyButton, FieldContainer, + Pill, Swatch, + Tooltip, } from '@cardstack/boxel-ui/components'; import { buildCssVariableName, @@ -58,13 +64,50 @@ function describeColor(base: string) { return `${base} ${COLOR_VALUE_INPUT_HELP}`; } -function getFieldGroup(fieldNames: string[], model?: Record) { +// `property` is what an unset variable is probed through to resolve its +// inherited default (see resolveThemeVariable) +function getFieldGroup( + fieldNames: string[], + model?: Record, + property = 'background-color', +) { return fieldNames?.map((fieldName: string) => ({ name: buildCssVariableName(fieldName), value: model?.[fieldName], + property, })); } +// what a probe computes to when the variable is undeclared or not valid for +// the probed property +const UNRESOLVED_PROBE_VALUES = new Set([ + '', + 'none', + 'auto', + 'rgba(0, 0, 0, 0)', +]); + +// Resolves a contract token the theme leaves unset. The swatch grid renders +// inside the dashboard's theme scope, where theme.css declares every token, so +// reading it there yields the default the card actually renders with, in the +// color mode the preview is showing. `expression` is the declared value with +// var() substituted (a color-mix formula stays a formula); `resolved` applies +// it to `property` on a probe so formulas collapse to a literal. +function resolveThemeVariable(el: HTMLElement, name: string, property: string) { + let expression = getComputedStyle(el).getPropertyValue(name).trim(); + let probe = document.createElement('span'); + probe.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none'; + probe.style.setProperty(property, `var(${name})`); + el.appendChild(probe); + let resolved = getComputedStyle(probe).getPropertyValue(property).trim(); + probe.remove(); + if (UNRESOLVED_PROBE_VALUES.has(resolved)) { + resolved = expression; + } + return { expression, resolved }; +} + export function calculateTypographyVariables( fieldDef: BaseDef, prefix?: string, @@ -139,6 +182,14 @@ export class ThemeTypographyField extends FieldDef { @field caption = contains(TypographyField, { description: 'Caption/annotation and small text typography settings.', }); + @field label = contains(TypographyField, { + description: + 'UI label typography: control text, table headers, badges, and other chrome.', + }); + @field eyebrow = contains(TypographyField, { + description: + 'Eyebrow typography: the small, tracked-out kicker above a title.', + }); get cssVariableFields(): CssVariableFieldEntry[] | undefined { return calculateTypographyVariables(this, 'theme'); } @@ -204,6 +255,16 @@ export class ThemeTypographyField extends FieldDef { <@fields.caption /> +
+

Label

+ <@fields.label /> +
+ +
+

Eyebrow

+ <@fields.eyebrow /> +
+ }; } +// The variable name behind a sample, so a reader can find what to edit: a +// muted Pill rendered as , in the mono face +export class TokenPill extends GlimmerComponent<{ + Args: { name: string }; + Element: HTMLElement; +}> { + +} + +// One rung of the type ladder: the specimen, its token, and the size and +// weight the browser actually resolved for it +class MeasuredType extends GlimmerComponent<{ + Args: { role: string; token: string }; + Blocks: { default: [] }; + Element: HTMLElement; +}> { + @tracked private measured?: string; + + private measure = modifier((el: HTMLElement) => { + let read = () => + schedule('afterRender', () => { + if (!el.isConnected) { + return; + } + // the yielded specimen carries the role's styles, not the wrapper + let style = getComputedStyle(el.firstElementChild ?? el); + let size = parseFloat(style.fontSize); + let line = parseFloat(style.lineHeight); + let ratio = + size && line + ? ` / ${(line / size).toFixed(2).replace(/0$/, '')}` + : ''; + let next = `${Math.round(size * 100) / 100}px${ratio} · ${style.fontWeight}`; + // the observer below also sees this component's own output, so only + // dirty the tracked value when the measurement actually moved + if (next !== this.measured) { + this.measured = next; + } + }); + read(); + // theme edits and the light/dark toggle land inside the dashboard: its + // + +} + export class FontPreviews extends GlimmerComponent<{ Args: { fontStack?: { label: string; stack?: string }[]; @@ -356,6 +592,9 @@ class Embedded extends Component { +} + class ThemeSwatch extends GlimmerComponent<{ Args: { value: string; - label?: string; + label: string; + property?: string; }; Element: HTMLElement; }> { @@ -398,10 +792,11 @@ class ThemeSwatch extends GlimmerComponent<{ /> {{else if @label.length}} -
-
{{@label}}
- /* not set */ -
+ {{/if}} @@ -453,14 +842,18 @@ class ThemeSwatch extends GlimmerComponent<{ class FieldGrid extends GlimmerComponent<{ Args: { - fields: { name: string; value: string }[]; + fields: { name: string; value: string; property?: string }[]; }; Element: HTMLElement; }> { @@ -498,6 +891,45 @@ export default class ThemeVarField extends FieldDef { @field popoverForeground = contains(ColorField, { description: describeColor('Text color for popover content.'), }); + // neutral surfaces beyond shadcn's; --foreground must read on all of them + @field canvas = contains(ColorField, { + description: describeColor( + 'Workspace ground behind the page background. Do not use as foreground color.', + ), + }); + @field inset = contains(ColorField, { + description: describeColor( + 'Background of a well sunk into a card. Do not use as foreground color.', + ), + }); + @field field = contains(ColorField, { + description: describeColor( + 'Background of an editable input at rest; its border is --input. Do not use as foreground color.', + ), + }); + @field hover = contains(ColorField, { + description: describeColor( + 'Pointer-hover surface, usually translucent so it composes over any background.', + ), + }); + @field stripe = contains(ColorField, { + description: describeColor( + 'Alternate (zebra) row background. Sits between --card and --hover.', + ), + }); + @field selected = contains(ColorField, { + description: describeColor( + 'Selected row/item background. Falls back to a tint of --primary over --card when unset.', + ), + }); + @field tooltip = contains(ColorField, { + description: describeColor( + 'Tooltip background; the one inverted surface. Do not use as foreground color.', + ), + }); + @field tooltipForeground = contains(ColorField, { + description: describeColor('Text color on tooltip surfaces.'), + }); @field primary = contains(ColorField, { description: describeColor( 'Primary brand/action cta background-color. Do not use as foreground color.', @@ -524,6 +956,11 @@ export default class ThemeVarField extends FieldDef { @field mutedForeground = contains(ColorField, { description: describeColor('Muted foreground color.'), }); + @field subtleForeground = contains(ColorField, { + description: describeColor( + 'Third ink step, fainter than --muted-foreground: timestamps, tertiary counts, placeholder-adjacent text.', + ), + }); @field accent = contains(ColorField, { description: describeColor('Accent background-color.'), }); @@ -543,16 +980,92 @@ export default class ThemeVarField extends FieldDef { 'Success/positive feedback color. Not standard shadcn: falls back to the fixed Boxel status palette green (--boxel-success) when unset.', ), }); + @field successForeground = contains(ColorField, { + description: describeColor('Text/icon color on success surfaces.'), + }); @field warning = contains(ColorField, { description: describeColor( 'Warning/caution feedback color. Not standard shadcn: falls back to the fixed Boxel status palette yellow (--boxel-warning) when unset.', ), }); + @field warningForeground = contains(ColorField, { + description: describeColor('Text/icon color on warning surfaces.'), + }); + @field info = contains(ColorField, { + description: describeColor( + 'Informational feedback color. Not standard shadcn. Do not use as foreground color.', + ), + }); + @field infoForeground = contains(ColorField, { + description: describeColor('Text/icon color on info surfaces.'), + }); + @field attention = contains(ColorField, { + description: describeColor( + 'Needs-your-attention feedback color, distinct from warning and destructive. Not standard shadcn. Do not use as foreground color.', + ), + }); + @field attentionForeground = contains(ColorField, { + description: describeColor('Text/icon color on attention surfaces.'), + }); + @field overlay = contains(ColorField, { + description: describeColor('Translucent scrim behind modals and drawers.'), + }); + + // hue-as-ink variables: each hue used as text/icon color on a neutral + // surface, as opposed to the -foreground color used on the hue's own fill + @field primaryInk = contains(ColorField, { + description: describeColor( + 'Primary hue as text/icon color on neutral surfaces (links, labels). Falls back to a mix of --primary and --foreground when unset.', + ), + }); + @field secondaryInk = contains(ColorField, { + description: describeColor( + 'Secondary hue as text/icon color on neutral surfaces. Falls back to a mix of --secondary and --foreground when unset.', + ), + }); + @field accentInk = contains(ColorField, { + description: describeColor( + 'Accent hue as text/icon color on neutral surfaces. Falls back to a mix of --accent and --foreground when unset.', + ), + }); + @field destructiveInk = contains(ColorField, { + description: describeColor( + 'Destructive hue as text/icon color on neutral surfaces. Falls back to a mix of --destructive and --foreground when unset.', + ), + }); + @field successInk = contains(ColorField, { + description: describeColor( + 'Success hue as text/icon color on neutral surfaces. Falls back to a mix of --success and --foreground when unset.', + ), + }); + @field warningInk = contains(ColorField, { + description: describeColor( + 'Warning hue as text/icon color on neutral surfaces. Falls back to a mix of --warning and --foreground when unset.', + ), + }); + @field infoInk = contains(ColorField, { + description: describeColor( + 'Info hue as text/icon color on neutral surfaces. Falls back to a mix of --info and --foreground when unset.', + ), + }); + @field attentionInk = contains(ColorField, { + description: describeColor( + 'Attention hue as text/icon color on neutral surfaces. Falls back to a mix of --attention and --foreground when unset.', + ), + }); + @field border = contains(ColorField, { description: describeColor('Specifies border-color.'), }); + @field borderStrong = contains(ColorField, { + description: describeColor( + 'One visible step darker than --border, for dividers that must hold their own.', + ), + }); @field input = contains(ColorField, { - description: describeColor('Background/border color for inputs.'), + description: describeColor( + 'Border color for inputs, and the track fill of unfilled controls such as a switch. Input backgrounds come from --field.', + ), }); @field ring = contains(ColorField, { description: describeColor('Focus ring color.'), @@ -574,6 +1087,12 @@ export default class ThemeVarField extends FieldDef { @field chart5 = contains(ColorField, { description: describeColor('Quinary chart/graph color.'), }); + @field chart6 = contains(ColorField, { + description: describeColor('Sixth chart/graph color.'), + }); + @field chart7 = contains(ColorField, { + description: describeColor('Seventh chart/graph color.'), + }); // sidebar color variables @field sidebar = contains(ColorField, { @@ -637,6 +1156,10 @@ export default class ThemeVarField extends FieldDef { @field trackingNormal = contains(CSSValueField, { description: 'Specifies letter-spacing base value.', }); + @field controlHeight = contains(CSSValueField, { + description: + 'Height of form controls (inputs, selects, buttons). Defaults to 2.5rem.', + }); // box-shadow primitives, stored so tweakcn themes round-trip losslessly. // The composed shadow scale below does not derive from them; editing these // has no effect on rendered shadows. @@ -686,6 +1209,9 @@ export default class ThemeVarField extends FieldDef { @field shadow2xl = contains(CSSValueField, { description: 'Largest shadow depth.', }); + @field shadowInset = contains(CSSValueField, { + description: 'Inset shadow for sunken wells and inputs.', + }); get cssVariableFields(): CssVariableFieldEntry[] | undefined { let fields = getFields(this); @@ -730,17 +1256,54 @@ export default class ThemeVarField extends FieldDef { 'popoverForeground', 'muted', 'mutedForeground', + 'subtleForeground', + ]; + private surfaceColors = [ + 'canvas', + 'inset', + 'field', + 'hover', + 'stripe', + 'selected', + 'tooltip', + 'tooltipForeground', ]; private formColors = [ 'border', + 'borderStrong', 'input', 'ring', 'destructive', 'destructiveForeground', 'success', + 'successForeground', 'warning', + 'warningForeground', + 'info', + 'infoForeground', + 'attention', + 'attentionForeground', + 'overlay', + ]; + private inkColors = [ + 'primaryInk', + 'secondaryInk', + 'accentInk', + 'destructiveInk', + 'successInk', + 'warningInk', + 'infoInk', + 'attentionInk', + ]; + private chartColors = [ + 'chart1', + 'chart2', + 'chart3', + 'chart4', + 'chart5', + 'chart6', + 'chart7', ]; - private chartColors = ['chart1', 'chart2', 'chart3', 'chart4', 'chart5']; private sidebarColors = [ 'sidebar', 'sidebarForeground', @@ -761,6 +1324,7 @@ export default class ThemeVarField extends FieldDef { 'shadowLg', 'shadowXl', 'shadow2xl', + 'shadowInset', ]; get fieldGroups() { return [ @@ -776,10 +1340,22 @@ export default class ThemeVarField extends FieldDef { title: 'UI Component Colors', fields: getFieldGroup(this.uiComponentColors, this), }, + { + title: 'Surface Colors', + description: + 'Neutral backgrounds beyond the card: the workspace ground, sunken wells, hover and selected states, zebra rows, and tooltips.', + fields: getFieldGroup(this.surfaceColors, this), + }, { title: 'Form & Feedback Colors', fields: getFieldGroup(this.formColors, this), }, + { + title: 'Ink Colors', + description: + "Each hue used as text or icon color on a neutral surface, as opposed to the -foreground color used on the hue's own fill.", + fields: getFieldGroup(this.inkColors, this), + }, { title: 'Chart Colors', fields: getFieldGroup(this.chartColors, this), @@ -790,7 +1366,7 @@ export default class ThemeVarField extends FieldDef { }, { title: 'Box Shadow', - fields: getFieldGroup(this.boxShadows, this), + fields: getFieldGroup(this.boxShadows, this, 'box-shadow'), }, ]; } @@ -849,6 +1425,7 @@ export default class ThemeVarField extends FieldDef { { label: 'lg', value: m.shadowLg }, { label: 'xl', value: m.shadowXl }, { label: '2xl', value: m.shadow2xl }, + { label: 'Inset', value: m.shadowInset }, ].filter((s) => s.value); } @@ -1002,6 +1579,87 @@ export default class ThemeVarField extends FieldDef { <@fields.mutedForeground />
+
+ + <@fields.subtleForeground /> + +
+ + +
+

Surfaces

+

+ Neutral backgrounds beyond the card: the workspace ground, sunken + wells, hover and selected states, zebra rows, and tooltips. +

+
+ + <@fields.canvas /> + + + <@fields.inset /> + +
+
+ + <@fields.field /> + + + <@fields.hover /> + +
+
+ + <@fields.stripe /> + + + <@fields.selected /> + +
+
+ + <@fields.tooltip /> + + + <@fields.tooltipForeground /> + +
@@ -1014,6 +1672,15 @@ export default class ThemeVarField extends FieldDef { > <@fields.border /> + + <@fields.borderStrong /> + + +
<@fields.input /> -
-
<@fields.success /> + + <@fields.successForeground /> + +
+
<@fields.warning /> + + <@fields.warningForeground /> + +
+
+ + <@fields.info /> + + + <@fields.infoForeground /> + +
+
+ + <@fields.attention /> + + + <@fields.attentionForeground /> + +
+
+ + <@fields.overlay /> + +
+
+ +
+

Ink Colors

+

+ Each hue used as text or icon color on a neutral surface. Leave + blank to derive from the hue and the foreground color. +

+
+ + <@fields.primaryInk /> + + + <@fields.secondaryInk /> + +
+
+ + <@fields.accentInk /> + + + <@fields.destructiveInk /> + +
+
+ + <@fields.successInk /> + + + <@fields.warningInk /> + +
+
+ + <@fields.infoInk /> + + + <@fields.attentionInk /> +
@@ -1107,6 +1901,22 @@ export default class ThemeVarField extends FieldDef { > <@fields.chart5 /> + + <@fields.chart6 /> + + +
+ + <@fields.chart7 /> +
@@ -1237,6 +2047,13 @@ export default class ThemeVarField extends FieldDef { > <@fields.trackingNormal /> + + <@fields.controlHeight /> + @@ -1319,6 +2136,15 @@ export default class ThemeVarField extends FieldDef { <@fields.shadow2xl /> +
+ + <@fields.shadowInset /> + +
Imported shadow primitives

Kept so a tweakcn export round-trips. The shadow scale above is not diff --git a/packages/base/structured-theme.gts b/packages/base/structured-theme.gts index 303151a9e35..0e80aaba091 100644 --- a/packages/base/structured-theme.gts +++ b/packages/base/structured-theme.gts @@ -280,7 +280,10 @@ class Isolated extends Component { > {{#if (eq section.id 'card-container-css')}} {{#if @model.cssVariables}} - + {{else}}

No theme variables added

{{/if}} @@ -333,6 +336,7 @@ class Edit extends Isolated { export default class StructuredTheme extends Theme { static displayName = 'Theme'; + static prefersWideFormat = true; @field typography = contains(ThemeTypographyField, { description: diff --git a/packages/base/style-reference.gts b/packages/base/style-reference.gts index e28101cba0c..065989ef235 100644 --- a/packages/base/style-reference.gts +++ b/packages/base/style-reference.gts @@ -117,10 +117,12 @@ class Isolated extends Component { /> {{else}}
-

<@fields.cardTitle />

-

- <@fields.cardDescription /> -

+
+

<@fields.cardTitle />

+

+ <@fields.cardDescription /> +

+
{{/if}} @@ -136,7 +138,7 @@ class Isolated extends Component { @editMode={{this.editMode}} > <:colorPalette> - <@fields.rootVariables /> + <@fields.rootVariables data-test-root-vars /> <:typography> <@fields.typography /> @@ -254,7 +256,10 @@ class Isolated extends Component { {{#if @model.cssVariables}}

Computed Styles

- +
{{/if}} @@ -284,8 +289,14 @@ class Isolated extends Component { section { scroll-margin-top: var(--boxel-sp-2xl); } - .style-reference { - max-width: 50rem; + /* the header band spans the card; its text keeps to the body's measure, + which is the dashboard's content measure less .dsr-content's padding + and the section grid's own padding-inline */ + .style-header-content { + max-width: calc( + var(--dsr-content-max-width, 56rem) - 2 * var(--boxel-sp-xl) - 2 * + var(--boxel-sp-2xl) + ); margin: 0 auto; } .style-header { diff --git a/packages/base/typography.gts b/packages/base/typography.gts index f2c30fdbaec..58671231609 100644 --- a/packages/base/typography.gts +++ b/packages/base/typography.gts @@ -28,7 +28,8 @@ class TypographyEmbedded extends Component { private get styles() { - let { fontFamily, fontSize, fontWeight, lineHeight } = this.args.model; + let { fontFamily, fontSize, fontWeight, lineHeight, letterSpacing } = + this.args.model; let styles = []; if (fontFamily) { styles.push(`font-family: ${fontFamily}`); @@ -42,6 +43,9 @@ class TypographyEmbedded extends Component { if (lineHeight) { styles.push(`line-height: ${lineHeight}`); } + if (letterSpacing) { + styles.push(`letter-spacing: ${letterSpacing}`); + } return sanitizeHtmlSafe(styles.join('; ')); } } @@ -53,6 +57,7 @@ export default class TypographyField extends FieldDef { @field fontSize = contains(CSSValueField); @field fontWeight = contains(CSSValueField); @field lineHeight = contains(CSSValueField); + @field letterSpacing = contains(CSSValueField); @field sampleText = contains(StringField); get fieldEntries(): CssVariableEntry[] | undefined { @@ -118,12 +123,14 @@ export default class TypographyField extends FieldDef { static edit = class Edit extends Component { private get styles() { - let { fontFamily, fontSize, fontWeight, lineHeight } = this.args.model; + let { fontFamily, fontSize, fontWeight, lineHeight, letterSpacing } = + this.args.model; let styles = []; if (fontFamily) styles.push(`font-family: ${fontFamily}`); if (fontSize) styles.push(`font-size: ${fontSize}`); if (fontWeight) styles.push(`font-weight: ${fontWeight}`); if (lineHeight) styles.push(`line-height: ${lineHeight}`); + if (letterSpacing) styles.push(`letter-spacing: ${letterSpacing}`); return sanitizeHtmlSafe(styles.join('; ')); } @@ -152,6 +159,11 @@ export default class TypographyField extends FieldDef { <@fields.lineHeight /> +
+ + <@fields.letterSpacing /> + +
<@fields.sampleText /> @@ -202,6 +214,7 @@ export default class TypographyField extends FieldDef { { key: 'fontSize', label: 'Font size' }, { key: 'fontWeight', label: 'Font weight' }, { key: 'lineHeight', label: 'Line height' }, + { key: 'letterSpacing', label: 'Letter spacing' }, ]; for (let { key, label } of labels) { let value = model[key] as string | undefined; diff --git a/packages/boxel-ui/src/components/card-container/index.gts b/packages/boxel-ui/src/components/card-container/index.gts index c8c2c068cbb..ce2c57228d9 100644 --- a/packages/boxel-ui/src/components/card-container/index.gts +++ b/packages/boxel-ui/src/components/card-container/index.gts @@ -234,10 +234,75 @@ const CardContainer: TemplateOnlyComponent =