GridKit v1.96.0

What is new in 1.96 and 1.95

1.96

New in 1.96 Timeline, Portrait video frame

1.96.0 2026-10-17

  • Added — a timeline: .gk-timeline
  • Added — a portrait video frame: .gk-video-frame

1.95

Changed in 1.95 Message

1.95.0 2026-10-17

  • Added — a small message: .gk-message-sm

All release notes

Table since 0.9

Complete table with all features

UnitNetVATStatusActions
ART-001Web Design Package Sflat€1,200.0020 %active
ART-002Hosting Standardpcs€9.9020 %active
ART-003SEO Consultingh€95.0020 %inactive
ART-004Logo Designflat€450.0020 %draft
ART-005Newsletter Setupflat€350.0020 %active
Show code
$table = new Table('articles');
$table->setData($articles)
    ->search(['article_number', 'name'])
    ->column('article_number', 'Article No.', ['width' => '120px', 'sortable' => true, 'nowrap' => true, 'muted' => true])
    ->column('name', 'Description', ['sortable' => true, 'nowrap' => true])
    ->column('unit', 'Unit', ['width' => '80px', 'nowrap' => true])
    ->column('net_price', 'Net', ['format' => 'currency', 'align' => 'right', 'width' => '100px', 'nowrap' => true])
    ->column('tax_rate', 'VAT', ['format' => 'percent', 'width' => '80px', 'nowrap' => true])
    ->column('is_active', 'Status', ['format' => 'label', 'nowrap' => true])
    ->filter('is_active', 'select', ['options' => ['active' => 'Active', 'inactive' => 'Inactive', 'draft' => 'Draft'], 'placeholder' => 'All Status'])
    ->button('edit', ['icon' => 'edit', 'class' => 'primary', 'position' => 'right', 'params' => ['id' => 'article_id']])
    ->button('delete', ['icon' => 'delete', 'class' => 'danger', 'position' => 'right', 'params' => ['id' => 'article_id']])
    ->newButton('New Article', ['icon' => 'add'])
    ->nowrap(true)
    ->paginate(5)
    ->render();

Invoice list with date and currency formatting

DueStatusActionsActions
RE-2026-001Mustermann GmbHFeb 1, 2026Mar 1, 2026€1,450.00paid
RE-2026-002Tech Solutions AGFeb 5, 2026Mar 5, 2026€3,200.00pending
RE-2026-003Weber & PartnerFeb 8, 2026Mar 8, 2026€890.50overdue
RE-2026-004Digital Agentur WienFeb 10, 2026Mar 10, 2026€5,600.00draft
RE-2026-005Startup Hub ViennaFeb 12, 2026Mar 12, 2026€2,100.00paid

Compact table without toolbar

E-MailRoleActive
Martin Huber[email protected]admin✓
Anna Schneider[email protected]editor✓
Thomas Berger[email protected]viewer–
Lisa Wagner[email protected]editor✓
Peter Gruber[email protected]admin–

Rows that open a side sheet since 1.91

List table since 1.92 · column priority since 1.92 · who cell since 1.92

Read in the row, change in the sheet: a click anywhere on a row opens its record beside the list — full screen on a phone. One name cell with two lines, groups instead of a status label per row, and colour only for what needs acting on. The six rules behind it are in the agent skill. size('list') sets the row they describe, and 'priority' lets columns give way by the list's own width — open the sheet or narrow the window and watch "Last seen" go first.

Users
UserPlanLast seenLast 30 days
With access3
Without access2
Show code
// Rule 4: colour only where something needs acting on — a count that
// is fine stays plain text.
foreach ($people as &$p) {
    $p['failing_html'] = $p['failing'] > 0
        ? '<span class="gk-label gk-label-red">' . (int) $p['failing'] . ' failing</span>'
        : '—';
}
unset($p);

(new Table('demo-people'))
    ->setData($people)
    ->caption('Users')
    ->size('list')
    ->groupBy('access', ['with' => 'With access', 'without' => 'Without access'])
    ->rowLink(['sheet' => 'demo-person-sheet', 'url' => 'form/f_person_sheet.php'])
    ->column('name', 'User', ['sub' => 'email'])
    ->column('plan', 'Plan', ['priority' => 3])
    ->column('seen', 'Last seen', ['muted' => true, 'priority' => 4])
    ->column('failing_html', 'Last 30 days', ['format' => 'html', 'priority' => 2])
    ->toolbar(false)
    ->render();

One row standing for many since 1.91

What is rarely needed folds into one row. The toggle shows and hides the rows its aria-controls names; the chevron and aria-expanded carry the state, so the label never has to change.

Users and chart profiles
UserPlan
Anna SchneiderPro
Thomas BergerTeam
Show code
echo <<<'HTML'
<div class="gk-table-wrap gk-table-mobile-card">
  <table class="gk-table">
    <caption class="gk-sr-only">Users and chart profiles</caption>
    <thead><tr><th scope="col">User</th><th scope="col">Plan</th></tr></thead>
    <tbody>
      <tr><td data-label="User">Anna Schneider</td><td data-label="Plan">Pro</td></tr>
      <tr><td data-label="User">Thomas Berger</td><td data-label="Plan">Team</td></tr>
      <tr class="gk-table-more"><td colspan="2">
        <button type="button" class="gk-table-more-toggle" aria-expanded="false" aria-controls="demo-profiles">
          <span class="gk-table-more-name">3 chart profiles</span>
          <span class="gk-cell-sub">Created from charts — they cannot sign in and have no plan.</span>
        </button>
      </td></tr>
    </tbody>
    <tbody id="demo-profiles" hidden>
      <tr><td data-label="User">Profile: Berlin, 1984</td><td data-label="Plan">—</td></tr>
      <tr><td data-label="User">Profile: Graz, 1991</td><td data-label="Plan">—</td></tr>
      <tr><td data-label="User">Profile: Linz, 2002</td><td data-label="Plan">—</td></tr>
    </tbody>
  </table>
</div>
HTML;

Sizes

size('sm') Compact
NameValueStatus
Widget A1.200 €active
Widget B340 €inactive
Widget C890 €active
size('md') Standard
NameValueStatus
Widget A1.200 €active
Widget B340 €inactive
Widget C890 €active
size('lg') Spacious
NameValueStatus
Widget A1.200 €active
Widget B340 €inactive
Widget C890 €active
$table->size('sm');  // compact
$table->size('md');  // standard (default)
$table->size('lg');  // spacious

Display variants

variant('default') Default
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('bordered') Bordered
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('striped') Striped
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('celled') Celled
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('padded') Padded
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('compact') Compact
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('selectable') Selectable
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('minimal') Minimal
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('flat') Flat
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft
variant('inverted') Inverted
DescriptionPriceStatus
Web Design Package1.200 €active
Hosting Standard9,90 €active
SEO Consulting95 €inactive
Logo Design450 €draft

Definition table

First column as label/key — ideal for detail views.

Company nameSSI Schaefer IT Solutions GmbH
Founded2003
LocationWien, Oesterreich
Employees24
Websitessi.at
StatusActive
$table->variant('default');     // Default
$table->variant('bordered');    // Full border lines
$table->variant('striped');     // Zebra stripes
$table->variant('celled');      // Grid lines around each cell
$table->variant('padded');      // Extra spacing
$table->variant('compact');     // Compact, more rows
$table->variant('selectable');  // Hover cursor, clickable
$table->variant('minimal');     // Only separator, no border
$table->variant('flat');        // Completely flat
$table->variant('inverted');    // Dark table (also in Light Mode)

// A variant is one slot: a second call REPLACES the first, it does not add.
$table->variant('celled')->variant('padded');   // celled is dropped — padded wins

// Variant and size are separate slots, so those two do stack:
$table->variant('striped')->size('sm');

Mobile-Responsive

Resize the browser window to <768px to see the mobile layout.

mobile('card') – Standard
Article No.DescriptionPriceStatus
ART-001Webdesign Paket S1.200 €active
ART-002Hosting Standard9,90 €active
ART-003SEO Beratung95 €inactive
mobile('scroll') – Horizontal Scroll
Article No.DescriptionPriceStatus
ART-001Webdesign Paket S1.200 €active
ART-002Hosting Standard9,90 €active
ART-003SEO Beratung95 €inactive
hideOnMobile – Hide columns
Article No.DescriptionPriceStatus
ART-001Webdesign Paket S1.200 €active
ART-002Hosting Standard9,90 €active
ART-003SEO Beratung95 €inactive
// Demo 1: Complete article table with pagination
$table = new Table('articles');
$table->setData($articles)
    ->search(['article_number', 'name'])
    ->column('name', 'Description', ['sortable' => true])
    ->column('net_price', 'Net', ['format' => 'currency'])
    ->column('tax_rate', 'VAT', ['format' => 'percent'])
    ->column('is_active', 'Status', ['format' => 'label'])
    ->button('edit', ['icon' => 'edit', 'class' => 'primary'])
    ->button('delete', ['icon' => 'delete', 'class' => 'danger'])
    ->newButton('New Article', ['icon' => 'add'])
    ->nowrap(true)
    ->paginate(5)
    ->render();

// Mobile-Responsive
$table->mobile('card');      // Cards on mobile (default)
$table->mobile('scroll');    // Horizontal Scroll
$table->column('desc', 'Description', ['hideOnMobile' => true]);

Form since 0.9

Grid Layout (16 columns)

Show code
$form = new Form('article_form');
$form->action('save/process_article.php')
    ->ajax()
    ->hidden('article_id', '')
    ->row()
        ->field('article_number', 'Article No.', 'text', ['required' => true, 'width' => 8])
        ->field('name', 'Description', 'text', ['required' => true, 'width' => 8])
    ->endRow()
    ->field('description', 'Description', 'textarea', ['rows' => 3])
    ->row()
        ->field('unit', 'Unit', 'select', ['options' => ['Stk' => 'Piece', 'h' => 'Hour', 'psch' => 'Flat rate'], 'width' => 5])
        ->field('net_price', 'Net price', 'number', ['step' => '0.01', 'width' => 5])
        ->field('tax_rate', 'VAT %', 'select', ['options' => ['20' => '20%', '10' => '10%', '0' => '0%'], 'width' => 6])
    ->endRow()
    ->field('is_active', 'Active', 'toggle', ['inline' => true])
    ->submit('Save')
    ->render();

Checkbox & Radio since 0.9

Toggle & Slider since 0.9

50
75

File Upload since 0.9

Drag files here or clickMax. 10MB

RichText Editor (CKEditor 5) since 0.9

Local vendor bundle (assets/ckeditor5/), no CDN. Initialization via IntersectionObserver — works in tabs and modals too.

RichText with pictures

