Aparência

Componentes

As peças de tuff.css, cada uma com a marcação que a produz. Toda amostra desta página é renderizada com o mesmo Tuff que um app recebe; o markup embaixo de cada uma é o que se copia. Um elemento com classe tuff-* mede pela borda (box-sizing: border-box) mesmo num documento sem o reset de tuff-base.css.

Painel e seção

O painel é a superfície de trabalho: --ds-bg2 sobre a página, com moldura e o respiro de --ds-gap-lg. Ele é um flex em coluna, e é isso que deixa a barra de ações grudar no fim. A seção (.tuff-sec) é o par título e descrição, com a hierarquia obrigatória: o título em --ds-text-mid e a descrição em --ds-text-dim, um corpo menor.

Servidor
Onde o backend deste app roda, e como você chega nele.

socket
/home/ana/.vssh-apps/notas/app.sock
pid
48213
<div class="tuff-painel">
  <div class="tuff-sec">
    <div class="tuff-sec-titulo">Servidor</div>
    <div class="tuff-sec-desc">Onde o backend deste app roda, e como você chega nele.</div>
  </div>
  <hr class="tuff-divisor">
  <dl class="tuff-kv tuff-kv--mono">
    <dt>socket</dt><dd>/home/ana/.vssh-apps/notas/app.sock</dd>
    <dt>pid</dt><dd>48213</dd>
  </dl>
  <div class="tuff-acoes tuff-acoes--direita">
    <button class="tuff-btn">reiniciar</button>
    <button class="tuff-btn tuff-btn--primario">salvar</button>
  </div>
</div>

.tuff-divisor é a linha de 1px em --ds-border. .tuff-acoes fica no rodapé do painel por margin-top: auto, que só morde dentro de um flex em coluna; --direita alinha os botões à direita.

Alça de largura

A borda entre duas regiões lado a lado, que a pessoa arrasta para mudar a largura de uma delas: o painel de detalhes ao lado de uma lista, a lateral de um navegador de pastas. A alça desenha a linha de 1px que a borda da região desenharia, e o ponteiro a pega a 4px de cada lado dela. A linha clareia sob o ponteiro, e só leva --ds-accent no arraste e no foco do teclado.

a lista
<div style="display:flex; height:140px; border:1px solid var(--ds-border); border-radius:var(--ds-radius-lg); overflow:hidden">
  <div style="flex:1; min-width:0; padding:var(--ds-gap-md)">a lista</div>
  <div class="tuff-alca" aria-label="Largura do painel de detalhes"></div>
  <aside id="detalhes" style="width:240px; padding:var(--ds-gap-md); background:var(--ds-bg2)">o painel</aside>
</div>

A alça é filha de um flex em linha, entre as duas regiões, e a região que ela redimensiona não leva borda própria do lado dela. O comportamento vem de tuff.js:

const detalhes = document.getElementById('detalhes');
const alca = TuffAlca.ligar(document.querySelector('.tuff-alca'), {
  alvo: detalhes,
  lado: 'fim',      // a região vem depois da alça; 'inicio' para uma lateral antes dela
  min: 200,
  max: () => detalhes.parentElement.clientWidth - 320,   // o que fica para a lista
  padrao: 280,      // a largura do duplo clique
  inicial: Number(localStorage.getItem('largura-detalhes')) || null,
  aoMudar: (largura) => localStorage.setItem('largura-detalhes', String(largura)),
});
// alca.largura(320), alca.destruir()

ligar dá à alça o papel de separador, a põe na ordem de tabulação e escreve a largura em aria-valuenow, entre aria-valuemin e aria-valuemax, para o leitor de tela dizer quanto o painel tem. As setas andam 16px (64 com Shift) e levam a borda para o lado delas, Home e End vão aos limites, e o duplo clique volta ao padrao. A largura conta o padding e a borda da região, que é o que ela ocupa na tela. O max pode ser uma função, perguntada a cada gesto, para o limite acompanhar o contêiner, e a largura inicial só passa pelo min, porque o contêiner pode ainda não ter tamanho quando o app liga a alça. Guardar a escolha é do app, no aoMudar. Durante o arraste o cursor é o mesmo na página inteira, e um iframe ou embed no caminho não toma o ponteiro.

