Right now you are viewing the "rendered" version of the markdown document.
If you want to download the "raw" version, go here.
- The dashboard
- Launching a task
- Viewing the full logs of a task
- Viewing most recent logs of a task
- Annex - Rubocop linting
- Annex - The license
- Updating the CSS
{"filename": ".bundle/config"}
---
BUNDLE_PATH: "vendor/bundle"
{"filename": "Gemfile"}
<<spdx_headers>>
source 'https://rubygems.org'
# Debug tools
gem 'awesome_print'
# Web-application prerequisites
gem 'puma'
gem 'sinatra'
gem 'rackup'
# SQL storage backend
gem 'sequel'
gem 'sqlite3'
<<rubocop_dependencies>>
{"filename": "app.rb"}
#!/usr/bin/env ruby
<<spdx_headers>>
require 'securerandom'
require 'open3'
require 'sinatra'
require 'sequel'
<<start_and_monitor>>
DATABASE_FILENAME = 'store.db'
def make_sqlite_connection
db = Sequel.connect("sqlite://#{DATABASE_FILENAME}")
<<create_table_if_exists>>
db
end
configure do
set :bind, '0.0.0.0'
set :sequel_conn, make_sqlite_connection
end
<<requesting_a_new_task>>
<<dashboard_backend>>
<<full_logs_backend>>
<<short_logs_backend>>
{"name": "create_table_if_exists"}
db.create_table? :tasks do
String :id, primary_key: true, null: false
String :command, null: false
String :cwd, null: false
DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
DateTime :finished_at, null: true
String :return_message, null: true
end
db.create_table? :buffers do
primary_key :id
foreign_key :task_id, :tasks
Integer :buffer_type
Integer :sequence_number
String :message, null: false
String :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
end
db.create_table? :commands do
primary_key :id
String :command
String :cwd
end
The dashboard
{"name": "dashboard_backend"}
get '/' do
commands = settings.sequel_conn[:commands]
.order(:id)
tasks = settings.sequel_conn[:tasks]
.order(Sequel.desc(:created_at))
.limit(100)
erb :index_tasks, locals: {
tasks: tasks,
commands: commands
}
end
{"filename": "views/index_tasks.erb"}
<%#
<<spdx_headers>>
%>
<h1 class="text-3xl font-bold p-10">Supervised processes</h1>
<h2 class="text-xl font-bold">Available commands</h2>
<table class="border-2 border-black">
<tr>
<% commands.columns.each do |column_name| %>
<th><%= column_name %></th>
<% end%>
<th>Launch</th>
</tr>
<% commands.each do |row| %>
<tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
<% row.each do |cell| %>
<td class="p-2"><%= cell[1] %></td>
<% end %>
<td>
<form action="/newtask/<%= row.first[1] %>" method="post">
<button type="submit">Start!</button>
</form>
</td>
</tr>
<% end %>
</table>
<h2 class="text-xl font-bold">Launched commands</h2>
<table class="border-2 border-black">
<tr>
<% tasks.columns.each do |column_name| %>
<th><%= column_name %></th>
<% end%>
<th>Full logs</th>
<th>Short logs</th>
</tr>
<% tasks.each do |row| %>
<tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
<% row.each do |cell| %>
<td class="p-2"><%= cell[1] %></td>
<% end %>
<td>
<a href="/getFullLogs/<%= row.first[1].to_s %>">🔗</a>
</td>
<td>
<a href="/getShortLogs/<%= row.first[1].to_s %>">🔗</a>
</td>
</tr>
<% end %>
</table>
Launching a task
Executing a task changes the status of the system, so it doesn't fit the pattern
of writing a GET handler.
Instead, we write a handler for a POST-type request, that reads the parameters of the
requested task (the command to run and its directory), generates an unique ID, and
inserts those informations in the database.
{"name": "requesting_a_new_task"}
post '/newtask/:cmdid' do
row = settings.sequel_conn[:commands]
.where(id: params[:cmdid])
.first
cmd = row[:command]
opts = { chdir: row[:cwd] }
execid = SecureRandom.uuid
settings.sequel_conn[:tasks]
.insert(
id: execid,
command: cmd,
cwd: opts[:chdir]
)
# Start a separate thread that executes the command, writes to the database
# all the command's output, and waits for the command to finish.
Thread.new do
start_and_monitor_cmd(cmd.split, opts, execid, settings.sequel_conn)
end
# While the background thread is working, this request finishes with a
# redirect-response towards the browser.
redirect "/getFullLogs/#{execid}"
end
The core of this application is based on code from https://dmytro.sh/blog/on-dangers-of-open3-popen3/.
We use sequence-numbers because the created_at timestamps have 1-second resolution, and if the application produces multiple messages in a second, those messages might be displayed out-of-order. With sequence-numbers, we can reliably show the messages in the same order they were printed.
{"name": "start_and_monitor"}
def start_and_monitor_cmd(cmd, opts, taskid, db_conn)
#
# Taken from https://dmytro.sh/blog/on-dangers-of-open3-popen3/
#
Open3.popen3(*cmd, opts) do |child_stdin, child_stdout, child_stderr, child_return|
child_stdin.close
readables = [child_stdout, child_stderr]
sequence_number = 0
until readables.empty?
ready_pipes, = IO.select(readables) # Blocking call
if ready_pipes.include? child_stdout
stdout_buf = child_stdout.read_nonblock(4096, exception: false)
if stdout_buf.nil?
# Child's STDOUT has reached the end-of-file. Stop reading from it.
readables.delete(child_stdout)
else
db_conn[:buffers].insert(
task_id: taskid,
buffer_type: 0,
sequence_number: sequence_number,
message: stdout_buf
)
sequence_number += 1
end
end
<<disable_rubocop_next>>
if ready_pipes.include? child_stderr
stderr_buf = child_stderr.read_nonblock(4096, exception: false)
if stderr_buf.nil?
# Child's STDERR has reached the end-of-file. Stop reading from it.
readables.delete(child_stderr)
else
db_conn[:buffers].insert(
task_id: taskid,
buffer_type: 1,
sequence_number: sequence_number,
message: stderr_buf
)
sequence_number += 1
end
end
<<enable_rubocop_next>>
end
return_status = child_return.value # Blocking call
db_conn[:tasks].where(id: taskid)
.update(
finished_at: Time.now,
return_message: return_status.to_s
)
end
end
Viewing the full logs of a task
On the backend, we create a handler for the GET requests to the path
/getFullLogs/:execid (where :execid is a parameter accessed under the name params[:execid]).
We query the database for the buffers associated with that task, and push that result-set to the :view_task frontend template.
{"name": "full_logs_backend"}
get '/getFullLogs/:execid' do
buffer_result = settings.sequel_conn[:buffers]
.where(task_id: params[:execid])
.order(:sequence_number)
.all
erb :view_task, locals: {
buffer: buffer_result
}
end
On the frontend side, we plug the data from the result set into an HTML-formatted table, one row at a time.
{"filename": "views/view_task.erb"}
<%#
<<spdx_headers>>
%>
<table class="border-2 border-black">
<tr>
<th>Timestamp</th>
<th>Content</th>
<th>Type</th>
</tr>
<% buffer.each do |row| %>
<tr class="odd=bg-white even:bg-gray-100 hover:bg-sky-700 hover:text-white">
<td class="p-2">
<%= row[:created_at] %>
</td>
<td class="p-2">
<%= Rack::Utils.escape_html(row[:message]).gsub("\n", '<br/>') %>
</td>
<td class="p-2">
<%= (row[:buffer_type] == 0) ? "STDOUT" : "STDERR" %>
</td>
</tr>
<% end %>
</table>
Viewing most recent logs of a task
Some long-running tasks acummulate a lot of output-lines.
We write a handler for /getShortLogs/:execid, that shows
just the last 100 rows.
The frontend template used for rendering is reused.
{"name": "short_logs_backend"}
get '/getShortLogs/:execid' do
buffer_result = settings.sequel_conn[:buffers]
.where(task_id: params[:execid])
.order(Sequel.desc(:sequence_number))
.limit(7)
.all
.reverse
erb :view_task, locals: {
buffer: buffer_result
}
end
Annex - Rubocop linting
RuboCop is a Ruby linter: it flags strange or awkward snippets of source-code, and sometimes it even can correct them.
To add RuboCop to a project, we first add it as dependency on a Gemfile, with a separate gem-group (called development). This makes RuboCop not a "strong" dependency but an optional one (ignored by default).
{"name": "rubocop_dependencies"}
# Rubocop linting
group :development, optional: true do
gem 'rubocop'
gem 'rubocop-performance'
gem 'rubocop-sequel'
end
To install it, we must first "unlock" the development group of dependencis, install it, and then run it.
# Enable and install the "development" group of dependencies
bundle config set with 'development'
bundle install
# Look for formatting issues
bundle exec rubocop
# Auto-fix formatting issues
bundle exec rubocop -A
Although the enforced coding style is quite good, some rules are not fitting the project, and we can disable them. Every project can configure which rules are applied, by configuring the tool.
{"filename": ".rubocop.yml"}
AllCops:
NewCops: enable
plugins:
- rubocop-sequel
- rubocop-performance
# Having complex code is not a problem. Sometimes we solve complex problems.
Metrics/AbcSize:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/MethodLength:
Enabled: false
# Ignore frozen-string warnings.
Style/FrozenStringLiteralComment:
Enabled: false
Style/MutableConstant:
Enabled: false
# This project's Gemfile lists dependencies in the logical order.
# Ordering them alphabetically would make no sense.
Bundler/OrderedGems:
Enabled: false
# Tech limitation.
Lint/ScriptPermission:
Enabled: false
Often we want to disable a rubocop rule just in a snippet of code. For this we can add a comment containing the rule's name. Below we disable the Style/Next rule.
{"name": "disable_rubocop_next"}
# rubocop:disable Style/Next
{"name": "enable_rubocop_next"}
# rubocop:enable Style/Next
{"filename": "views/layout.erb"}
<%#
<<spdx_headers>>
%>
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="/css/output.css" rel="stylesheet">
</head>
<body>
<main class="container mx-auto mt-28 mb-32">
<%= yield %>
</main>
</body>
</html>
{"filename": "sequeldump.rb"}
#!/usr/bin/env ruby
<<spdx_headers>>
require 'awesome_print'
require 'sequel'
DATABASE_FILENAME = 'store.db'
TASK = "17efecd7-f636-4c66-8093-b34833cde437"
#tasks = Sequel.connect("sqlite://#{DATABASE_FILENAME}")[:tasks]
#tasks.each do |row|
# ap row
#end
buffer = Sequel.connect("sqlite://#{DATABASE_FILENAME}")[:buffers]
.where(task_id: TASK)
.where(
Sequel.|(
Sequel.ilike(:message, '%reply from server%'),
Sequel.ilike(:message, '%error%'),
Sequel.ilike(:message, '%warn%')
)
)
.order(:created_at)
buffer.each do |row|
ap row
end
Annex - The license
This project attepts to be mostly-compliant with the REUSE Software specifications.
One requirement of the specification is to have the text of the chosen license (MIT in our case) in a file in the LICENSES directory. As such, we add it in:
{"filename": "LICENSES/MIT.txt"}
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
Another prerequisite of the specification is that every copyrightable file must include two comments using a special formatting, containing the license and the author/copyright holder.
{"name": "spdx_headers"}
# SPDX-FileCopyrightText: 2026 PersonalCompute.Net <publisher@PersonalCompute.Net>
# SPDX-License-Identifier: MIT
We can check the REUSE compliance using the lint tool:
pipx run reuse lint
Updating the CSS
Since the stylesheet doesn't change that often, the minified CSS is stored in the repo.
To regenerate it, download the standalong tool form https://github.com/tailwindlabs/tailwindcss/releases and run it in the main directory.
chmod +x ~/Downloads/tailwindcss-linux-x64
~/Downloads/tailwindcss-linux-x64 -i ./public/css/input.css -o ./public/css/output.css --minify
{"filename": "public/css/input.css"}
@import "tailwindcss";
{"filename": "public/css/output.css"}
/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-sky-700:oklch(50% .134 242.749);--color-gray-100:oklch(96.7% .003 264.542);--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-bold:700;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.static\!{position:static!important}.start-1{inset-inline-start:calc(var(--spacing) * 1)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-28{margin-top:calc(var(--spacing) * 28)}.mb-32{margin-bottom:calc(var(--spacing) * 32)}.\!block{display:block!important}.block{display:block}.contents{display:contents}.hidden{display:none}.inline{display:inline}.table{display:table}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-black{border-color:var(--color-black)}.p-2{padding:calc(var(--spacing) * 2)}.p-10{padding:calc(var(--spacing) * 10)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.\[rdoc-ref\:BigDecimal\@Not\+a\+Number\]{rdoc-ref:BigDecimal@Not+a+Number}.\[ruby-dev\:28445\]{ruby-dev:28445}.even\:bg-gray-100:nth-child(2n){background-color:var(--color-gray-100)}@media (hover:hover){.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:text-white:hover{color:var(--color-white)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}