Add 'upload' => '/your/endpoint' and the editor gains a picture button, drag & drop, paste, size handles and alt text. Without it nothing changes — an upload button with nowhere to put the file is worse than no button.

Pictures render as a plain <img> inside the paragraph, carrying their size as an inline style — deliberately not as <figure class="image">, so the same content survives being sent as an e-mail, where no stylesheet loads and Outlook does not lay out <figure> reliably.

Try it: click the picture below to get the size handles and the alt-text button. The upload button works too, but this public demo stores nothing and says so.

Clearable date/time fields

Trash icon only appears when a value is set — disappears automatically after clearing.

New field types

Uniform height of 44px for all input types. No browser spinner for number.

Select extensions since 0.9

Searchable Select – Country selection with search field


Multi-Select – Select multiple categories (with chips)

Webdesign
SEO
Hosting
Development
Support
Consulting

Ajax Select – Customer search via API (min. 2 characters)

// Grid Layout (16 columns)
$form->row()
    ->field('name', 'Name', 'text', ['width' => 8])
    ->field('email', 'E-Mail', 'email', ['width' => 8])
->endRow()
->field('agree', 'Accept terms', 'checkbox', ['checked' => true])
->field('payment', 'Payment method', 'radio', [
    'options' => ['card' => 'Credit card', 'bank' => 'Bank transfer'],
    'value' => 'card', 'inline' => true
])
->field('active', 'Active', 'toggle', ['inline' => true])
->field('volume', 'Volume', 'range', ['min' => 0, 'max' => 100, 'value' => 50])
->field('doc', 'Document', 'file', ['accept' => '.pdf', 'multiple' => true, 'maxSize' => '10MB'])
->field('content', 'Content', 'richtext', ['preset' => 'full'])
->field('datum',  'Date',    'date',     ['value' => date('Y-m-d'),      'clearable' => true])
->field('farbe', 'Color',  'color',  ['value' => '#6750a4'])
->field('preis', 'Price',  'number', ['placeholder' => '0.00'])

File Upload (extended)

Drag & Drop

Select files via drag & drop or click

Validation

Type, size (min/max), count and total size

Preview

Image thumbnails directly in the upload zone

Queue UI

Progress indicator, states, errors per file

Variant 1 — Simple

Document upload with type filter and size limit.

Drag file here or clickPDF or Word, max. 10 MB

Variant 2 — Images with preview

Multi-upload with image thumbnails directly in the zone.

Drag files here or clickMax. 8 MB

Variant 3 — Full configuration

All options combined: types, min/max size, total limit, file count.

Drag files here or clickMax. 10 MB/file · 50 MB total · 10 files

Queue states live

Manually cycle queue items through states: pending → uploading → done / error.

Validation error demo

Zone with strict limits — PDF only, max. 100 KB.

Drag file here or clickPDF only, max. 100 KB — other files will be rejected

Color Picker since 1.0

Styled native color input — color swatch on the left (clickable) + hex field on the right. Swatch and hex value sync automatically, validated as #RRGGBB.

Live Demo

Behavior

  • Clicking the color swatch opens the native browser color picker
  • The hex text field shows the current value and is directly editable
  • Both fields synchronize in real time
  • Validation: only valid #RRGGBB values are accepted
  • Uniform 44px height like all other GridKit inputs

Form Density

Wrap any form in .gk-form-compact for reduced spacing. All elements — inputs, selects, toggles, checkboxes — scale down proportionally.

Normal (default)
Compact .gk-form-compact
<!-- Normal (default) -->
<form>
  <div class="gk-field">...</div>
</form>

<!-- Compact -->
<form class="gk-form-compact">
  <div class="gk-field">...</div>
</form>

<!-- Also works as wrapper -->
<div class="gk-form-compact">
  <div class="gk-card">
    <div class="gk-field">...</div>
  </div>
</div>

Cards since 0.9

Card grid for structured content — responsive, with header, body and footer.

Auto-Grid (responsive)

Webdesign
Package S · from 1,200 €
Responsive design, CMS integration and SEO basics for small projects.
Hosting
Managed · from 9.90 €/month
SSD storage, daily backups and SSL included. 99.9% uptime.
SEO
Consulting · 95 €/h
Keyword analysis, on-page optimization and monthly reports.

Fixed column count

Performance
99.9% Uptime
Security
SSL & Firewall
Support
Available 24/7
Backups
Daily automatic

2 columns

Development
Frontend and backend development with modern technologies.
Consulting
Strategic IT consulting for digital transformation.

3 columns

Web
Mobile
Cloud
.gk-cards          // Auto-Grid (min 280px)
.gk-cards-2 / -3 / -4  // Fixed columns
.gk-card .gk-card-header / .gk-card-body / .gk-card-footer
.gk-card-link      // Clickable Card

Stat Cards since 0.9

Customers248
Revenue€84,250.00
Orders64
Outstanding€12,480.00
$stats = new StatCards('dashboard');
$stats->card('Customers', 248, ['format' => 'number', 'color' => 'blue'])
    ->card('Revenue', 84250.00, ['format' => 'currency', 'color' => 'green'])
    ->card('Outstanding', 12480.00, ['format' => 'currency', 'color' => 'red'])
    ->render();

Admin blocks

The small parts an admin page used to style itself, as classes. Light and dark come from the roles.

Compact figures since 1.92

3failing
12openfor 3 days
40done
Show code
(new StatCards('demo-tiles'))
    ->compact()
    ->card('failing', 3, ['color' => 'danger'])
    ->card('open', 12, ['sub' => 'for 3 days'])
    ->card('done', 40)
    ->render();

Setting since 1.92 and answer tiles since 1.92

Takes effect at once, also on running sessions.
Signs the person out everywhere.
Show code
(new Form('demo-settings'))
    ->field('demo_login', 'Sign-in allowed', 'toggle', ['hint' => 'Takes effect at once, also on running sessions.', 'value' => 1])
    ->field('demo_lock', 'Lock the account', 'toggle', ['hint' => 'Signs the person out everywhere.', 'danger' => true])
    ->field('demo_next', 'What happens now?', 'choice', ['value' => 'fix', 'options' => [
        'fix'   => 'Fix it now',
        'later' => ['title' => 'Later', 'hint' => 'Stays on the list until next week.'],
    ]])
    ->render();

Read-only value since 1.92

Plan Everything The superuser always has everything.
Show code
echo <<<'HTML'
<div class="gk-field-static">
  <span class="gk-field-static-label">Plan</span>
  <span class="gk-field-static-value">Everything</span>
  <span class="gk-field-hint">The superuser always has everything.</span>
</div>
HTML;

Status dots since 1.92 and avatar colours since 0.9

today 08:12 3 days ago running never
Show code
echo <<<'HTML'
<div class="gk-flex gk-flex-col gk-gap-xl">
  <div class="gk-flex gk-flex-wrap gk-gap-xl gk-items-center">
    <span><span class="gk-dot gk-dot-success" aria-hidden="true"></span> today 08:12</span>
    <span><span class="gk-dot gk-dot-warning" aria-hidden="true"></span> 3 days ago</span>
    <span><span class="gk-dot gk-dot-primary gk-dot-pulse" aria-hidden="true"></span> running</span>
    <span><span class="gk-dot gk-dot-outline" aria-hidden="true"></span> never</span>
  </div>
  <div class="gk-flex gk-gap-md">
    <span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-1" aria-hidden="true">AS</span>
    <span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-2" aria-hidden="true">TB</span>
    <span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-3" aria-hidden="true">LW</span>
    <span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-4" aria-hidden="true">PG</span>
    <span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-5" aria-hidden="true">MH</span>
  </div>
</div>
HTML;

Chart colours since 1.92

Model A 41 % Model B 27 % Model C 18 % Model D 9 % Other 5 %
Show code
echo <<<'HTML'
<div class="gk-flex gk-flex-wrap gk-gap-xl">
  <span><span class="gk-swatch gk-swatch-1" aria-hidden="true"></span> Model A 41 %</span>
  <span><span class="gk-swatch gk-swatch-2" aria-hidden="true"></span> Model B 27 %</span>
  <span><span class="gk-swatch gk-swatch-3" aria-hidden="true"></span> Model C 18 %</span>
  <span><span class="gk-swatch gk-swatch-4" aria-hidden="true"></span> Model D 9 %</span>
  <span><span class="gk-swatch gk-swatch-5" aria-hidden="true"></span> Other 5 %</span>
</div>
HTML;

Actions in a message since 1.0 Changed in 1.95, a finger-sized button since 0.9

2 runs are stuck.
Show code
echo <<<'HTML'
<div class="gk-message gk-message-warning">
  <span>2 runs are stuck.</span>
  <div class="gk-message-actions"><button type="button" class="gk-btn gk-btn-outlined gk-btn-warning gk-btn-touch">Check now</button></div>
</div>
HTML;

Layout

Segment since 1.0

Container for related content — single, stacked or with header.

A simple segment visually groups related content.

Segment with header

Project overview

Segments can have a header to describe the content.

Stacked segments

Step 1

Create account and confirm email.

Step 2

Fill out profile and configure settings.

Step 3

Create first project and get started.

Variants

Raised

With shadow — stands out more from the background.

Muted

Muted background — for secondary content.

Compact

Less padding — for denser layouts.

.gk-segment / .gk-segment-raised / .gk-segment-muted / .gk-segment-compact
.gk-segment-padded / .gk-segment-basic
.gk-segments > .gk-segment  // Stacked

Message since 1.0 Changed in 1.95

Notices, warnings and status messages for users.

A neutral message without a specific status.

Types

Information
Your changes are saved automatically.
Success
The profile has been successfully updated.
Warning
Your SSL certificate expires in 7 days.
Error
Could not establish connection to the database.
  • Host unreachable
  • Timeout after 30 seconds

Compact

Compact message for less space.

Small since 1.0 Changed in 1.95

A notch denser and without a margin of its own, for a place that sets its own gaps: the sentence above a chat, a hint beside a form. All four tones work, light and dark; a long address wraps.

Your data stays with us
The conversation is stored on our server and never passed on.
Saved. Nothing else to do.
The link expires in 7 days.
Could not reach the server. Try again in a minute.

Dismissible

This message can be closed.
.gk-message / .gk-message-info / .gk-message-success
.gk-message-warning / .gk-message-error / .gk-message-compact
.gk-message-sm       // Denser, no margin of its own
.gk-message-dismiss  // Close button

Navigation & Filter

Accordion since 1.0

Collapsible content areas — individually or as a group.

