Skip to content

Lesson 2 — Restyling Phlex::UI with tokens

This lesson updates every Phlex::UI component to use semantic token classes instead of raw palette values. The component logic is unchanged — only the class strings change.

Components::Button

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Components::Button < Components::Base
  VARIANTS = {
    primary:   "bg-primary text-white hover:bg-primary-hover " \
               "focus:ring-primary-ring",
    secondary: "bg-secondary text-secondary-text hover:bg-secondary-hover " \
               "focus:ring-border",
    danger:    "bg-danger text-white hover:bg-danger-hover " \
               "focus:ring-danger",
    outline:     "bg-transparent text-text hover:bg-secondary " \
               "focus:ring-border",
  }.freeze

  BASE_CLASSES = "inline-flex items-center justify-center rounded-md " \
                 "font-medium transition-colors focus:outline-none " \
                 "focus:ring-2 focus:ring-offset-2 disabled:opacity-50 " \
                 "disabled:pointer-events-none"

  SIZES = {
    sm: "px-3 py-1.5 text-sm",
    md: "px-4 py-2 text-base",
    lg: "px-6 py-3 text-lg",
  }.freeze

  # props and view_template unchanged
end

Components::Badge

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Components::Badge < Components::Base
  VARIANTS = {
    default: "bg-surface-alt text-text-muted",
    primary: "bg-info-bg text-info",
    success: "bg-success-bg text-success",
    warning: "bg-warning-bg text-warning",
    danger:  "bg-danger/10 text-danger",
  }.freeze

  # props and view_template unchanged
end

Components::Alert

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Components::Alert < Components::Base
  VARIANTS = {
    info:    { bg: "bg-info-bg",    border: "border-info/30",    text: "text-info",    icon: "ℹ" },
    success: { bg: "bg-success-bg", border: "border-success/30", text: "text-success", icon: "✓" },
    warning: { bg: "bg-warning-bg", border: "border-warning/30", text: "text-warning", icon: "⚠" },
    danger:  { bg: "bg-danger/10",  border: "border-danger/30",  text: "text-danger",  icon: "✕" },
  }.freeze

  # props and view_template unchanged
end

Components::Avatar

1
2
3
4
5
6
7
def avatar_classes
  class_names(
    "inline-flex items-center justify-center rounded-full",
    "bg-surface-alt text-text-muted font-medium overflow-hidden",
    SIZES[@size]
  )
end

Components::Breadcrumb

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  def view_template(&)
    vanish(&)
    nav(aria: { label: "Breadcrumb" }) do
      ol(class: "flex items-center gap-2 text-sm text-text-muted") do
        @items.each_with_index do |item, index|
          li(class: "flex items-center gap-2") do
            if index < @items.length - 1
              a(href: item[:url], class: "hover:text-text") { item[:label] }
              span(class: "text-border") { "/" }
            else
              span(class: "text-text font-medium") { item[:label] }
            end
          end
        end
      end
    end
  end

Components::Card

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    article(class: "rounded-lg border border-border bg-surface shadow-sm") do
      div(class: "card-header px-6 py-4 border-b border-border") do
        if @header
          raw safe(@header)
        elsif @title
          h3(class: "font-semibold text-lg text-text") { @title }
        end
      end if @title || @header

      div(class: "card-body px-6 py-4") { raw safe(@body) } if @body

      div(class: "card-footer px-6 py-4 border-t border-border bg-surface-alt") do
        raw safe(@footer)
      end if @footer
    end

Components::Checkbox

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  def view_template
    div(class: "space-y-1") do
      label(class: "flex items-center gap-2 text-sm text-text cursor-pointer") do
        input(
          type:    "checkbox",
          id:      field_id,
          name:    field_name,
          value:   @value,
          checked: @checked,
          class:   "w-4 h-4 rounded border-border text-primary " \
                   "focus:ring-primary focus:ring-offset-1 cursor-pointer"
        )
        plain @label if @label
      end
      p(class: "text-xs text-text-muted pl-6") { @hint } if @hint
    end
  end

Components::EmptyState

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  def view_template
    div(class: "text-center py-12 px-4") do
      p(class: "text-4xl mb-4") { "📋" }
      h3(class: "text-lg font-semibold text-text mb-2") { @title }
      p(class: "text-text-muted text-sm mb-6") { @message }
      if @action_label && @action_url
        Button(label: @action_label, href: @action_url)
      end
    end
  end

Components::ErrorSummary

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  def view_template
    return unless @errors.any?

    div(
      class: "rounded-md bg-danger/10 border border-danger/30 p-4",
      role:  "alert"
    ) do
      p(class: "text-sm font-medium text-danger mb-2") do
        plain "Please fix the following errors:"
      end
      ul(class: "list-disc list-inside text-sm text-danger space-y-1") do
        @errors.full_messages.each { |msg| li { msg } }
      end
    end
  end