Botões

Três variantes, e nenhuma a mais: o shell tem exatamente estas, e o motivo de não inventar outras é ter um vocabulário em vez de uma variante por tela. O primário tira a cor de --ds-accent e o texto de --ds-on-accent; o de perigo é vazado, em --ds-danger.

<button class="tuff-btn">comum</button>
<button class="tuff-btn tuff-btn--primario">primário</button>
<button class="tuff-btn tuff-btn--perigo">apagar</button>
<button class="tuff-btn" disabled>indisponível</button>

O botão de ícone é quadrado, sem o respiro lateral que existe para acomodar palavra. Ele precisa de aria-label: o nome não está na tela, e sem o rótulo um leitor de tela anuncia "botão" e nada mais. Dentro de uma .tuff-barra, de um .tuff-chrome ou de um .tuff-transporte ele perde a moldura, e a borda volta no hover, que é quando ela informa algo.

<button class="tuff-btn tuff-btn--icone" aria-label="Recarregar"><svg class="tuff-ico"><use href="#ico-refresh"></use></svg></button>
<button class="tuff-btn tuff-btn--icone" aria-label="Ampliar"><svg class="tuff-ico"><use href="#ico-zoom-in"></use></svg></button>
<button class="tuff-btn"><svg class="tuff-ico"><use href="#ico-save"></use></svg> com rótulo</button>

Um botão que liga e desliga alguma coisa mostra o próprio estado por aria-pressed, e é esse atributo que o seletor lê: o leitor de tela e o olho não têm como divergir. O realce é o mesmo do item ativo da gaveta, que é como o ambiente já diz "este aqui está valendo".

<div class="tuff-barra">
  <button class="tuff-btn tuff-btn--icone" aria-pressed="true" aria-label="Repetir"><svg class="tuff-ico"><use href="#ico-repeat"></use></svg></button>
  <button class="tuff-btn tuff-btn--icone" aria-pressed="false" aria-label="Aleatório"><svg class="tuff-ico"><use href="#ico-shuffle"></use></svg></button>
</div>

Campos

Nunca controle nativo. O custo de ignorar isso é um input type="date" abrindo um calendário branco no meio de uma janela escura. O rótulo (.tuff-rotulo) vem acima do campo, pequeno e em --ds-text-dim; o campo tem fundo --ds-bg-input e a borda muda para o destaque com foco.

<div>
  <label class="tuff-rotulo" for="ex-nome">Nome do projeto</label>
  <input class="tuff-campo" id="ex-nome" placeholder="sem espaços">
</div>
<div>
  <label class="tuff-rotulo" for="ex-notas">Notas</label>
  <textarea class="tuff-campo" id="ex-notas" rows="3">O textarea cresce só na vertical.</textarea>
</div>

.tuff-select basta sozinho: uma classe, um controle. A seta é um SVG embutido, porque appearance: none apaga a nativa e não há pseudo-elemento em select para desenhar outra. A lista aberta também é estilizada, que é o item mais fácil de esquecer: no Windows ela herda o fundo do sistema e sai branca sob um controle escuro.

<div>
  <label class="tuff-rotulo" for="ex-motor">Motor</label>
  <select class="tuff-select" id="ex-motor">
    <option>nenhum</option>
    <option>Xpra</option>
    <option>Scramjet</option>
  </select>
</div>

A busca (.tuff-busca) é um input type="search" com a lupa embutida e a cruz nativa do WebKit retirada; ela está em Listas e navegação.

Switch