GridKit is a lightweight PHP/CSS/JS framework for admin dashboards and internal tools. Zero dependencies, M3-inspired.
Table, Form, Modal, Cards, StatCards, Sidebar, Header, Tabs, Accordion, Breadcrumb, Toast, Confirm and more. All with Light & Dark Mode.
No. GridKit has no dependencies — one CSS file, one JS file, done. Just include and get started.
<div class="gk-accordion" data-gk-single>
    <div class="gk-accordion-item open">
        <button class="gk-accordion-trigger">
            <span>Question?</span>
            <span class="material-icons">expand_more</span>
        </button>
        <div class="gk-accordion-content">
            <div class="gk-accordion-body">Answer...</div>
        </div>
    </div>
</div>

// data-gk-single: only one item open at a time
// .open: item open by default

Avatar since 0.9

Profile pictures with initials fallback, status dot and groups.

Sizes
XS
SM
MD
LG
XL
Status
MM
Group (stacked)
+3
.gk-avatar.gk-avatar-lg           // Sizes: xs, sm, md, lg, xl
.gk-avatar-status.online          // Status: online, away, busy, offline
.gk-avatar-group                  // Stacked group
.gk-avatar-square                 // Square instead of round

Breadcrumb since 0.9

Path navigation for orientation in nested areas.

Page content here...

<nav class="gk-breadcrumb">
    <a href="#"><span class="material-icons">home</span></a>
    <span class="gk-breadcrumb-sep"><span class="material-icons">chevron_right</span></span>
    <a href="#">Products</a>
    <span class="gk-breadcrumb-sep">...</span>
    <span class="gk-breadcrumb-current">Current Page</span>
</nav>

Tabs since 1.0

Tab navigation for switching between content areas.

This is the overview — the first tab is active by default.

Detail information is displayed here.

Settings and configuration options.

<div class="gk-tabs">
    <div class="gk-tab-nav">
        <button class="gk-tab-btn gk-active" data-tab="tab-1">Tab 1</button>
        <button class="gk-tab-btn" data-tab="tab-2">Tab 2</button>
    </div>
    <div class="gk-tab-panel gk-active" data-tab="tab-1">Content 1</div>
    <div class="gk-tab-panel" data-tab="tab-2">Content 2</div>
</div>

Combinations

Combination examples showing the full potential of GridKit.

Tabs + Accordion (combined)

Tabs for main categories, accordion for details — a common pattern in admin UIs.

3 packages available: S (1,200 €), M (2,400 €), L (3,500 €). All incl. responsive design and CMS.
Managed hosting from 9.90 €/month. SSD, backups, SSL included. 99.9% uptime guarantee.
24 active customers, 3 open inquiries
Mustermann GmbH
Since 2024 · 12 projects
Tech Solutions AG
Since 2023 · 8 projects
Weber & Partner
Since 2025 · 3 projects
Notifications


Segment + Message + Table

Dashboard view with status message and data table in a segment.

Server-Status
All 3 servers are online — last check 2 minutes ago.
ServerStatusCPURAMUptime
Baerli (server8)Online12%4.2 GB47 days
Theo (server7)Online8%3.8 GB23 days
Waldi (server6)Online15%5.1 GB31 days

Pagination (standalone) since 0.9

Page navigation — automatically generated by Table, but can also be used standalone.

<nav class="gk-pagination">
    <button class="gk-page-btn" disabled>&laquo;</button>
    <button class="gk-page-btn gk-active">1</button>
    <button class="gk-page-btn">2</button>
    <button class="gk-page-btn">&raquo;</button>
</nav>

FilterChips since 0.9

$chips = new FilterChips('status-filter', 'status');
$chips->chip('', 'All', ['count' => 152])
    ->chip('active', 'Active', ['count' => 89, 'color' => 'green'])
    ->render();

YearFilter since 0.9

$years = new YearFilter('year-nav', 'year');
$years->range(2022, 2026)->render();

TableHeader since 1.10

One unified filter/search bar for all tables. Three fixed sections: Status row · Toolbar · Advanced (collapsible). Stops every page from looking different.

Reset filters
Date & amount range
––
TableHeader::make('exp')
    ->status(fn() => $statusChips->render())
    ->search('q', $q, 'Search…', ['live' => 'exp-live'])
    ->filter(fn() => $yearFilter->render())
    ->filter('<select class="gk-filter">…</select>')
    ->advanced(fn() => renderDateRange())
    ->reset('/faktura/expenses')
    ->render();

Formatter since 0.9

Built-in formatting for table columns: currency, percent, date, boolean, label, email.

CurrencyPercentDateBooleanLabelEmail
€1,234.5620 %Feb 13, 2026✓paid[email protected]
€99.0010 %Jan 28, 2026–pending[email protected]
€5,500.000 %Dec 1, 2025✓cancelled
->column('amount', 'Amount', ['format' => 'currency'])    // €1,234.56  (follows the locale)
->column('tax', 'VAT', ['format' => 'percent'])           // 20 %
->column('date', 'Date', ['format' => 'date'])            // Feb 13, 2026  (follows the locale)
->column('active', 'Active', ['format' => 'boolean'])     // ✓ / –
->column('status', 'Status', ['format' => 'label'])       // Colored label
->column('email', 'E-Mail', ['format' => 'email'])        // mailto: Link

Gallery since 1.0

Two ways to show pictures. A gallery looks: tiles that open a viewer. An image picker chooses: the same tiles, but each one is a radio button, and the viewer can pick. Both use the same viewer, so it pages with the arrow keys, closes on Escape and gives the focus back.

Image picker since 1.94 — six images

Tap a tile to pick it, or Tab into the group and use the arrow keys. The magnifier opens the viewer; Select this image in it picks the picture you are looking at. The status line, the form value and the event log below follow every change. One tile has a small origin chip ("AI"), one a remove button, one has no preview, one is disabled and says why.

Header image

Selected: Fireplace at dusk

Own image
AI generated
Own image
stock-image.webp

Not released yet

Own image
Own image
Show code
\GridKit\ImagePicker::render($pickerItems(6, 1, true), ['name' => 'cover', 'id' => 'demo-pk-six']);

Form value: img-1

Events appear here.

Thirty images — two rows, then "Show all 30"

The picked tile (21 here) stays visible even though it lies beyond the fold. Resize the window: the number of tiles in the two rows follows the width. On a phone two tiles fit in a row, and thirty images are about four tiles and a button instead of a screen-long list.

Pick one of thirty

Selected: Cabin door (2)

Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image
Own image

One image, and none

One image

Nothing selected

Own image
Nothing yet (the shell waits for GK.picker.add)

Nothing selected

Keyboard

Keys in the picker and in its viewer
WhereKeyWhat happens
PickerTabEnters the group once, on the picked tile; the next Tab reaches that tile's magnifier (and remove button), then the fold button — thirty tiles are not sixty tab stops.
Picker← → ↑ ↓Moves to the next tile and picks it (native radios); disabled tiles are skipped.
PickerEnter on the magnifierOpens the viewer on that picture.
Viewer← →Previous or next picture (all of them, folded ones included).
ViewerTabStays inside: close, previous, next, "Select this image", "Close".
ViewerEscCloses it; the focus goes back to the magnifier of the picture last shown — or to the radio, when one was picked from the viewer.

Events and script

Picking is the radio's own change event, so a form needs nothing new. A caption button fires gk:pickeraction and does nothing else — here the page asks "Really?" first (GK.confirm) and only then calls GK.picker.remove(). The library never deletes anything itself.

picker.addEventListener('gk:pickeraction', (e) => {
    // e.detail = { action: 'remove', value: 'img-3', item: <the tile>, picker: <the container> }
    GK.confirm('Remove this image?', { danger: true }).then((ok) => {
        if (ok) GK.picker.remove(e.detail.picker, e.detail.value);
    });
});

GK.picker.add(picker, { value: 'img-9', title: 'Fresh upload', thumb: '/t/9.webp', full: '/g/9.webp' },
              { select: true, focus: true });   // picks it as a click would — one change event

// The viewer is open to your own lists too:
GK.lightbox.show([{ src: '/g/1.webp', caption: 'Fireplace', meta: 'Own image' }], 0, openerButton);

Image grid with lazy loading, hover overlay and lightbox (arrow keys, Escape).

<div class="gk-gallery">
    <div class="gk-gallery-item" data-lightbox="full.jpg" data-caption="Caption" data-lazy>
        <img data-src="thumb.jpg" alt="">
        <div class="gk-gallery-overlay">
            <span class="gk-gallery-caption">Caption</span>
        </div>
    </div>
</div>

// Variants:
.gk-gallery-sm              // Smaller thumbnails (100px min)
.gk-gallery-lg              // Larger thumbnails (220px min)
.gk-gallery-masonry         // Pinterest layout (columns)
// Lightbox: arrow keys, Escape, click outside to close
// data-lazy: load images only when visible

Portrait video frame since 1.96 New in 1.96

A frame for a 9:16 video or picture — a short, a story, a phone screenshot. It keeps its shape, is never wider than its column and never taller than 75 % of the window, and shows the whole picture. gk-video-frame-safe draws the zones a platform covers with its own buttons (about the top tenth and the bottom fifth) as guide lines — they never catch a click, so the controls still work. Nothing to show yet, or it broke: gk-video-frame-busy and gk-video-frame-error reuse the empty state. No autoplay.

Version 2 · 3 s
Safe zones shown: top 10 %, bottom 20 %
The first frame of the video: a blue field with a pale disc
A picture shows in the same frame
Video is being made
This takes a minute or two. You can leave the page.
Version 3
No video
The image service did not answer. Try again, or change the topic.
Version 3 failed
Show code
echo <<<'HTML'
<div class="demo-frames">
  <figure class="gk-video-frame">
    <div class="gk-video-frame-media">
      <video controls playsinline preload="metadata" aria-label="Sample video, 3 seconds" src="media/sample-portrait.webm"></video>
    </div>
    <figcaption class="gk-video-frame-caption">Version 2 · 3 s</figcaption>
  </figure>
  <figure class="gk-video-frame gk-video-frame-safe">
    <div class="gk-video-frame-media">
      <video controls playsinline preload="metadata" aria-label="Sample video with the safe zones shown" src="media/sample-portrait.webm"></video>
    </div>
    <figcaption class="gk-video-frame-caption">Safe zones shown: top 10 %, bottom 20 %</figcaption>
  </figure>
  <figure class="gk-video-frame">
    <div class="gk-video-frame-media">
      <img src="media/sample-portrait.svg" alt="The first frame of the video: a blue field with a pale disc">
    </div>
    <figcaption class="gk-video-frame-caption">A picture shows in the same frame</figcaption>
  </figure>
  <figure class="gk-video-frame gk-video-frame-busy">
    <div class="gk-video-frame-media">
      <div class="gk-empty" role="status">
        <div class="gk-empty-inner">
          <span class="material-icons gk-empty-icon" aria-hidden="true">movie</span>
          <div class="gk-empty-title">Video is being made</div>
          <div class="gk-empty-hint">This takes a minute or two. You can leave the page.</div>
        </div>
      </div>
    </div>
    <figcaption class="gk-video-frame-caption">Version 3</figcaption>
  </figure>
  <figure class="gk-video-frame gk-video-frame-error">
    <div class="gk-video-frame-media">
      <div class="gk-empty">
        <div class="gk-empty-inner">
          <span class="material-icons gk-empty-icon" aria-hidden="true">error</span>
          <div class="gk-empty-title">No video</div>
          <div class="gk-empty-hint">The image service did not answer. Try again, or change the topic.</div>
        </div>
      </div>
    </div>
    <figcaption class="gk-video-frame-caption">Version 3 failed</figcaption>
  </figure>