Components::FormField

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
  def render_label
    label(for: field_id, class: "block text-sm font-medium text-text") do
      plain @label
      abbr(title: "required", class: "ml-1 text-danger") { "*" } if @required
    end
  end

  def render_error_message
    p(class: "text-xs text-danger",
      id:    "#{@field}_error",
      role:  "alert") { @error }
  end

  def render_hint
    p(class: "text-xs text-text-muted") { @hint }
  end

  def render_input
    raise NotImplementedError, "#{self.class} must implement render_input"
  end

  def base_input_classes
    "w-full rounded-md border px-3 py-2 text-sm bg-surface " \
    "text-text placeholder:text-text-subtle " \
    "focus:outline-none focus:ring-2 focus:ring-offset-1"
  end

  def error_classes
    error? ? "border-danger focus:ring-danger"
           : "border-border focus:ring-primary"
  end

Components::Heading

1
2
3
4
5
6
7
8
  SIZES = {
    1 => "text-4xl font-bold text-text",
    2 => "text-3xl font-bold text-text",
    3 => "text-2xl font-semibold text-text",
    4 => "text-xl font-semibold text-text",
    5 => "text-lg font-semibold text-text",
    6 => "text-base font-semibold text-text-muted",
  }.freeze

Components::Link

1
2
3
4
5
6
7
  VARIANTS = {
    default:   "text-primary hover:text-primary-hover underline underline-offset-2",
    secondary: "text-text-muted hover:text-text underline underline-offset-2",
    button:    "inline-flex items-center justify-center rounded-md font-medium " \
               "bg-primary text-white hover:bg-primary-hover px-4 py-2 " \
               "focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2",
  }.freeze

Components::Panel

1
2
3
4
    div(class: "rounded-lg border border-border bg-surface p-4") do
      h4(class: "font-semibold text-text mb-3") { @title }
      yield
    end

Components::RadioGroup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def view_template
    fieldset do
      legend(class: "block text-sm font-medium text-text mb-2") { @label } if @label
      div(class: option_wrapper_classes) do
        @options.each do |opt|
          label_text, value = opt.is_a?(Array) ? opt : [opt.to_s, opt.to_s]
          option_id = @form ? @form.field_id(@field, value) : "#{@field}_#{value}"

          label(for: option_id, class: "flex items-center gap-2 text-sm text-text cursor-pointer") do
            input(
              type:    "radio",
              id:      option_id,
              name:    field_name,
              value:   value,
              checked: value.to_s == @selected.to_s,
              class:   "w-4 h-4 border-border text-primary " \
                       "focus:ring-primary focus:ring-offset-1 cursor-pointer"
            )
            plain label_text
          end
        end
      end
      p(class: "text-xs text-text-muted mt-1") { @hint } if @hint

      if @error
        msg = @error.strip.empty? ? "Please select an option." : @error
        p(class: "text-xs text-danger mt-1", role: "alert") { msg }
      end
    end
  end

Components::Select

1
2
3
   div(class: "pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-text-muted") do
        span(class: "text-lg rotate-90 inline-block leading-none") { "›" }
   end

Components::Table

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def render_table
    div(class: "overflow-x-auto rounded-lg border border-border") do
      table(class: "w-full text-sm text-left") do
        caption(class: "px-4 py-2 text-sm text-text-muted text-left") do
          @caption
        end if @caption
        thead(class: "bg-surface-alt text-xs text-text-muted uppercase tracking-wider") do
          tr do
            @columns.each do |col|
              th(class: "px-4 py-3 font-medium") { col[:header] }
            end
            th(class: "px-4 py-3 font-medium") { "Actions" } if @action_block
          end
        end
        tbody(class: "divide-y divide-border") do
          @rows.each do |row|
            tr(class: "hover:bg-surface-alt transition-colors") do
              @columns.each do |col|
                td(class: "px-4 py-3 text-text") do
                  plain col[:content].call(row).to_s
                end
              end
              if @action_block
                td(class: "px-4 py-3") do
                  instance_exec(row, &@action_block)
                end
              end
            end
          end
        end
      end
    end
  end  

Components::TextInput

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def input_classes
  base = "w-full rounded-md border px-3 py-2 text-sm bg-surface " \
         "text-text placeholder:text-text-subtle " \
         "focus:outline-none focus:ring-2 focus:ring-offset-1"
  if @error
    "#{base} border-danger focus:ring-danger"
  else
    "#{base} border-border focus:ring-primary"
  end
end

The pattern is consistent throughout:

Before After
bg-white bg-surface
bg-gray-50 bg-surface-alt
border-gray-200 border-border
text-gray-900 text-text
text-gray-500 text-text-muted
bg-blue-600 bg-primary
hover:bg-blue-700 hover:bg-primary-hover
bg-red-600 bg-danger

After updating all components run the test suite to confirm nothing broke — the component structure is unchanged so all tests should pass.

You can then check out all the components in Lookbook - they should look unchanged but the underlying html should be using our new token styles.