Um input type="checkbox" de verdade por baixo: ele traz teclado, foco, :checked e o rótulo clicável de graça. Desenhar um switch com div custa os quatro. O input fica invisível sobre o trilho, e o trilho (.tuff-switch-trilho) desenha o estado a partir de :checked.

<label class="tuff-switch">
  <input type="checkbox" checked aria-label="Manter o backend vivo">
  <span class="tuff-switch-trilho"></span>
</label>
<label class="tuff-switch">
  <input type="checkbox" aria-label="Aceleração por GPU">
  <span class="tuff-switch-trilho"></span>
</label>
<label class="tuff-switch">
  <input type="checkbox" disabled aria-label="Indisponível">
  <span class="tuff-switch-trilho"></span>
</label>

Linha de configuração

O par rótulo e controle, em grupo. O título em --ds-text, a descrição em --ds-text-dim e o controle à direita; as linhas de um .tuff-grupo se separam por uma divisa. É a forma de uma tela de opções, e é a mesma da janela de Configurações do ambiente.

Manter o backend vivo
Fechar a janela não encerra o processo.
Qualidade
Vale para novas sessões.
<div class="tuff-grupo">
  <div class="tuff-linha">
    <div class="tuff-linha-corpo">
      <div class="tuff-linha-titulo">Manter o backend vivo</div>
      <div class="tuff-linha-desc">Fechar a janela não encerra o processo.</div>
    </div>
    <div class="tuff-linha-ctrl">
      <label class="tuff-switch">
        <input type="checkbox" checked aria-label="Manter o backend vivo">
        <span class="tuff-switch-trilho"></span>
      </label>
    </div>
  </div>
  <div class="tuff-linha">
    <div class="tuff-linha-corpo">
      <div class="tuff-linha-titulo">Qualidade</div>
      <div class="tuff-linha-desc">Vale para novas sessões.</div>
    </div>
    <div class="tuff-linha-ctrl">
      <div class="tuff-seg">
        <label class="tuff-seg-btn"><input type="radio" name="ex-q">baixa</label>
        <label class="tuff-seg-btn"><input type="radio" name="ex-q" checked>média</label>
        <label class="tuff-seg-btn"><input type="radio" name="ex-q">alta</label>
      </div>
    </div>
  </div>
</div>

Controle segmentado

Radios de verdade por baixo, pelo mesmo motivo do switch: setas do teclado, agrupamento e foco vêm prontos. :has() é o que pinta o rótulo a partir do estado do input sem uma linha de JavaScript. Os input de um mesmo controle levam o mesmo name. O segmento escolhido é neutro, um degrau mais claro com contorno e o texto em peso forte: um segmentado escolhe entre modos de ver, e o destaque cheio fica para o botão primário da tela.

<div class="tuff-seg">
  <label class="tuff-seg-btn"><input type="radio" name="ex-vista" checked>lista</label>
  <label class="tuff-seg-btn"><input type="radio" name="ex-vista">grade</label>
  <label class="tuff-seg-btn"><input type="radio" name="ex-vista">mapa</label>
</div>

Estado

A pílula responde "como está isto?" numa palavra, com as cores semânticas do ambiente e a borda na mesma cor a 45%. A --ocupado é neutra e parada: a palavra ("subindo", "reiniciando") já diz que algo anda. A tag é uma etiqueta neutra, e a tecla (.tuff-tecla) desenha um atalho.

no ar degradado fora subindo desconhecido
python3 ffmpeg python3-pip
Ctrl Shift P
<span class="tuff-pill tuff-pill--ok">no ar</span>
<span class="tuff-pill tuff-pill--aviso">degradado</span>
<span class="tuff-pill tuff-pill--erro">fora</span>
<span class="tuff-pill tuff-pill--ocupado">subindo</span>
<span class="tuff-pill">desconhecido</span>
<hr class="tuff-divisor">
<span class="tuff-tag">python3</span>
<span class="tuff-tag">ffmpeg</span>
<span class="tuff-tag">python3-pip</span>
<hr class="tuff-divisor">
<span class="tuff-tecla">Ctrl</span>
<span class="tuff-tecla">Shift</span>
<span class="tuff-tecla">P</span>