</div>
HTML;

Search since 1.24

System-wide quick search. GridKit provides the widget only — WHAT is searched is decided by your backend endpoint. Opens with Ctrl+K (Cmd+K on Mac) or any element carrying data-gk-search. Arrow keys select, Enter opens, Escape closes.

GK.search.init({
  url: '/demo/api/quicksearch.php',
  hotkey: 'ctrl+k',
  minLength: 0
});

// Server response. The German key names — gruppen / titel / treffer /
// untertitel / betrag — are still read as a fallback, but this is the shape:
{ "groups": [
    { "title": "Transactions",
      "items": [
        { "title": "Hetzner",
          "subtitle": "Jul 7, 2026",
          "amount": "€64.80",
          "url": "/transactions/2",
          "icon": "account_balance" } ] } ] }

Feedback & Dialogs

Toast since 0.9

Toast notifications for success, error and info messages. Disappear after 3 seconds.

GK.toast.success('Successfully saved!');
GK.toast.error('Error while saving!');
GK.toast.warning('Warning: Limit reached!');
GK.toast.info('3 new entries');

Announcement after a save since 1.93

A toast is for the corner; this line says "Saved." where the person is working, and a screen reader reads it. The region is on the page from the start and never hidden — empty, it draws no box. GK.announce() changes only its words, and the same words twice are read twice.

Show code
echo <<<'HTML'
<div class="demo-announce-row">
  <button type="button" class="gk-btn gk-btn-filled gk-btn-primary" data-demo-announce="Saved." data-tone="success">Save</button>
  <button type="button" class="gk-btn gk-btn-outlined gk-btn-danger" data-demo-announce="Could not save — the connection dropped." data-tone="error">Fail</button>
  <button type="button" class="gk-btn gk-btn-text gk-btn-neutral" data-demo-announce="">Clear</button>
</div>
<div class="gk-message gk-message-compact gk-announce" id="demo-announce" role="status" aria-live="polite" aria-atomic="true"></div>
<script>
document.addEventListener('click', function (e) {
  var b = e.target.closest('[data-demo-announce]');
  if (b) GK.announce('demo-announce', b.getAttribute('data-demo-announce'), b.getAttribute('data-tone'));
});
</script>
HTML;

Timeline since 1.96 New in 1.96

A log of steps, one under the other: what ran, in which order, how each step ended. A real ordered list, so the order is the information; each state has its own symbol and a word beside the title, never a colour alone. Small chips carry cost or provider, a failed step shows its message as a block. The ring of the running step turns, and stands still when the person asked for less motion. Light and dark follow from the roles; long addresses break.

A run in progress

  1. Write the script Done · 4.2 s
    Claude$0.03

    Three scenes, 38 words, ends with a question.

  2. Record the voice Done · 11 s
    Text to speech$0.11
  3. Make the pictures Running · 8 s so far

    Picture 2 of 3.

  4. Assemble the video Waiting
  5. Check the safe zones Waiting

A run that broke

  1. Write the script Done · 3.9 s
    Claude$0.03
  2. Make the pictures Failed · 31 s
    Image service

    The image service did not answer after 3 tries (https://example.org/api/v1/images/generate?scene=2&attempt=3, timeout after 30 s).

  3. Assemble the video Skipped

    Needs the pictures.

  4. Check the safe zones Skipped
Show code
echo <<<'HTML'
<div class="demo-timelines">
  <div>
    <h4>A run in progress</h4>
    <ol class="gk-timeline" role="list" aria-label="Steps of the run in progress">
      <li class="gk-timeline-step gk-timeline-ok">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Write the script</span>
            <span class="gk-timeline-state">Done</span>
            <span class="gk-timeline-time"><time datetime="2026-10-17T09:12:03">09:12:03</time> · 4.2 s</span>
          </div>
          <div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Claude</span><span class="gk-label gk-label-gray">$0.03</span></div>
          <p class="gk-timeline-detail">Three scenes, 38 words, ends with a question.</p>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-ok">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Record the voice</span>
            <span class="gk-timeline-state">Done</span>
            <span class="gk-timeline-time"><time datetime="2026-10-17T09:12:08">09:12:08</time> · 11 s</span>
          </div>
          <div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Text to speech</span><span class="gk-label gk-label-gray">$0.11</span></div>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-running" aria-current="step">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Make the pictures</span>
            <span class="gk-timeline-state">Running</span>
            <span class="gk-timeline-time"><time datetime="2026-10-17T09:12:19">09:12:19</time> · 8 s so far</span>
          </div>
          <p class="gk-timeline-detail">Picture 2 of 3.</p>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-waiting">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Assemble the video</span>
            <span class="gk-timeline-state">Waiting</span>
          </div>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-waiting">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Check the safe zones</span>
            <span class="gk-timeline-state">Waiting</span>
          </div>
        </div>
      </li>
    </ol>
  </div>
  <div>
    <h4>A run that broke</h4>
    <ol class="gk-timeline" role="list" aria-label="Steps of the run that broke">
      <li class="gk-timeline-step gk-timeline-ok">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Write the script</span>
            <span class="gk-timeline-state">Done</span>
            <span class="gk-timeline-time"><time datetime="2026-10-17T10:40:11">10:40:11</time> · 3.9 s</span>
          </div>
          <div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Claude</span><span class="gk-label gk-label-gray">$0.03</span></div>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-failed">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Make the pictures</span>
            <span class="gk-timeline-state">Failed</span>
            <span class="gk-timeline-time"><time datetime="2026-10-17T10:40:15">10:40:15</time> · 31 s</span>
          </div>
          <div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Image service</span></div>
          <p class="gk-timeline-error">The image service did not answer after 3 tries (https://example.org/api/v1/images/generate?scene=2&amp;attempt=3, timeout after 30 s).</p>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-skipped">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Assemble the video</span>
            <span class="gk-timeline-state">Skipped</span>
          </div>
          <p class="gk-timeline-detail">Needs the pictures.</p>
        </div>
      </li>
      <li class="gk-timeline-step gk-timeline-skipped">
        <span class="gk-timeline-marker" aria-hidden="true"></span>
        <div class="gk-timeline-body">
          <div class="gk-timeline-head">
            <span class="gk-timeline-title">Check the safe zones</span>
            <span class="gk-timeline-state">Skipped</span>
          </div>
        </div>
      </li>
    </ol>
  </div>
</div>
HTML;

Confirm since 0.9

Confirm dialogs as clean modals. Promise-based, with danger mode for destructive actions.

GK.confirm('Send invoice?', {
    title: 'Send Invoice', confirmText: 'Send'
}).then(ok => { if (ok) GK.toast.success('Sent!'); });

GK.confirm('Really delete?', {
    title: 'Delete', confirmText: 'Delete', danger: true
}).then(ok => { if (ok) { /* ... */ } });

Modal since 0.9

Modals in four sizes. Loaded via AJAX, backdrop click and ESC close them.

Nesting: Modal with form

A modal loads a form via AJAX – the most common use case.

Nesting: Modal with table + sub-modal

A large modal shows a customer list. Clicking "Edit" opens a second modal with the form.

GK.modal.open('Title', 'form/edit.php', {id: 42}, 'small');
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'medium');
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'large');
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'full');

$table->button('edit', ['icon' => 'edit', 'modal' => 'edit_form'])
    ->modal('edit_form', 'Edit', 'form/edit.php', ['size' => 'large']);

UI Components

Buttons since 0.9

Variants

Colors – Filled

Colors – Outlined

Colors – Tonal

Colors – Text

Icon-Only

Sizes

States

Icon Position & Special

As Link

Button Group

 

Shapes

FAB (Floating Action Button) since 0.9

use GridKit\Button;
echo Button::render('Save', ['variant' => 'filled', 'color' => 'primary', 'icon' => 'save']);
echo Button::icon('edit', ['variant' => 'filled', 'color' => 'primary']);
echo Button::fab('add');
echo Button::fab('edit', ['extended' => true, 'label' => 'Edit']);

Labels since 0.9

Colored labels for status display — also usable standalone, not only in tables.

Active Draft Error Archived Info
<span class="gk-label gk-label-green">Active</span>
<span class="gk-label gk-label-orange">Draft</span>
<span class="gk-label gk-label-red">Error</span>
<span class="gk-label gk-label-gray">Archived</span>
<span class="gk-label gk-label-blue">Info</span>

Tooltip since 0.9

Tooltips appear automatically on buttons with a title attribute. Hover over the buttons:

// Tooltip via title attribute (CSS-only, no JS)
Button::icon('edit', ['title' => 'Edit']);
<button class="gk-btn" title="Tooltip-Text">...</button>

Empty State since 0.9

Placeholder for empty tables or lists.

NameE-MailStatus
No entries found
<div class="gk-empty">No entries found</div>

Header since 0.9

Invoices

Minimal (title + user with initials only)

Dashboard

$header = new Header();
echo $header->title('Invoices')
    ->breadcrumb(['Dashboard' => '/', 'Invoices'])
    ->search('Search...', 'q')
    ->action(Button::render('New', ['icon' => 'add', 'size' => 'sm']))
    ->user('Martin Huber', ['avatar' => '...', 'menu' => [...]])
    ->sticky()->render();

Sidebar since 0.9

Responsive sidebar navigation with groups, icons, badges and mobile toggle. Visible live on this page.

$sidebar = new Sidebar('main');
$sidebar->brand('My Project', 'dashboard', 'v0.7.0')
    ->group('Modules')
    ->item('Dashboard', '/dashboard', 'analytics', ['active' => true])
    ->item('Invoices', '/invoices', 'receipt_long', ['badge' => 3])
    ->render();

GK.sidebar.toggle(); GK.sidebar.open(); GK.sidebar.close();

Themes since 0.9

M3-compliant theme system with 6 themes and Dark/Light Mode.

Layout Mode

Theme Selection

Live Preview

Card Title

Surface-container background with on-surface text colors.

Color Roles

Primary Secondary Tertiary Error
Primary Container Secondary Container Tertiary Container
Surface Container Highest
Theme::set('ocean', 'dark');
echo Theme::bodyTag('gk-root');
echo Theme::switcher();
GK.theme.set('forest'); GK.theme.toggleMode(); GK.theme.restore();

Examples

A whole application, not a mock-up

Everything below this box is a gallery — it shows what exists. If you would rather see what it is for, examples/invoices/ is a working invoice manager: create, edit, delete, with search, filter, sort and paging answered by the server. About 300 lines, no database.

Open the example application

Dashboard Demo

Invoices152
Revenue 2026€127,840.00
Open€18,320.00
Overdue€4,280.00
AmountDateStatus
RE-2026-001Mustermann GmbH€2,400.00Feb 1, 2026paid
RE-2026-002Technik AG€5,800.00Feb 5, 2026pending
RE-2026-003Design Studio€1,200.00Jan 15, 2026overdue
RE-2026-004Web Solutions€3,600.00Feb 10, 2026paid
RE-2026-005Media House€950.00Feb 12, 2026pending

Skeleton — Starting point for new projects

skeleton.php is the ready-made scaffolding for a new GridKit project. Just copy, adjust title + navigation, fill in sections — done.

Quick start:
cp /path/to/gridkit/skeleton.php my-project/index.php

Anatomy of a GridKit Page

Every page follows this structure — from skeleton.php to production.

Config
SIDEBAR
Header
Content
Footer · JS
Configuration
Theme::set('indigo') · Layout::bodyTag('gk-root')
Sidebar
->brand() · ->group() · ->item(label, url, icon)
Header
->title() · ->fixed() · ->user(name, opts)
Content
<main class="gk-main"> · Tables, Forms, Cards…
Footer
<script src="gridkit.js">

Auth — Login Protection

Auth protects pages with session-based login. Passwords are stored as bcrypt hashes.

Auth::protect(); // Protect page Auth::login($user, $pass); // Login attempt Auth::logout('login.php'); // Clear session Auth::check(); // Logged in? Auth::user(); // Username Auth::hashPassword('abc'); // bcrypt hash Auth::renderLogin([...]); // Login page

Open the login page live demo:

Open Login Demo

Credentials: demo / demo

Tooltip since 0.9

CSS-only Tooltips

Pure CSS tooltips via data-gk-tooltip attribute. No JavaScript needed.

Top (default) Bottom Left Right

Multiline Tooltip

Hover for multiline

Rich Tooltip (HTML content)

Rich tooltips use data-gk-tooltip-rich pointing to a hidden element. Supports links, formatting, and stays open on hover.

Hover for details
GridKit Tooltip

Rich tooltips support full HTML content including links, images, and interactive elements.

Learn more →

Tooltip on Buttons

Global title tooltip (GK.tip, since v1.23.0)

Any element with a title attribute gets a GridKit popup — no markup needed. Opt out with data-gk-tip-off.

Element with title Multiline Opt-out

Usage

<!-- Simple tooltip (CSS-only) -->
<span data-gk-tooltip="Hello!">Hover me</span>

<!-- Position: top (default), bottom, left, right -->
<span data-gk-tooltip="Bottom" data-gk-tooltip-pos="bottom">...</span>

<!-- Multiline -->
<span data-gk-tooltip="Long text here" data-gk-tooltip-wrap>...</span>

<!-- Rich tooltip (HTML content) -->
<span data-gk-tooltip-rich="#myTip">Hover</span>
<div id="myTip">HTML content here</div>

Changelog

