Files
playpen/src/style.css
T
2026-08-27 11:02:32 -04:00

899 lines
27 KiB
CSS

/* Zen-style vertical tabs: a sidebar column with the terminal inset to its
right.
Every color here is a name from `palette.zig`, which writes the matching
`@define-color` block for the scheme in force; `appearance.zig` prepends that
and loads the pair as a single provider. Nothing below may hardcode a color —
a literal hex would be a rule that looks right in one scheme and wrong in the
other, it would look right in whichever one you happened to be testing in,
and the color editor in the settings page could not reach it at all.
`alpha()` is used for every wash rather than a second named color, so a
state's tint is always derived from the state's own color and the two
can't drift apart. */
.playpen-window {
background-color: @pp_bg;
}
/* The sidebar is a card, not a column: rounded on every corner and inset from
the window edge, so it sits on the backdrop the same way a pane does. It
used to run edge to edge and meet the content at a hairline, which left the
top-left corner reading as three unrelated things sharing one point — the
window edge, the header buttons and the first tab row all at once. */
.playpen-sidebar {
background-color: @pp_sidebar;
border: 1px solid @pp_border;
border-radius: 12px;
margin: 6px 0 6px 6px;
}
/* Room above the buttons so they clear the card's rounded top edge rather than
sitting in it.
The rule underneath is stated explicitly rather than left to the one Adwaita
draws for every header bar: that one is a shadow in the desktop theme's
color, which on this palette read as a stray line rather than as the pair
it now makes with the footer's. */
.playpen-sidebar headerbar {
background: none;
box-shadow: none;
min-height: 44px;
padding: 6px 8px 2px 8px;
border-bottom: 1px solid @pp_border;
}
/* The two header actions. Styled explicitly rather than left to Adwaita, whose
default button is a gray that belongs to a different window than this one.
Scoped by class instead of by `headerbar button` so the window controls
beside them keep their own appearance — those belong to the desktop, not to
the app, and making them match would be the wrong kind of consistent.
Two selectors because a MenuButton wraps its button in a `menubutton` node,
so the class lands one level above the thing that draws. */
button.playpen-header-button,
.playpen-header-button > button {
min-width: 30px;
min-height: 30px;
padding: 0;
margin: 0 1px;
border-radius: 8px;
color: @pp_text_dim;
background-color: @pp_surface_raised;
background-image: none;
border: 1px solid @pp_border;
box-shadow: none;
}
button.playpen-header-button:hover,
.playpen-header-button > button:hover {
background-color: @pp_row_hover;
background-image: none;
color: @pp_text;
}
/* The strip below the tab list, holding the settings button. Separated by a
rule so the button doesn't read as a row of the list it sits under. */
.playpen-sidebar-footer {
padding: 6px 8px;
border-top: 1px solid @pp_border;
}
/* The restore-session offer, between the tab list and the footer. Rules of its
own rather than a reused row style: it is not a tab, and styling it as one
would put a fifth thing in a column of four that all select something.
Bordered top and bottom so it reads as a band across the sidebar rather than
a card floating in it — the footer below already draws the same hairline, and
the pair of them frame the offer without adding a third weight. The accent
tint is what makes it the one thing in the column asking to be clicked, kept
low enough that it does not compete with the selected row above it. */
.playpen-restore {
padding: 6px 8px;
border-top: 1px solid @pp_border;
background-color: alpha(@pp_accent_strong, 0.08);
}
.playpen-restore-action {
padding: 5px 6px;
border-radius: 8px;
color: @pp_text_dim;
}
.playpen-restore-action:hover {
background-color: alpha(@pp_accent_strong, 0.16);
color: @pp_text;
}
/* The one accent-colored thing in the banner, so that what draws the eye is the
symbol for the action rather than the sentence describing it. */
.playpen-restore-icon {
color: @pp_accent_strong;
}
.playpen-restore-title {
font-weight: bold;
font-size: 0.92em;
}
/* How many tabs are on offer, sized and dimmed like the sublabels in the
settings dialog: a caption on the action above it, not a second action. */
.playpen-restore-detail {
color: @pp_text_faint;
font-size: 0.82em;
}
/* Quieter than the action beside it, and quieter still than a tab's close
button, which at least closes something you were looking at. Saying no to
this should be easy to find and hard to hit by accident. */
.playpen-restore-dismiss {
min-width: 22px;
min-height: 22px;
padding: 0;
color: @pp_text_faint;
}
.playpen-restore-dismiss:hover {
color: @pp_text;
}
.playpen-settings-button {
min-width: 26px;
min-height: 26px;
padding: 0;
color: @pp_text_faint;
}
.playpen-settings-button:hover {
color: @pp_text;
}
.playpen-list {
background: none;
padding: 4px 8px;
}
.playpen-list > row {
border-radius: 8px;
margin: 2px 0;
padding: 7px 8px;
color: @pp_text_dim;
transition: background-color 120ms ease;
}
.playpen-list > row:hover {
background-color: @pp_row_hover;
}
.playpen-list > row:selected {
background-color: @pp_row_selected;
color: @pp_text;
}
/* The kind icon picks up the accent on the current row. The status dot is
excluded: it carries a color that *is* the information, and this rule is
specific enough to beat the state classes, which used to leave the selected
row's dot accent-colored whatever it was trying to say. */
.playpen-list > row:selected image:not(.playpen-status-dot) {
color: @pp_accent_strong;
}
/* An emoji standing in for that icon. Sized to the width the icon occupies
rather than left to the glyph, so a sidebar mixing emoji rows with icon rows
still starts every label in the same column — emoji vary in advance width and
without this the labels beside them would step in and out by a pixel or two
per row. The font size is a little under the icon's 16px because a color
glyph fills its box where a symbolic icon leaves air around itself. */
.playpen-tab-emoji {
min-width: 16px;
font-size: 13px;
}
/* State on the row itself, not just on its dot.
One bar and two washes, and that is the whole vocabulary. Finished gets the
bar down its leading edge; the two states that actually want you get a wash
of their own color behind the row. The dot says what a row is doing once you
are reading it; these are what let you sweep a sidebar of a dozen tabs and
land on the one you care about without reading any of them.
Only finished carries a bar. Every state used to have one, drawn as an inset
shadow, and a shadow is clipped to the row's 8px corners: what landed was a
short bar with a curl at each end, four rows deep in slightly different
colors. Read as ornament rather than as signal, so it is gone from everything
but the one state whose bar is a plain rectangle.
These come after the :selected rules deliberately. Equal specificity means
source order decides, and a state outranks "this is the tab you are on". */
/* Working is the dot alone. It is the most common state by far, and a sidebar
where half the rows are marked all afternoon teaches you to stop looking. */
/* No wash for finished, for a different reason: a tinted row is what selection
looks like in this list, so washing the finished ones left "the tab you are
on" and "the tab that just finished" saying the same thing. The dot and the
bar are enough — nothing here is waiting on you.
The bar is a background image rather than an inset shadow precisely because
it is not clipped to the corners, so it stays a rectangle at any height. Tall
enough now to read as a deliberate mark rather than a stray pixel, and still
short of the row's full height so it reads as pinned to the middle of the
row. */
.playpen-list > row.playpen-attn-done {
background-image: linear-gradient(@pp_ok, @pp_ok);
background-size: 3px 18px;
background-position: left center;
background-repeat: no-repeat;
}
.playpen-list > row.playpen-attn-input {
background-color: alpha(@pp_warn, 0.14);
}
.playpen-list > row.playpen-attn-failed {
background-color: alpha(@pp_err, 0.14);
}
/* The current row keeps its own background: the wash and the selection would
otherwise blend into a color that reads as neither, and the dot is left to
carry the state on its own — which on the row you are already looking at is
enough. Done is absent because it has no wash to suppress, only a bar, and a
bar and a selection sit together fine. */
.playpen-list > row:selected.playpen-attn-input,
.playpen-list > row:selected.playpen-attn-failed {
background-color: @pp_row_selected;
}
/* The row being dragged, left dimmed and accented where it currently sits. As
with a pane there is no separate drop indicator: the sidebar reorders live
during the drag, so the list itself is the preview and this only has to say
which row is the one on the move.
Last of the row rules, so it wins over both the selection and the state
washes. While you are moving a tab, where it is going is the only thing you
are looking for. */
.playpen-list > row.dragging {
opacity: 0.65;
background-color: alpha(@pp_accent_strong, 0.18);
background-image: none;
}
/* The copy of that row traveling with the pointer, which GTK draws into a
surface of its own. What it paints is the row's *box* — the icon, the label
and the close button — and a row's background belongs to the row above that
box, so without this the label would float on nothing. Given the selected
row's background it reads as the row lifted off the list, which is what it
is.
Qualified by a class the window puts on this drag's icon, because the `dnd`
node belongs to every drag icon in the app: unqualified, this would also
repaint the one a pane drag carries. */
dnd.playpen-tab-drag {
background-color: @pp_row_selected;
color: @pp_text;
border-radius: 8px;
}
/* Keep the close button unobtrusive until the row is hovered or current. */
.playpen-close {
opacity: 0;
min-width: 20px;
min-height: 20px;
padding: 0;
}
.playpen-list > row:hover .playpen-close,
.playpen-list > row:selected .playpen-close {
opacity: 0.65;
}
.playpen-close:hover {
opacity: 1;
}
.playpen-content {
background-color: @pp_bg;
}
/* A view is the container for one tab's terminals. The left side has no
padding of its own: the root split's separator is the 6px gutter there, so
the content stays evenly inset while that gap doubles as the drag handle
that resizes the sidebar. */
.playpen-view {
padding: 6px 6px 6px 0;
}
/* Paned itself draws nothing; the panes inside it carry the styling. */
.playpen-view paned {
background: none;
}
/* Each terminal sits in its own rounded frame so multiple panes in a view
read as distinct surfaces. */
.playpen-pane {
background-color: @pp_surface;
border-radius: 10px;
border: 1px solid @pp_border;
}
.playpen-pane.active {
border-color: @pp_accent_muted;
}
.playpen-pane-header {
padding: 2px 4px 2px 10px;
background-color: @pp_surface_raised;
border-bottom: 1px solid @pp_border;
}
.playpen-pane.active .playpen-pane-header {
background-color: @pp_surface_raised_active;
}
.playpen-pane-title {
font-size: 0.82em;
color: @pp_text_faint;
}
.playpen-pane.active .playpen-pane-title {
color: @pp_text;
}
.playpen-pane-button {
min-width: 22px;
min-height: 22px;
padding: 0;
opacity: 0;
}
.playpen-pane:hover .playpen-pane-button,
.playpen-pane.active .playpen-pane-button {
opacity: 0.55;
}
.playpen-pane-button:hover {
opacity: 1;
}
.playpen-pane-icon {
color: @pp_text_faint;
-gtk-icon-size: 14px;
}
.playpen-pane.active .playpen-pane-icon {
color: @pp_accent_strong;
}
/* Status dots. One shared appearance for the sidebar row and the pane header,
colored entirely by which state class is on the icon. Symbolic icons take
their color from `color`, so nothing here needs a second asset.
These are read at a glance from across a screen full of tabs, so the colors
are picked to survive that: amber and red carry the two states that actually
want you, and they are the only warm colors anywhere in the window. */
.playpen-status-dot {
-gtk-icon-size: 11px;
opacity: 0;
}
.playpen-status-busy,
.playpen-status-done,
.playpen-status-input,
.playpen-status-failed {
opacity: 1;
}
/* Working: blue, and pulsing. Its own palette color rather than the accent —
the accent is whatever the theme is built around, and a green one put "still
working" in the same color as "finished", which is the one distinction this
whole indicator exists to make. The pulse carries the rest of it: a mark that
moves reads as something in progress before you have registered its color. */
.playpen-status-busy {
color: @pp_busy;
animation: playpen-pulse 1.6s ease-in-out infinite;
}
/* Finished while you were elsewhere. Green, and still — the dot that has
stopped moving is the one that has stopped working. */
.playpen-status-done {
color: @pp_ok;
}
/* Blocked on you. Amber, and not animated — a pulsing dot reads as progress,
and this is the opposite of progress. */
.playpen-status-input {
color: @pp_warn;
}
.playpen-status-failed {
color: @pp_err;
}
@keyframes playpen-pulse {
0% { opacity: 0.35; }
50% { opacity: 1; }
100% { opacity: 0.35; }
}
/* Renaming a tab: a single entry in a popover hanging off the row. */
.playpen-rename {
padding: 8px;
min-width: 220px;
}
/* The row's right-click menu. Narrower than the rename popover and with tighter
padding: it holds two words per line, and a menu sized like a form reads as
though something failed to fill it. */
.playpen-row-menu {
padding: 4px;
min-width: 150px;
}
.playpen-row-menu > button {
padding: 5px 8px;
border-radius: 6px;
}
/* The rule between what a row *is* and whether it is allowed to interrupt you.
Inset from the popover's edges so it reads as a divider inside a list rather
than as the list being cut in two. */
.playpen-row-menu > separator {
margin: 3px 6px;
background-color: @pp_border;
}
/* What a tab's mute is currently doing, above the ways out of it. Sized and
dimmed like the sublabels in the settings dialog, because it is the same kind
of thing: a caption on the controls under it, not a control. */
.playpen-row-menu-state {
padding: 2px 8px;
color: @pp_text_faint;
font-size: 0.85em;
}
/* A web pane's navigation bar, below the pane header. Kept visually quieter
than the header so the two rows don't compete. */
.playpen-nav {
padding: 4px 6px;
background-color: @pp_surface;
border-bottom: 1px solid @pp_border;
}
.playpen-nav-button {
min-width: 24px;
min-height: 24px;
padding: 0;
color: @pp_text_dim;
}
.playpen-nav-button:disabled {
opacity: 0.3;
}
.playpen-nav-entry {
min-height: 24px;
margin-left: 4px;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.85em;
background-color: @pp_bg;
color: @pp_text;
border: 1px solid @pp_border;
box-shadow: none;
}
.playpen-nav-entry:focus-within {
border-color: @pp_accent;
}
/* Find-in-page, in the same clothes as the nav bar above it: it is the same
kind of furniture, just one that comes and goes. */
.playpen-find {
padding: 4px 6px;
background-color: @pp_surface;
border-bottom: 1px solid @pp_border;
}
/* A search with no results says so in the box's border rather than only in
the count beside it, which is easy to miss mid-typing. */
.playpen-find-entry.error {
border-color: @pp_err;
}
.playpen-find-status {
margin: 0 4px;
font-size: 0.8em;
color: @pp_text_dim;
}
/* The pane currently filling its tab. The other panes are still open and still
running, just not drawn, so this is marked quietly — a lit border rather than
anything alarming. The header's toggle icon carries the rest of the message. */
.playpen-pane.zoomed {
border-color: @pp_accent;
}
.playpen-pane.zoomed .playpen-pane-button {
opacity: 0.55;
}
/* State on a pane's own frame, so a tab full of splits says which pane is
asking without being read pane by pane.
Only the three states that are news get a border. Working deliberately does
not: it is the resting state of every pane you have set going, and coloring
the frame for it would leave the whole view repainting itself all afternoon
while saying nothing you did not already know. Its pulsing dot is enough.
After `.active` and `.zoomed` in source order, so a state outranks both —
they say where you are, which you know, and this says what happened, which
you don't. `.dragging` still wins over all of it, further down: while you are
moving a pane, feedback about the move is the only thing that matters. */
.playpen-pane.playpen-attn-done {
border-color: @pp_ok;
}
.playpen-pane.playpen-attn-input {
border-color: @pp_warn;
}
.playpen-pane.playpen-attn-failed {
border-color: @pp_err;
}
/* The header carries a wash of the same color. The border alone is a hairline
around a large shape; the header is a solid band right next to the dot and
the title, which is what makes a pane readable at a glance in a four-way
split. */
.playpen-pane.playpen-attn-done .playpen-pane-header {
background-color: alpha(@pp_ok, 0.13);
}
.playpen-pane.playpen-attn-input .playpen-pane-header {
background-color: alpha(@pp_warn, 0.13);
}
.playpen-pane.playpen-attn-failed .playpen-pane-header {
background-color: alpha(@pp_err, 0.13);
}
/* The pane being dragged. There is no separate drop indicator: the layout
rearranges live during the drag, so the view itself is the preview. */
.playpen-pane.dragging {
opacity: 0.65;
border-color: @pp_accent_strong;
}
/* Saved-layout menu, hanging off the sidebar header. */
.playpen-layout-menu {
min-width: 260px;
}
.playpen-layout-menu button {
padding: 4px 8px;
border-radius: 6px;
}
.playpen-layout-empty {
padding: 8px;
color: @pp_text_faint;
font-size: 0.9em;
}
/* Layout dialogs: opening one, and saving the current tab as one. */
.playpen-dialog {
background-color: @pp_surface;
}
.playpen-dialog-content {
padding: 16px;
}
.playpen-dialog-actions {
padding: 12px 16px;
border-top: 1px solid @pp_border;
}
.playpen-dialog-heading {
margin-top: 8px;
font-weight: bold;
color: @pp_text;
}
.playpen-dialog-label {
color: @pp_text_dim;
}
.playpen-dialog-sublabel {
font-size: 0.88em;
color: @pp_text_faint;
}
.playpen-dialog-hint {
font-size: 0.85em;
color: @pp_text_faint;
}
.playpen-dialog-error {
color: @pp_err;
}
/* A directory parameter's field, and the completions hanging under it. The list
is a hint rather than a menu — it never takes the focus — so it is styled
quietly: the surface it sits on, and rows that only stand out when they are
the one the arrow keys have landed on. */
.playpen-path-list contents {
padding: 4px;
min-width: 240px;
background-color: @pp_surface_raised;
}
.playpen-path-list list {
background-color: transparent;
}
.playpen-path-list row {
border-radius: 6px;
}
.playpen-path-row {
padding: 3px 8px;
color: @pp_text;
}
.playpen-path-more {
padding: 3px 8px;
font-size: 0.85em;
color: @pp_text_faint;
}
/* Each captured pane in the save dialog, so the sections read apart. */
.playpen-dialog-pane {
padding: 8px;
border: 1px solid @pp_border;
border-radius: 8px;
}
/* -------------------------------------------------------------------------
Settings
One section per group of preferences, each a titled card on the dialog's
surface. There is a single group today; the card is what stops the second
one from needing a redesign. */
.playpen-settings-group {
padding: 12px 14px;
border: 1px solid @pp_border;
border-radius: 10px;
background-color: @pp_surface_raised;
}
.playpen-settings-title {
font-weight: bold;
color: @pp_text;
}
/* The scheme picker: three linked buttons rather than a dropdown, so the whole
choice is visible without opening anything, and so the one you are on is
readable at a glance rather than by reading a word. */
/* `background-image: none` on every rule below is load-bearing. Adwaita paints
button states with a gradient *over* the background color, so setting the
color alone leaves a washed-out version of it — most visibly on the checked
button, where the accent came out as a pale gray-blue. `:backdrop` is
spelled out for the same reason: Adwaita dims unfocused windows, and the
selected scheme is exactly the thing that has to stay readable while you are
looking at the window behind this one to see what it did. */
.playpen-settings-choice > button {
padding: 5px 14px;
color: @pp_text_dim;
background-color: @pp_surface;
background-image: none;
border: 1px solid @pp_border;
box-shadow: none;
}
.playpen-settings-choice > button:hover {
background-color: @pp_row_hover;
background-image: none;
color: @pp_text;
}
.playpen-settings-choice > button:checked,
.playpen-settings-choice > button:checked:backdrop {
background-color: @pp_accent;
background-image: none;
border-color: @pp_accent;
/* Against a filled accent in either scheme — the light palette's accent is
dark enough that white text is the readable choice there too. */
color: #ffffff;
}
.playpen-settings-choice > button:checked:hover {
background-color: @pp_accent_strong;
background-image: none;
border-color: @pp_accent_strong;
}
/* -------------------------------------------------------------------------
The color editor
One row per color in the palette, in sections. The rows are deliberately
plain: on a page where thirty-nine swatches are the point, the swatch should
be the only thing on a line carrying any color, and a bordered card per
section would read as noise. The space between sections does that work
instead. */
.playpen-color-group {
margin-top: 2px;
}
.playpen-color-group-title {
font-size: 0.9em;
font-weight: bold;
color: @pp_text_dim;
}
/* The value beside the swatch, so a palette can be read off and copied out as
text rather than only poked at through a picker. */
.playpen-color-hex {
font-family: monospace;
font-size: 0.85em;
color: @pp_text_faint;
}
/* Adwaita gives a color button the full button treatment: a gradient over the
color, and a border in a gray belonging to neither palette. What is wanted
is a rectangle of the color with this app's border around it, so the swatch
reads as the color rather than as a button holding one. */
.playpen-color-group colorbutton > button {
padding: 0;
min-width: 0;
min-height: 0;
background-image: none;
box-shadow: none;
border: 1px solid @pp_border;
border-radius: 6px;
}
.playpen-color-group colorbutton > button:hover {
border-color: @pp_border_strong;
}
/* The contrast slider, for the same reason as the swatch above it: Adwaita
paints a scale in the *desktop's* accent color, which on a page whose whole
subject is the color this window is painted from would be the one thing on
screen ignoring it. Left stock, dragging it toward a warm theme leaves a
blue bar sitting in the middle of the result. */
.playpen-contrast trough {
background-color: @pp_surface_raised_active;
}
.playpen-contrast slider {
background-color: @pp_accent;
border: 1px solid @pp_accent_strong;
box-shadow: none;
}
.playpen-contrast:disabled slider {
background-color: @pp_border_strong;
border-color: @pp_border_strong;
}
/* The tick at the neutral position, and the two words at the ends. Faint on
purpose: they label the ends of a scale whose real readout is the window
behind the dialog. */
.playpen-contrast marks indicator {
background-color: @pp_border_strong;
}
.playpen-contrast marks label {
font-size: 0.8em;
color: @pp_text_faint;
}
/* -------------------------------------------------------------------------
The startup list
One card per tab the app opens itself with. The border is doing real work: a
row's parameter fields sit *under* its header line, and without something
drawing the boundary they read as belonging to the row below them instead. */
.playpen-startup-row {
padding: 8px;
border: 1px solid @pp_border;
border-radius: 8px;
}
/* Indented under the header line, which is the other half of the same grouping:
these fields are a property of the layout picked above them. */
.playpen-startup-params {
margin-left: 10px;
}
/* -------------------------------------------------------------------------
The emoji picker
A tab's chosen glyph, then a search box over a grid of every glyph on offer.
The grid is a GtkFlowBox, so each cell is a `flowboxchild` wrapping a label
and the hover and selection states have to be drawn here — Adwaita styles a
flow box for lists of cards, which at this size leaves the cells looking like
nothing in particular. */
/* The current choice, big enough to check from across the dialog. Boxed to the
same square whether it holds a glyph or the word "None", so switching between
them doesn't shift the text beside it. */
.playpen-emoji-preview {
min-width: 44px;
min-height: 44px;
font-size: 26px;
border: 1px solid @pp_border;
border-radius: 10px;
background-color: @pp_surface;
}
.playpen-emoji-preview-none {
font-size: 0.85em;
color: @pp_text_faint;
}
/* Sunk into the dialog rather than raised off it: the grid is a well you look
into, and the section card it sits in is already the raised surface. */
.playpen-emoji-scroller {
border: 1px solid @pp_border;
border-radius: 10px;
background-color: @pp_surface;
}
.playpen-emoji-grid {
padding: 6px;
background: none;
}
.playpen-emoji-cell {
font-size: 19px;
}
.playpen-emoji-grid > flowboxchild {
padding: 0;
border-radius: 8px;
background: none;
transition: background-color 100ms ease;
}
.playpen-emoji-grid > flowboxchild:hover {
background-color: @pp_row_hover;
}
/* The glyph in force. `background-image: none` for the same reason as the theme
buttons above: Adwaita paints selection with a gradient over the color, and
without this the accent comes out washed. */
.playpen-emoji-grid > flowboxchild:selected,
.playpen-emoji-grid > flowboxchild:selected:backdrop {
background-color: @pp_accent_muted;
background-image: none;
box-shadow: inset 0 0 0 1px @pp_accent;
}
/* The divider between the sidebar and the content: the gutter that was always
there, made draggable. It paints as the backdrop so nothing looks different
until it is hovered, the same way the pane dividers behave. */
.playpen-root-split > separator {
background-color: @pp_bg;
min-width: 6px;
}
.playpen-root-split > separator:hover {
background-color: @pp_accent_muted;
}
/* Divider between panes. Wide enough to grab without hunting for it. */
.playpen-view paned > separator {
background-color: @pp_bg;
min-width: 6px;
min-height: 6px;
}
.playpen-view paned > separator:hover {
background-color: @pp_accent_muted;
}