Dica

A linha explicativa que acompanha um controle: barra à esquerda em vez de caixa inteira, porque ela pertence ao que está acima, e uma caixa a transformaria numa peça independente. O texto corre com strong e code no meio; um ícone, quando existe, é o primeiro filho e flutua à esquerda.

O servidor resolve o caminho; a sua máquina não entra.
Sem systemd de usuário, nenhum limite é aplicado. É informação, sem erro.
O socket já está em uso por outra instância deste app.
Com ícone: ele flutua, e o parágrafo o contorna.
<div class="tuff-dica">O servidor resolve o caminho; a sua máquina não entra.</div>
<div class="tuff-dica tuff-dica--aviso">Sem systemd de usuário, nenhum limite é aplicado. É informação, sem erro.</div>
<div class="tuff-dica tuff-dica--erro">O socket já está em uso por outra instância deste app.</div>
<div class="tuff-dica"><svg class="tuff-ico tuff-ico--sm"><use href="#ico-info"></use></svg>Com ícone: ele flutua, e o parágrafo o contorna.</div>

Vazio

Uma tela vazia é um convite para agir. O estado vazio tem título e uma frase, e a frase diz o que fazer; que está vazio, a tela já mostra. Dentro dele as ações não grudam no fim: num vazio alto (o palco de um player, uma lista de tela inteira), o botão a centenas de pixels da frase que o explica seria lido como "abra um vídeo" sem botão nenhum por perto.

Nenhuma pasta escolhida
Escolha uma pasta do servidor para começar. O app lê e grava nela como você.
<div class="tuff-vazio">
  <div class="tuff-vazio-titulo">Nenhuma pasta escolhida</div>
  <div class="tuff-vazio-msg">Escolha uma pasta do servidor para começar. O app lê e grava nela como você.</div>
  <div class="tuff-acoes">
    <button class="tuff-btn tuff-btn--primario">escolher pasta</button>
  </div>
</div>

Espera

Spinner para "não sei quanto falta"; barra para "sei". Trocar os dois custa confiança: uma barra que anda até 90% e para ensina a não acreditar em barra nenhuma. A barra indeterminada é uma faixa que atravessa, sem fingir saber a fração; a --alerta troca o preenchimento para --ds-warn.

conectando ao servidor…
<div><span class="tuff-spinner"></span> <span class="tuff-sec-desc">conectando ao servidor…</span></div>
<div class="tuff-progresso"><div class="tuff-progresso-preenchido" style="width:64%"></div></div>
<div class="tuff-progresso tuff-progresso--alerta"><div class="tuff-progresso-preenchido" style="width:92%"></div></div>
<div class="tuff-progresso tuff-progresso--indeterminado"><div class="tuff-progresso-preenchido"></div></div>

A largura do preenchimento é style="width:…%" escrito pelo app, e a transição de --ds-dur faz a barra andar em vez de pular.

Ícones

.tuff-ico dá o tamanho (16px; --sm 14, --lg 20) e flex: none, para o ícone não encolher ao lado de um texto longo. A cor vem de currentColor, e é por isso que um ícone dentro de um .tuff-btn--perigo fica vermelho sem ninguém pedir. As formas vêm de tuff-icones.js; a lista inteira está em Ícones.

herda a cor
<svg class="tuff-ico tuff-ico--sm"><use href="#ico-folder"></use></svg>
<svg class="tuff-ico"><use href="#ico-folder"></use></svg>
<svg class="tuff-ico tuff-ico--lg"><use href="#ico-folder"></use></svg>
<span class="tuff-pill tuff-pill--erro"><svg class="tuff-ico tuff-ico--sm"><use href="#ico-x-circle"></use></svg> herda a cor</span>