1.96.0]2026-10-17
Added — a timeline: `.gk-timeline` A log of steps, one under the other: what ran, in which order, how each step ended. The SSI Panel's "Shorts" module makes a short video in several steps and has to show the person who approves it where the time and the money went and where a step broke; any admin that runs a pipeline needs the same list. Markup and classes only — the page that has the steps writes them, so there is no PHP class and no script. ```html <ol class="gk-timeline" role="list" aria-label="Steps of the run"> <li class="gk-timeline-step gk-timeline-failed"> <span class="gk-timeline-marker" aria-hidden="true"></span> <div class="gk-timeline-body"> <div class="gk-timeline-head"> <span class="gk-timeline-title">Make the pictures</span> <span class="gk-timeline-state">Failed</span> <span class="gk-timeline-time"><time datetime="2026-10-17T09:12:08">09:12:08</time> · 31 s</span> </div> <div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Image service</span></div> <p class="gk-timeline-error">The image service did not answer after 3 tries.</p> </div> </li> </ol> ``` · **A real ordered list**, named and with `role="list"`: the numbers and bullets are off (the markers and the line draw the sequence), and a screen reader that sees `list-style: none` drops the list unless the role says it is one. · **Five states, each a shape AND a word — never colour alone.** `gk-timeline-ok` (a check), `gk-timeline-failed` (a cross), `gk-timeline-running` (a ring with a gap, turning), `gk-timeline-waiting` (an empty dashed ring with a dot), `gk-timeline-skipped` (a dash) on the `<li>`; the word stands beside the title in `.gk-timeline-state`; the marker is empty and `aria-hidden`, CSS draws it. The symbols are lines and borders in the colour of the text, not fills, so `forced-colors` keeps them. `aria-current="step"` goes on the running step. · **Parts:** `.gk-timeline-head` (title, state word and time on a row; on a phone the time goes under them), `.gk-timeline-time`, `.gk-timeline-chips` (the `.gk-label` GridKit has — cost, provider — allowed to wrap), `.gk-timeline-detail`, and `.gk-timeline-error`: the message of a failed step as a tinted block with a 4px edge in the error container pair. A title, detail, message or chip with an address in it breaks inside the column from 320px up. · **Roles only, no dark rule**, so light, `[data-gk-mode="dark"]` and `.gk-dark` follow from the tokens. The word of a tone uses the text-safe variants (`--gk-success-text`, `--gk-danger-text`, `--gk-primary-text`), the marker the container pair of its tone. · **The one thing that moves** is the ring of the running step (`gk-spin`, reused); it stands still under `prefers-reduced-motion`. Text measured in a browser in all six themes and without one, light and both dark spellings, on a card and on the page, weakest value (light / dark / dark class): title, word, time and detail 5.36 / 5.62 / 5.62, the symbol on its disc 6.50 / 4.95 / 4.95 (the skipped dash and the gray chip), the error message 7.97 / 7.97 / 7.97 (WCAG AA asks 4.5:1). Added — a portrait video frame: `.gk-video-frame` A frame for a 9:16 video or picture — a short, a story, a phone screenshot. A bare `<video>` of a vertical file is as wide as its column and three screens tall. ```html <figure class="gk-video-frame gk-video-frame-safe"> <div class="gk-video-frame-media"> <video controls playsinline preload="metadata" aria-label="Version 3 of the short" src="/v/3.mp4"></video> </div> <figcaption class="gk-video-frame-caption">Version 3 · 24 s</figcaption> </figure> ``` · **The shape and its limits.** `.gk-video-frame-media` is `aspect-ratio: 9 / 16`, rounded and cut at the corners; the `<video>` or `<img>` lies IN it (absolute — a portrait file in the flow gives an aspect-ratio box a height of its own) and is shown whole (`object-fit: contain`, on black). The height is limited by `--gk-video-frame-max-h` — 75% of the window, `75dvh` with `75vh` behind it — and the frame takes its WIDTH from that limit, `min(100%, limit × 9/16)`: with `width: 100%` and a `max-height` alone, a limit that bites leaves a wide, flat box. Never wider than its column, 9:16 at every width and window height. · **No autoplay.** The documented markup has `controls` and `playsinline` and a name; the stylesheet has nothing to do with playing. · **Safe zones: `gk-video-frame-safe`.** Guide lines over the picture for the zones a platform covers with its own buttons — the top 10% and the bottom 20% — as hatching with a dashed edge. They take no room and never catch a click (the video's controls lie in the bottom zone); the two numbers are `--gk-video-safe-top` and `--gk-video-safe-bottom`, because YouTube Shorts and Instagram Reels differ. · **Nothing to show yet, and it broke: `gk-video-frame-busy`, `gk-video-frame-error`.** `.gk-empty` is reused, filling the box (its 56px of padding would not fit a frame 150px wide); the busy symbol pulses (`gk-dot-pulse`, reused) and stands still under reduced motion; the failed state is in the error colour. A box with nothing in it has a dashed edge, so it shows on a page of the same grey. A message too long for a small frame scrolls inside the box. · Colours are roles, apart from the black behind a video and the guide lines. Weakest text value of the empty states and the caption, six themes, both modes (light / dark / dark class): 5.52 / 5.62 / 5.62. Tests · `tests/timeline.test.php` (new) and `tests/video-frame.test.php` (new): the rules of each block as written above, read from the stylesheet — five different symbols, the word beside the title, roles only and no dark rule, the ring and the pulse the only motion and both stopped under reduced motion, long words, the width formula, the safe zones on request and click-through, `.gk-empty` reused, its place before the last block — the text of every state computed from the tokens, light and dark, and the documented markup (list role, hidden marker, a word in every step; a figure, `controls`, `playsinline`, no `autoplay`). `tests/css-helpers.php` holds the small readers both use. 279 and 148 assertions; the suite 7022 (6489 before). · `ci/browser-timeline-video.js` (new, 53 cases, with the fixture mode `--timeline-video`): the blocks in a browser — the accessibility tree, five distinct symbols centred in their discs, the line, the animation and its stopping, the error block, both dark spellings, forced colours, the keyboard, 360 and 320px; the frame's 9:16 at every column width and window height, the picture in the box, the zones' height, a click on the play button through the bottom zone, the empty states, a phone. · `ci/farben.js` (+62 texts, 167 in all): every text of both blocks (title, word, time, detail, chip, symbol, message; title, sentence, symbol, caption) against the ground it stands on, on a card and on the page, in every theme and mode. · `tests/blocks.test.php`: the marks window moved to 1.96 / 1.95 — the timeline and the frame are the blocks new in 1.96, the message the one changed in 1.95, the picker (1.94) has fallen out of the window. `css/blocks.json`: the blocks "Timeline" and "Portrait video frame"; the demo shows both (a sample video of 9 KB and a picture in `demo/media/`); SPEC.md, the README and the skill describe them. · Counter-checks (`scripts/gegenprobe.php` of the Panel): 74 mutations of `css/gridkit.css`, each turns the named check red — 30 in the suite, 33 in `ci/browser-timeline-video.js`, 11 in `ci/farben.js`; the order mutation (both blocks moved behind the last block) was done by hand. Three mutations first stayed green and changed things: a disc that ignored the mode (the check now compares the disc, not only its symbol), a cross whose second stroke was `display: none` (the stroke has to be drawn), and a `min-width: 0` on the body that proved dead (the grid track `minmax(0, 1fr)` does that work; the declaration, and two like it on the title and the frame, are gone). Several runs of the browser script failed on a loaded server with a timeout in `scrollIntoViewIfNeeded`; the script now waits for animation frames and for the loaded video instead of fixed pauses, and its default timeout is 20 s. ---
1.95.0]2026-10-17
Added — a small message: `.gk-message-sm` The SSI Panel put a privacy sentence above its chat as a `.gk-field-hint`: 12px grey text that read like body copy. A `.gk-message` has the look such a sentence needs — tinted ground, border, a symbol in the signal colour, `.gk-message-header` as the title — but it is too heavy for that place: padding 14px 20px and a 16px margin underneath. `.gk-message-sm` keeps the look and shrinks the box: ```html <div class="gk-message gk-message-info gk-message-sm"> <span class="material-icons" aria-hidden="true">lock</span> <div class="gk-message-content">The conversation is stored on our server and never passed on.</div> </div> ``` · **The box:** padding `10px 14px`, gap `10px`, text `--gk-text-label` (13px) over a line height of 1.45, the symbol one step down (`--gk-icon-sm`, 18px), the title 2px above its text, and **no margin** (`margin: 0`) — the surroundings set the spacing; a `gk-mb-*` on it still wins. The text may wrap: `min-width: 0` on `.gk-message-content` and `overflow-wrap: anywhere`, so a long address breaks instead of pushing out of the box. · **No colour of its own.** It takes the tone from `.gk-message-info`, `-success`, `-warning`, `-error` or none, light and dark, exactly as the message does — there is no dark rule for it. · **Its place in the file is part of the rule.** After the phone rules, because `.gk-message` gets padding `12px 14px` on a phone at the same weight and the later rule wins (on a phone the small one stays at 10px); before the margin utilities, for the same reason the other way round. · **`.gk-message-compact` is not this, and is unchanged.** It already existed (padding `10px 16px`, 13px) and keeps the 16px margin of `.gk-message`; some twenty Panel views use it and count on that margin, so taking it away would have moved all of them. The small message is a class of its own. Text on the tinted band, measured in a browser in all six themes and without one, light and both dark spellings (`[data-gk-mode="dark"]`, `.gk-dark`), with and without `themes.css` — weakest value per tone, light / dark / dark class: neutral 16.30 / 14.03 / 14.64, info 7.40 / 5.66 / 6.76, success 8.31 / 7.92 / 9.45, warning 6.11 / 8.24 / 9.76, error 7.91 / 6.93 / 8.12 (WCAG AA asks 4.5:1). The message without `-sm` is identical to the digit — the same colours — and had not been measured at all before. Tests · `tests/message-sm.test.php` (new): the box as written above and denser than `.gk-message` and `.gk-message-compact`, read from their rules; the text size a token between 12px and the body size; the rule after the phone rule of `.gk-message` and before `.gk-mb-0`; no colour, background or border in any rule of `.gk-message-sm` and no dark rule of its own; `.gk-message` and `.gk-message-compact` as they were; the dark text of every tone on its tint over each dark ground a message sits on, computed, at least 4.5:1. · `ci/farben.js`: the neutral small message in the palette check, and Text and Title of every tone — with and without `-sm` — against the band that really lies under them, in every theme and mode. · `ci/browser.js`: the small message in a browser — box, symbol, title, a long address that breaks, `gk-mb-3` that still wins, the empty announcement that still takes no room, phone width and dark mode. · `tests/blocks.test.php`: the marks speak of the last two minors, and those moved to 1.95 and 1.94 — the picker is the block new in 1.94, the message the one changed in 1.95, and the region (1.93) is now the block that has fallen out of the window and carries no mark. · `css/blocks.json`: `gk-message-sm` in the Message block; the demo shows it; SPEC.md and the skill describe it. ---
1.94.0]2026-10-12
Added — the image picker: choose one image out of many, in a compact grid `GridKit\ImagePicker` and `GK.picker`. A list of images with a full row each is fine for three and unusable for thirty; the SSI Panel built that list by hand — preview left, title, a loose button right — and with ten images a header-picture choice filled the screen (a phone: 4 700 px for thirty). The picker is a grid of 16:9 tiles, about 150 px wide, two to a row on a phone, that works like a radio group, shows the first two rows with a "Show all 30" button and opens the viewer on a large version of the picture it was given. ```php \GridKit\ImagePicker::render([ ['value' => 'a.webp', 'title' => 'Fireplace at dusk', 'thumb' => '/t/a.webp', 'full' => '/g/a.webp', 'meta' => 'Own image', 'checked' => true], ['value' => 'b.webp', 'title' => 'Mountain road', 'thumb' => '/t/b.webp', 'full' => '/g/b.webp', 'meta' => 'AI generated', 'badge' => 'AI', 'actions' => [['name' => 'remove', 'label' => 'Remove']]], ['value' => 'c.webp', 'title' => 'Not released yet', 'note' => 'Waiting for approval', 'disabled' => true], ], ['name' => 'cover', 'label' => 'Header image']); ``` · **Native radios underneath.** One `<input type="radio">` per image, drawn by its own box-shadow over the picture, so a click or a tap lands on it, the form submits its value and Tab, the arrow keys and a screen reader work without a line of script. Picking is the radio's own `change` event: a page that listened to the old list needs nothing new. The radio is named by the tile's title (`aria-labelledby`, the title is a `<label for>`, so a click on the words picks too) and described by what the caller says about the picture (`meta`, `note`). · **The state is never colour alone.** A picked tile has a check mark (a shape), a 3 px primary ring with a white inner ring that shows on a dark and on a light picture (a size) and a bold title; the others have a 1 px line. Keyboard focus is a 3 px primary outline with an offset. Disabled tiles are dimmed and say why (`note`). · **Per image:** `value`, `title`, `thumb` (the SMALL picture for the tile), `full` (the LARGE one the viewer loads when it opens; omitted, the tile has no zoom button and a placeholder), `alt`, `meta` (where it comes from), `note`, `badge` (a short word on the picture), `checked`, `disabled`, `actions` and `data`. Options: `name`, `id`, `label`, `rows` (default 2, 0 = never fold), `status`, `class`, `labels`. `html()` returns the markup, `render()` prints it, `tileHtml()` writes one tile. · **Fold by rows.** Only the first `rows` rows show, then `Show all N` / `Show fewer` (`aria-expanded`). How many tiles that is comes from the columns the grid resolved to (`gridTemplateColumns`) — nothing is laid out first to be measured — and again when the width changes. The picked tile and the one holding the focus never fold away. · **A roving tab stop for the magnifiers.** The zoom button of ONE tile — the one with the focus, else the picked one — is in the tab order; the others are `tabindex="-1"`; the arrow keys move between tiles and the viewer reaches every picture. A caption action ("Remove") stays in the tab order on EVERY tile: the focused tile is always the picked one, and an application will not let the picked image be removed, so rationing the actions left every other image's button out of the keyboard's reach (found in review, with the first draft that rationed both). · **Actions are events.** A caption button (`remove`, …) fires `gk:pickeraction` (`detail: { action, value, item, picker }`, bubbles) and does nothing else: the library never deletes. The application decides and asks "Really?" (`GK.confirm`), then calls `GK.picker.remove(picker, value)`. · **Script:** `GK.picker.init(root)` (also from `GK.initContent`), `.add(picker, item, { select, focus })` (an upload that arrived: appended, picked as a click would — one `change` — and focused), `.remove(picker, value)`, `.refresh(picker)` (after a script changed which radio is checked) and `.tileHtml(item, opts)`, which writes the same markup PHP does; `ci/browser-picker.js` compares the two. Words: `picker.*` in both languages, exported to the browser as `picker_*`, and overridable per picker (`labels`), so an application can say "Show all 30 images". · **Icons.** `zoom_in` and `image` join the inline SVG set, in PHP and in `GK.table.iconSvg` (the parity test holds the two together): a tile has no icon-font ligature that could show as a word. · **Classes (the block "Image picker" in `css/blocks.json`):** `gk-picker` (the container), `gk-picker-status`, `gk-picker-grid`, `gk-picker-item` (one tile), `gk-picker-media` (the 16:9 picture area), `gk-picker-img`, `gk-picker-ph` (the placeholder without a picture), `gk-picker-input` (the radio, drawn as the picture area), `gk-picker-check`, `gk-picker-badge`, `gk-picker-zoom`, `gk-picker-caption`, `gk-picker-title`, `gk-picker-action`, `gk-picker-note` and `gk-picker-more`. One class each, nothing nested deeper than a sibling combinator and no `!important`: an application restyles a tile with one class of its own. · **Touch and motion.** The zoom button is a `--gk-target-min` box with a 30 px chip drawn in it, and the caption action grows from 32 to `--gk-target-min` on `(pointer: coarse)`; the action sits in the caption row, below the picture, so a tap that picks never reaches it. With `prefers-reduced-motion` the ring does not ease. Changed — the lightbox can show more, and pick `GK.lightbox.show(entries, index, from)` opens the viewer on a list the caller built (`src`, `caption`, `meta`, and for a picker `choose()`, `selected()`, `disabled`, `returnTo()`); the gallery's own tiles still come in through the click handler and are the same thing with `src` and `caption`. · A second line, `.gk-lightbox-meta`, under the caption (where the picture comes from), and a row, `.gk-lightbox-actions`, with **Select this image** and a visible **Close** — only when an entry can be picked; a plain gallery shows no row. On the picture that is already picked the button says "Selected" and stays pressed; on one that cannot be picked it is disabled — and the focus moves to "Close" instead of falling onto the page behind. White on the dark overlay in every theme (the theme's primary measured 4.47:1 under white in the default light palette). · Closing gives the focus back to the zoom button of the picture last shown (after paging from 3 to 8 the place a person knows is 8), or to the radio that took the pick, falling back to the opener when the target is not on screen (a folded tile). The one `_gkRestoreFocus` is used, as before; a target other than the opener is scrolled into view (`block: "nearest"`) — after paging from 1 to 29 the ring was on a button below the fold. The dialog is named by the picture it shows (`aria-label`). `GK.lightbox.refresh()` redraws the buttons when something changed behind an open viewer; a picture that arrives meanwhile (`GK.picker.add` with `focus`) does not take the focus out from under the dialog. · New lightbox classes: `gk-lightbox-stage`, `gk-lightbox-foot`, `gk-lightbox-meta`, `gk-lightbox-actions`, `gk-lightbox-choose`, `gk-lightbox-dismiss` and `gk-lightbox-pick`. · The picture lives in a stage (`.gk-lightbox-stage`) that takes the height the foot (caption, meta, buttons) leaves, instead of a fixed `85vh`: on a phone held sideways the caption no longer covers the picture, and a viewer with a row of buttons has room. A long caption wraps instead of running out of the screen. In a picker's viewer (`.gk-lightbox-pick`) a small picture fills the stage — a 640 px upload is shown as large as the screen allows — and its shadow follows the picture (a filter); a plain gallery keeps showing natural size. Clicking the dark area around the picture still closes it. Buttons draw a white 3 px focus ring on the dark overlay. · **What changes for a plain gallery (every application that uses `.gk-gallery-item[data-lightbox]`):** behaviour is the same (open, ← →, Escape, the dark area, the focus back, no extra buttons), the layout is not. The caption is no longer a one-line pill laid over the picture's lower edge but stands under it and wraps; a large picture is bigger (1378×689 where it was 1268×634 at 1440×900) and on a phone held sideways it no longer sits under the caption. Measured against 1.93.4 at 1440×900, 390×844 and 667×375. · On a screen under 480 px high (a phone held sideways, a window at 200 % zoom) caption and buttons share one line and the origin line is dropped, so the picture keeps its room (174 px of 375 became at least 200). · `prefers-reduced-motion`: the viewer's entrance is switched off in its own block. Added — the demo has a Gallery section A "Gallery" entry in the sidebar, with the plain gallery (moved from "Navigation"), the picker with six, thirty, one and no images, a form that shows its value, a log of the events it hears, "Add a new image" (`GK.picker.add`), a remove button that asks first, the keyboard in a table and the script. The examples are `showcase()` listings: the code shown is the code that ran. Changed — the Releases page is fed by a workflow `ci/github-release.yml` (to be switched on the way `github-actions.yml` is — below) creates the GitHub Release for every pushed `v*` tag (title "GridKit <version>", the notes of that version from `ci/release-notes.sh`, a "Live demo" line) and has a button, "Run workflow" with `since` (default 1.54.0), for ONE catch-up Release for the tag of the current `VERSION`: the newest version in full and every version since as the titles of its changes. 58 tags (v1.55.0 … v1.93.4) had no Release object; the page ended at v1.54.0. Idempotent: a tag whose Release exists is left alone, the catch-up replaces the notes of an existing Release instead of adding another; the tag must equal `VERSION`; `--verify-tag` never creates a tag. One concurrency group for every start, the mode decided by the event (the button pressed on a tag is still the catch-up), `since` matched with `[[ =~ ]]` (a second line used to pass `grep -E`), no `--latest` (GitHub picks the highest version; an old hotfix tag must not become "Latest"). `ci/release-notes.sh --since` fell over GitHub's 125 000-character limit (1.54.0 → 1.93.4 is 150 000): past 120 000 bytes it now prints the newest version in full and the titles of the rest. It sits in `ci/`, not in `.github/workflows/`: the token this repository is pushed with has no `workflow` scope, `.gitignore` keeps that folder out of commits (a tracked workflow once blocked every push for three days) and a test now fails if one is tracked. `ci/README.md` has the commands to switch it on. It cannot be run from a checkout, so it is held to what it says (`tests/package.test.php`) and its YAML was parsed. Changed · `README.md`, the landing page and `GRIDKIT_SKILL.md`: seventeen components, twenty-two classes in `src/`; the skill has a section "Image picker — ImagePicker" and the component and class tables a row for it. `css/blocks.json`: block "Image picker" and the new lightbox classes, 1.94.0. Tests · `tests/picker.test.php` (new, 174 assertions): the markup (one radio per image in one group, named by its title, described by its origin, `alt=""`, lazy, inline SVG icons, the zoom button on the picture and the action in the caption row, no zoom without a large version), escaping of every value that comes from the caller (script tag, attribute break, a `data` name cut to letters, an `id` stripped), no inline style or handler, options and words as data attributes, the status line, ids unique across two pickers, `tileHtml` is what `html` is made of, the stylesheet (check mark on `:checked`, 3 px ring, 3 px focus ring, `[hidden]` really hides, finger-sized targets, `pointer: coarse`, 16:9, no `!important`, reduced motion), the script's contract (events, fold by `gridTemplateColumns`, roving tab stop, the viewer's `choose`/`returnTo`), the guide, the demo and the words in both languages. · `ci/browser-picker.js` (new, 83 cases in Chromium, outside the suite like `ci/browser.js`) on `ci/browser-fixture.php --picker`: 1, 3, 12 and 30 images at 850 px, 390 and 360 px (tiles per row, rows shown, "Show all", the picked tile beyond the fold, no sideways scroll); picking with the mouse and the keyboard (arrows skip a disabled tile, a 3 px ring, one `change`); the tab order; the viewer (opens on the large version, ←/→, Tab trap, Escape, focus back to the right zoom button, paging to a folded tile, "Select this image", "Selected" on the picked one, a click on the dark area, a small picture enlarged); `gk:pickeraction` and `GK.picker.remove`; `GK.picker.add` produces the markup PHP writes; two pickers stay apart; a plain gallery is as it was; a touch screen gets 44 px targets; dark contrast; reduced motion; a portrait picture is cropped to 16:9, not squeezed (found while writing it: an in-flow `<img>` gave the box its own height). · Found by an independent review of the first commit and fixed before release (each with a case in `ci/browser-picker.js`, 83 cases now): a caption action out of the keyboard's reach; the focus ring below the fold after paging far; the focus dropped on the page when "Select this image" disables itself under it; an upload that arrives under an open viewer taking the focus (and the viewer's button not noticing the new pick); the JS side of the escaping — `GK.picker.add`, the viewer, the status line, the dialog name — had no test that would fail (hostile text in every field, `$&` and `$$` in a title, a `data` value, an action name); `String.replace` with a title as the replacement (`$&` read as a pattern, in the status line and in the application's question); the id prefix and an icon name written raw by the script where PHP cuts and escapes them; the pick button's contrast; a stale "show all" that unfolded pictures added later; the status line naming the first of several `checked` where the browser keeps the last. · Found by that run and fixed before release: the pointer's 2 px hover ring beat the 3 px picked ring (`:hover` outranked `:checked`: a picked tile looked less picked under the mouse); the portrait tile; a title measured against black in a fixture without a page background. · Gegenproben (`scripts/gegenprobe.php`, each: break it, the test goes red, put it back byte for byte, green): the check mark on `:checked`, the roving tab stop, the focus return, Escape, the fold, the picked tile staying visible, the viewer's pick, the Tab trap, the escaping of the title, the `gk:pickeraction` event, the hover ring that must not thin a picked tile and the focus after a pick. (A guard of the picker's own for `[hidden]` was written first and taken out again: the library-wide rule for every `gk-` class — 1.92 — already covers a folded tile, and a Gegenprobe on the guard stayed green.)
1.93.4]2026-09-28
Fixed — active chips keep their colour role in dark mode In dark mode `[data-gk-mode="dark"] .gk-chip.gk-chip-active` (0,3,0) came after every chip colour (`.gk-chip-danger.gk-chip-active`, 0,2,0) and outranked it, so every active chip wore the tonal container of the chip without a colour: wherever filter chips carry colours (on the SSI Panel, receipts, expenses, banking, the chart of accounts and the website monitor), a danger, success, warning and neutral chip looked alike (found in round 29; measured in 1.93.3: one ground per theme for every active chip, danger to neutral). The chip's dark rules now stand with the chip, before its colours, and each colour names both dark spellings (`[data-gk-mode="dark"]` and `.gk-dark`) in its own selector list, at the same 0,3,0 — one rule per colour for both modes, nothing more specific than before. Danger and success write their white as a literal, as the filled buttons do: `--gk-on-primary` is dark in dark mode, and dark text on the danger fill read 2.3:1 there. In dark mode neutral carries light text, so its count takes the darker tint again; warning keeps the lighter one, as in light. Dark, all six themes and without one, both spellings, at rest and under the pointer: danger/red `#dc2626` label 4.83:1, count 6.87:1; success/green `#047857` 5.48 / 7.60; warning/orange `#fbbf24` 8.79 / 9.69; neutral 9.26-10.78 / 10.99-12.42 (was: every one of them on the tonal container, 5.99-8.21 / 7.79-9.97). Primary and blue are the chip without a colour in light and stay it in dark; the chip without a colour, the inactive chip and every light value are unchanged: 6912 computed values in 24 combinations of theme and mode, with and without `themes.css`, at rest, on hover and focused, differ only on the active colour chips in dark mode. The focus ring is the same. Tests · `tests/contrast.test.php`: an active chip with a colour role wears, in both dark spellings and under the pointer, the background and border it wears in light; label and count at least 4.5:1, the count never weaker than the label; no colour, danger, success, warning and neutral each have a ground of their own; primary, blue and the chip without a colour stay on the tonal container. The count test picks its rules by the chips they reach, no longer from a written-out list, which knew only the selectors of its day. · `ci/farben.js`: every colour role and alias, at rest and under the pointer, in all six themes and without one, light and both dark spellings: the ground is the colour of its role and never that of the chip without one, the label reads at 4.5:1, and the count never reads weaker than the label. · `tests/contrast.test.php`: "dark component rules take their TEXT colour from a role" read the stylesheet only from the marker of the dark component section on, and the chip's dark rules now stand before it (a literal written into them stayed green; review of this release). It now reads every rule whose selector list names `[data-gk-mode="dark"]` or `.gk-dark`, wherever it stands, and checks that every dark selector of the file sits in a rule it read. Two literals are allowed for one rule each, bound to selector and value: `#1f2937` on the active warning chip (no role carries dark text on amber in both modes) and the white veil `rgba(255, 255, 255, 0.78)` of the dark sidebar, which had stood outside the section all along.
1.93.3]2026-09-28
Fixed — the count in an active chip is readable `.gk-chip-count` lays a tint over its chip. On an active chip that tint was 20 % white under white text, so it took away what the accent had: the count measured 3.19–3.90:1 in light mode and 3.68–4.67:1 in dark, in every theme, on every page with `FilterChips` (SSI Panel, round 28). The tint now moves away from the text: `rgba(0, 0, 0, 0.2)` under light text, and the old white tint only under the dark text of `warning`/`orange` and `neutral` (a dark tint would put warning at 4.43:1). In dark mode every active chip carries light text (the dark chip rule outranks the chip colours), so every count takes the dark tint there. Now 6.37–7.89:1 light and 7.79–9.97:1 dark. Nothing else about a chip moved: label, ground, border and focus ring measure identical at rest, on hover and focused, and the count of an inactive chip is untouched. Changed — the hover of a filled primary, success, warning or danger button is stated once Two rules painted it: `.gk-btn-filled.gk-btn-X:hover` and the default variant `.gk-btn.gk-btn-X:not(.gk-btn-outlined):not(.gk-btn-text):not(.gk-btn-tonal):hover`, which also matches a button carrying both `.gk-btn-filled` and `.gk-btn` and wins on specificity. Both named the same colour, so a change to the filled rule changed nothing on screen (noted in 1.93.1). Background and border now stand in ONE selector list per role, where the filled rule stood; the shadow stays on `.gk-btn-filled` alone, which is also the only selector that reaches a button with `gk-btn-filled` and no `gk-btn`. Primary had the same two rules with the same colour and joined the same way. Nothing looks different: 136080 computed values (every variant and role, with and without `gk-btn`, plain, small, disabled and inside a message, at rest, on hover, focused and pressed, seven theme settings, light and both dark spellings) are identical to 1.93.2. Neutral is left as it is, on purpose: its two hover rules name DIFFERENT colours (`.gk-btn-filled.gk-btn-neutral:hover` darkens the secondary colour, the default variant mixes in 18 % of the text colour), so a neutral button looks different under the pointer depending on whether it carries `gk-btn`. Every `Button::render` button takes the default-variant colour. Which one is right is a design decision; choosing it changes what one of the two spellings looks like, so it waits for a round of its own with a measurement before and after. A comment at the rule says so. Tests · `tests/contrast.test.php`: the count of every active chip colour, at rest and under the pointer, light and both dark spellings, followed through the rules of the stylesheet — at least 4.5:1 and never weaker than its label. And exactly one rule paints the hover fill of each filled primary, success, warning and danger button, with the shadow left on the filled rule. The rules are picked by the buttons they reach — every spelling of a filled button, light and both dark spellings — not by the colour they set: picked by colour, a second hover rule with another colour went uncounted and the suite stayed green (review of this release). The cascade over every rule that reaches the button under the pointer has to end on the hover token, for background and border, shorthand and longhand alike. · `ci/farben.js`: the count of the resting, the active, the hovered and each coloured active chip, in all six themes and without one, light and both dark spellings. · `ci/browser.js`: buttons with `gk-btn-filled` and no `gk-btn` are measured too; the border of a filled button is its fill at rest and on hover; filled primary, success and danger keep their hover shadow; and the fill under the pointer is what the role's hover token computes to, in every spelling, light and dark, so whichever rule wins has to be the one that names the token.
1.93.2]2026-09-27
Fixed — a disabled button can be enabled by a script `Button::render(…, ['disabled' => true])` gave the `<button>` both the `disabled` attribute **and** a `disabled` class. The class rule (`opacity: .45; pointer-events: none`) survived `button.disabled = false`, so a button a page had enabled stayed grey and unclickable until a reload (Panel „Meine Webseite": „Veröffentlichen" after a change, 27.09.2026). The class is no longer set; the element is always a `<button disabled>` and `.gk-btn:disabled` styles it. The rule `.gk-btn.disabled` stays for pages that set the class themselves.
1.93.1]2026-09-26
Fixed — filled buttons answer the pointer and stay readable on hover · **Primary, dark mode:** the hover changed the button by 1.03:1 — nobody could see it. `--gk-primary-hover` is derived darker (`l - 0.06`, DERIVED STATE COLORS) and a dark-mode rule put `filter: brightness(1.1)` on top, so the two cancelled out. The filter is gone: 1.24:1 in dark, 1.30:1 in light. · **Success, warning, danger:** the hover of a filled button was the light ROLE tone (`--gk-success-hover` …), not a step from the dark FILL — the button got lighter under the pointer and white text dropped to 2.0–3.1:1 (danger in light barely changed at all, 1.03:1). New tokens `--gk-success-fill-hover`, `--gk-warning-fill-hover`, `--gk-danger-fill-hover`, derived one step darker than the fill, with literal fallbacks. The role hovers stay as they are for tonal and outlined buttons. Note: `.gk-btn.gk-btn-success:not(…):hover` also matches `.gk-btn-filled` buttons and wins on specificity; both rules now name the same colour. Found by the SSI Panel reviews of rc965 and round 18. `ci/browser.js` measures every filled button in both modes as painted (background through the button's own filter on a canvas, because a computed background never shows a filter): a visible step of at least 1.1:1 and text of at least 4.5:1 on the hover colour. Tests · `Table::percent('-0')` and `percent(-0.0)` show "0 %" — handled since 1.81, but no case held it: removing the line kept the suite green. ---
1.93.0]2026-09-24
Two requests from the people who use GridKit most. Martin asked to see what has changed without reading the whole changelog: marks in the demo, the way Fomantic UI marks what is new, and the news of each version in one place. And Vespera's admin said "Saved." to the eye only: its status line was written `hidden` until the first message and shown in the same step its text arrived, so the live region came into being at the moment it spoke — VoiceOver and NVDA often read nothing. Written visible and empty instead, a `.gk-message` drew an empty box. Both are fixed at the source, once. Added — an announcement that is heard: `.gk-announce`, `GK.announce()` ```html <div class="gk-message gk-message-compact gk-announce" id="saved" role="status" aria-live="polite" aria-atomic="true"></div> ``` ```js GK.announce('saved', 'Saved.', 'success'); // tone: info, success, warning, error GK.announce('saved', ''); // empty again ``` The region is on the page from the start and never hidden, so a screen reader knows it before anything is said in it. Empty, it has no box — not `display: none` or `visibility: hidden`, which would take it out of the accessibility tree again, but the clip of `.gk-sr-only`: no border, no padding and no gap in a flex column. Words bring the box back. `GK.announce()` changes only the words (as text, never HTML) and the tone of a `.gk-message`; the same words twice are no change and nothing is read, so a repeat empties the region and writes the words back 150 ms later. It takes an element, an id or a selector, adds `role="status"`, `aria-live` and `aria-atomic` when the markup forgot them, and takes `hidden` off — the next message is heard, this one may not be. `GK.init()` does the same for every `.gk-announce` on the page before anything is announced. `GK.melde()` is the same function under the name Vespera's pages already call. Added — what is new, and since when: `css/blocks.json` Every `gk-` class of both stylesheets, in the block the demo shows it as, with the release that first shipped it — 679 classes in 55 blocks, read once out of the git history: the first commit whose stylesheet has the class, counted from the last time it appeared, with the version of that commit (the tag before 1.0.0, when VERSION was still renumbered). A block's "since" is its oldest class. "changed" is the last release that changed a block without adding a class to it, kept by hand from 1.92.0 on; a new class marks its block changed by itself. A JSON file beside the stylesheet, not a PHP array or comments in the CSS: PHP, the demo, a test, an agent and a consuming project all read it without running anything; a comment beside a rule would be one of many rules a class spreads over, and a class list inside PHP could not be read by anything else. It ships with the package, because a project with GridKit in `vendor/` is who asks "does my copy have this block?". Added — marks in the demo, and the news at the top Each block's heading in the demo says "since 1.0", and a block that is new or changed in the last two minors carries a mark: "New in 1.92" in the green label, "Changed in 1.92" in the blue one — GridKit's own labels, no new colour (4.5:1 or better in both modes and every theme, measured in `ci/farben.js`). At the top of the demo a box shows the last two minors: the blocks each brought or changed, from the index, and the headings of their changelog entries, read out of this file — the demo writes no second text. A demo in German says "Neu in" and "Geändert in". The admin blocks of 1.92 each have their own heading now, so each carries its own mark, and the hand-written "v1.10" chip beside TableHeader is gone. Fixed — a line break is not empty, one heading says "since" once A review of this release before it shipped found four things, fixed in it. · **A region written with a line break between its tags drew the empty box.** "Empty" is CSS `:empty`, and that matches no child node at all — the line break a PHP or TypeScript template writes before `</div>` is a text node. The box this block exists to avoid came back, 22 px high, and `GK.init()` left it, because it only looked at an empty `textContent`. Init now empties a region that holds nothing but white space; the skill and the spec say to write nothing between the tags, because a page without the script, or before it runs, still shows the box. · **"since 1.91 since 1.91 Changed in 1.92".** A heading that shows two blocks called `since()` twice, and said so twice, to the eye and to a screen reader. `since('row-link', 'sheet')` now takes several blocks and says "since" once, for the oldest, with each mark once ("New in" wins over "Changed in" for the same release). The marks lost their left margin, which indented them when they wrapped onto a line of their own on a phone; a space between the marks makes the heading read "1.91 Changed", not "1.91Changed". · **20 classes were dated 1.27.2 that are older.** They first reached git with the commit that rescued the live 1.27.2 from the server, so the history dated them there. The changelog names where they came from: the global search (`gk-search-*`) in 1.24.0, table groups and the number format (`gk-table-group*`, `gk-num*`, `gk-td-num`) in 1.25.0, `gk-cell-sub` in 1.27.1. Two it does not name (`gk-rowpager-count`, `gk-code`) stay at 1.27.2. · **The suite was red on the commit.** `demo/_whatsnew.php` is a partial, and a leading underscore on a tracked file reads as a probe script left behind; the number below was counted before the file was tracked. It is on the list of partials now. Tests · `tests/blocks.test.php` (new, 12 tests, 1889 assertions): the index against both stylesheets in both directions (a class in neither, a class in two blocks), its versions against VERSION and the changelog, a class new in this version named in this entry, VERSION's heading as the first the demo reads, the marks (own labels, German too) and the news box as the demo renders them, every new block marked in the demo, the announcement's markup, rule and script. Confirmed by breaking what each covers — twelve breaks, twelve reds: a new class and a missing one, a stale one, VERSION 1.94.0, a class dated after VERSION, `display: none` on the empty region, no repeat, the demo region written `hidden`, the new block unmarked, a mark in a colour of its own, a heading left out of the box, the new class missing from this entry. The fixes above, seven more: two `since()` side by side again, a key named twice counted twice, "since" the newest block's instead of the oldest's, "Changed in" beside "New in" for the same release, init leaving white space in a region, the skill without the line-break rule, the partial off the list — each red. · `ci/browser.js`: 9 cases on a new fixture (`browser-fixture.php --announce`), 125 in all. The accessibility tree over the browser's protocol: empty, the region is a status in the tree and adds neither a box nor a second gap to a flex column; init repairs a hidden and a bare region; words bring the box and the tone and the tree reads them; a repeat is emptied and written back; words are text; emptied, box and tone go and the region stays; a region holding only a line break is emptied by init and draws no box (red without the fix). The fault itself is a case too: an empty `.gk-message` without the class draws a box, a hidden one is out of the tree. Five breaks (`display: none`, no clip, no repeat, no init, `innerHTML`) turned the matching cases red. · `ci/farben.js`: the marks and "since" on a card and on the page, every theme, three spellings — weakest: "New" 8.57:1 light, 6.78:1 dark; "Changed" 7.15 / 6.14; "since" 6.92 / 5.62 (`.gk-dark`). The same on the demo page itself, over every mark it renders: 8.57 / 6.78, 7.15 / 6.14, 6.92 / 5.71. · `php tests/run.php`, run on the commit: 5559 assertions, none red. ---