Skip to content

feat: implement Material 3 typography tokens and classes#120

Open
treeder wants to merge 12 commits into
mainfrom
feature/m3-typescale-tokens
Open

feat: implement Material 3 typography tokens and classes#120
treeder wants to merge 12 commits into
mainfrom
feature/m3-typescale-tokens

Conversation

@treeder

@treeder treeder commented Jun 25, 2026

Copy link
Copy Markdown
Member

This PR adds the M3 type scale tokens, classes, and helper styles under typography/md-typescale.js and updates md-typescale-styles.js for backward compatibility.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Material Design 3 Expressive indicators, adding a morphing geometric 'md-loading' component and extending 'md-progress' with wavy circular and linear tracks. It also refactors typography styles into a centralized 'md-typescale.js' module. Feedback focuses on optimizing animation performance by pausing the animation loop when inactive and caching DOM queries. Additionally, several robustness improvements are recommended, including preventing infinite loops from non-finite progress values, avoiding stack overflows from circular CSS variable references, ensuring SSR compatibility, and adding defensive null checks for canvas contexts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread indicators/loading.js
Comment on lines +359 to +361
startAnimation() {
if (this.rafId) return
const canvas = this.renderRoot?.querySelector('#canvas')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When paused is true, the requestAnimationFrame loop continues to run continuously, clearing and redrawing the canvas on every frame. This wastes CPU/GPU resources and drains battery on mobile devices. We should prevent starting the animation loop if this.paused is true.

Suggested change
startAnimation() {
if (this.rafId) return
const canvas = this.renderRoot?.querySelector('#canvas')
startAnimation() {
if (this.paused) return
if (this.rafId) return
const canvas = this.renderRoot?.querySelector('#canvas')

Comment thread indicators/loading.js
Comment on lines +412 to +423
updated(changedProperties) {
if (changedProperties.has('size')) {
this.style.setProperty('--md-loading-size', `${this.size}px`)
const canvas = this.renderRoot.querySelector('#canvas')
if (canvas) {
this.ctx = setupCanvas(canvas, this.size)
}
}
if (changedProperties.has('color') || changedProperties.has('containerColor') || changedProperties.has('contained')) {
this.updateResolvedColors()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To fully optimize the paused state, we should dynamically start or stop the animation loop when the paused property changes.

  updated(changedProperties) {
    if (changedProperties.has('size')) {
      this.style.setProperty('--md-loading-size', `${this.size}px`)
      const canvas = this.renderRoot.querySelector('#canvas')
      if (canvas) {
        this.ctx = setupCanvas(canvas, this.size)
      }
    }
    if (changedProperties.has('color') || changedProperties.has('containerColor') || changedProperties.has('contained')) {
      this.updateResolvedColors()
    }
    if (changedProperties.has('paused')) {
      if (this.paused) {
        this.stopAnimation()
      } else {
        this.startAnimation()
      }
    }
  }

Comment thread indicators/loading.js
Comment on lines +425 to +450
_resolveColor(colorString) {
if (!colorString) return '#6750a4'
const trimmed = colorString.trim()
if (trimmed === 'currentColor') {
return window.getComputedStyle(this).color || '#6750a4'
}
if (trimmed.startsWith('var(') && trimmed.endsWith(')')) {
const content = trimmed.substring(4, trimmed.length - 1).trim()
const commaIdx = content.indexOf(',')
if (commaIdx === -1) {
const varName = content.trim()
const val = window.getComputedStyle(this).getPropertyValue(varName).trim()
return val ? this._resolveColor(val) : '#6750a4'
} else {
const varName = content.substring(0, commaIdx).trim()
const fallback = content.substring(commaIdx + 1).trim()
const val = window.getComputedStyle(this).getPropertyValue(varName).trim()
return val ? this._resolveColor(val) : this._resolveColor(fallback)
}
}
if (trimmed.startsWith('--')) {
const val = window.getComputedStyle(this).getPropertyValue(trimmed).trim()
return val ? this._resolveColor(val) : '#6750a4'
}
return trimmed
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If there is a circular reference in CSS variables (e.g., --color1: var(--color2); --color2: var(--color1);), _resolveColor will recursively call itself infinitely, causing a stack overflow crash. We should limit the recursion depth to prevent this. Additionally, we should check if window is defined to support server-side rendering (SSR) environments safely.

  _resolveColor(colorString, depth = 0) {
    if (depth > 5) return '#6750a4'
    if (!colorString) return '#6750a4'
    const trimmed = colorString.trim()
    const windowExists = typeof window !== 'undefined'
    if (trimmed === 'currentColor') {
      return (windowExists ? window.getComputedStyle(this).color : '') || '#6750a4'
    }
    if (trimmed.startsWith('var(') && trimmed.endsWith(')')) {
      const content = trimmed.substring(4, trimmed.length - 1).trim()
      const commaIdx = content.indexOf(',')
      if (commaIdx === -1) {
        const varName = content.trim()
        const val = windowExists ? window.getComputedStyle(this).getPropertyValue(varName).trim() : ''
        return val ? this._resolveColor(val, depth + 1) : '#6750a4'
      } else {
        const varName = content.substring(0, commaIdx).trim()
        const fallback = content.substring(commaIdx + 1).trim()
        const val = windowExists ? window.getComputedStyle(this).getPropertyValue(varName).trim() : ''
        return val ? this._resolveColor(val, depth + 1) : this._resolveColor(fallback, depth + 1)
      }
    }
    if (trimmed.startsWith('--')) {
      const val = windowExists ? window.getComputedStyle(this).getPropertyValue(trimmed).trim() : ''
      return val ? this._resolveColor(val, depth + 1) : '#6750a4'
    }
    return trimmed
  }

Comment thread indicators/progress.js
Comment on lines +698 to +712
function getWavyCirclePath(cx, cy, r, amplitude, frequency, phase, progress = 1.0) {
const points = []
const steps = Math.max(10, Math.round(180 * progress))
for (let i = 0; i <= steps; i++) {
const theta = (i / steps) * progress * 2 * Math.PI
const perturbedRadius = r + amplitude * Math.sin(frequency * theta + phase)
const x = cx + perturbedRadius * Math.cos(theta)
const y = cy + perturbedRadius * Math.sin(theta)
points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`)
}
if (progress >= 1.0) {
points.push('Z')
}
return points.join(' ')
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If progress is Infinity (which can happen if this.max is 0 and this.value > 0), steps will be Infinity, causing an infinite loop that freezes the browser tab. We must clamp progress and handle NaN / non-finite values.

Suggested change
function getWavyCirclePath(cx, cy, r, amplitude, frequency, phase, progress = 1.0) {
const points = []
const steps = Math.max(10, Math.round(180 * progress))
for (let i = 0; i <= steps; i++) {
const theta = (i / steps) * progress * 2 * Math.PI
const perturbedRadius = r + amplitude * Math.sin(frequency * theta + phase)
const x = cx + perturbedRadius * Math.cos(theta)
const y = cy + perturbedRadius * Math.sin(theta)
points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`)
}
if (progress >= 1.0) {
points.push('Z')
}
return points.join(' ')
}
function getWavyCirclePath(cx, cy, r, amplitude, frequency, phase, progress = 1.0) {
if (isNaN(progress) || !isFinite(progress)) {
progress = 0
}
progress = Math.max(0, Math.min(1, progress))
const points = []
const steps = Math.max(10, Math.round(180 * progress))
for (let i = 0; i <= steps; i++) {
const theta = (i / steps) * progress * 2 * Math.PI
const perturbedRadius = r + amplitude * Math.sin(frequency * theta + phase)
const x = cx + perturbedRadius * Math.cos(theta)
const y = cy + perturbedRadius * Math.sin(theta)
points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`)
}
if (progress >= 1.0) {
points.push('Z')
}
return points.join(' ')
}

Comment thread indicators/loading.js
Comment on lines +289 to +299
function setupCanvas(canvas, cssSize) {
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
const px = Math.round(cssSize * dpr)
canvas.width = px
canvas.height = px
canvas.style.width = `${cssSize}px`
canvas.style.height = `${cssSize}px`
const ctx = canvas.getContext("2d")
ctx.scale(dpr, dpr)
return ctx
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If canvas.getContext("2d") returns null (which can happen if the browser is out of memory or canvas is disabled), calling ctx.scale will throw a TypeError and crash the component. We should add a defensive null check.

Suggested change
function setupCanvas(canvas, cssSize) {
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
const px = Math.round(cssSize * dpr)
canvas.width = px
canvas.height = px
canvas.style.width = `${cssSize}px`
canvas.style.height = `${cssSize}px`
const ctx = canvas.getContext("2d")
ctx.scale(dpr, dpr)
return ctx
}
function setupCanvas(canvas, cssSize) {
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
const px = Math.round(cssSize * dpr)
canvas.width = px
canvas.height = px
canvas.style.width = `${cssSize}px`
canvas.style.height = `${cssSize}px`
const ctx = canvas.getContext("2d")
if (ctx) {
ctx.scale(dpr, dpr)
}
return ctx
}

Comment thread indicators/loading.js
Comment on lines +221 to +230
update(ts) {
if (this.paused) {
this.lastTs = ts
return
}
if (this.lastTs === 0) this.lastTs = ts
const rawDt = Math.min((ts - this.lastTs) / 1e3, 0.1)
const dt = rawDt * this.speed
this.lastTs = ts
if (dt <= 0) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If this.speed is set to NaN or a non-finite value, dt will be NaN or non-finite, which propagates and crashes the animation loop. We should guard against this.

Suggested change
update(ts) {
if (this.paused) {
this.lastTs = ts
return
}
if (this.lastTs === 0) this.lastTs = ts
const rawDt = Math.min((ts - this.lastTs) / 1e3, 0.1)
const dt = rawDt * this.speed
this.lastTs = ts
if (dt <= 0) return
update(ts) {
if (this.paused) {
this.lastTs = ts
return
}
if (this.lastTs === 0) this.lastTs = ts
const rawDt = Math.min((ts - this.lastTs) / 1e3, 0.1)
const dt = rawDt * this.speed
this.lastTs = ts
if (isNaN(dt) || !isFinite(dt) || dt <= 0) return

Comment thread indicators/progress.js
Comment on lines +69 to +85
startWavyAnimation() {
if (this.wavyAnimationActive) return
this.wavyAnimationActive = true
this.wavyPhase = 0
const loop = (ts) => {
if (!this.wavyAnimationActive) return
this.wavyPhase += 0.08
const path = this.renderRoot.querySelector('.active-track.wavy-circle')
if (path) {
const isIndeterminateWavy = this.indeterminate && this.shape === 'wavy'
const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max)
path.setAttribute('d', getWavyCirclePath(2400, 2400, 2160, 120, 12, this.wavyPhase, progress))
}
this.wavyRafId = requestAnimationFrame(loop)
}
this.wavyRafId = requestAnimationFrame(loop)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Querying the DOM on every frame using querySelector is a known performance anti-pattern in Web Components / Lit. We should cache the path element reference.

  startWavyAnimation() {
    if (this.wavyAnimationActive) return
    this.wavyAnimationActive = true
    this.wavyPhase = 0
    const loop = (ts) => {
      if (!this.wavyAnimationActive) return
      this.wavyPhase += 0.08
      if (!this.wavyCirclePath) {
        this.wavyCirclePath = this.renderRoot.querySelector('.active-track.wavy-circle')
      }
      const path = this.wavyCirclePath
      if (path) {
        const isIndeterminateWavy = this.indeterminate && this.shape === 'wavy'
        const progress = isIndeterminateWavy ? 0.25 : (this.value / this.max)
        path.setAttribute('d', getWavyCirclePath(2400, 2400, 2160, 120, 12, this.wavyPhase, progress))
      }
      this.wavyRafId = requestAnimationFrame(loop)
    }
    this.wavyRafId = requestAnimationFrame(loop)
  }

Comment thread indicators/progress.js
Comment on lines +50 to +54
updated(changedProperties) {
if (changedProperties.has('shape') || changedProperties.has('type')) {
this.updateWavyAnimationState()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should clear the cached path reference when shape or type changes to ensure the correct element is queried next time.

Suggested change
updated(changedProperties) {
if (changedProperties.has('shape') || changedProperties.has('type')) {
this.updateWavyAnimationState()
}
}
updated(changedProperties) {
if (changedProperties.has('shape') || changedProperties.has('type')) {
this.wavyCirclePath = null
this.updateWavyAnimationState()
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant