npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@my-eyes/core

v0.6.0

Published

Design tokens, component styles and headless behaviours shared by every my-eyes frontend

Readme

my-eyes

A Tailwind component library and admin scaffolding for Laravel.

Think of it as a better Bootstrap: a small set of well-behaved components, semantic theme colours you swap in one place, light and dark out of the box, and an admin shell that is genuinely usable on a phone.

Live showcase →

CI Packagist npm PHP License


Why

Most component libraries make you choose between a design system you cannot change and a pile of utility classes you have to re-type on every project. my-eyes takes a third route:

  • Colours are roles, not hues. Components reference primary-600, never blue-600. Rebranding is remapping six roles in your own CSS.
  • One design system, many frameworks. The look lives in CSS and the behaviour in framework-free TypeScript, so Blade, Livewire, Vue and React render the same markup instead of four drifting copies.
  • Utilities always win. Component styles sit in @layer components, below Tailwind's utilities, so class="me-btn me-btn--primary bg-emerald-600" comes out emerald. No !important, no fighting the framework.
  • Server-first. The table sorts, filters and paginates through the query string — linkable URLs, working back button, results before any JS loads.

Contents

Requirements

| | | |---|---| | PHP | 8.2+ | | Laravel | 11 or 12 | | Tailwind CSS | 4 |

Install

composer require marcioelias/my-eyes
npm install @my-eyes/core

resources/css/app.css:

@import 'tailwindcss';
@import '@my-eyes/core/css';

resources/js/app.js:

import { startMyEyes } from '@my-eyes/core'

startMyEyes()

That is the whole setup. startMyEyes() wires every behaviour and re-runs itself after Livewire or Turbo swap DOM, so the same components work unchanged across all three.

No bundler? Use the standalone build instead — it starts itself and exposes window.myEyes:

<script src="/vendor/my-eyes/my-eyes.min.js"></script>

Theming

Six roles, each a full 50–950 scale: primary, secondary, success, danger, warning, info. Rebrand by remapping them:

@import 'tailwindcss';
@import '@my-eyes/core/css';

@theme {
    --color-primary-50:  var(--color-violet-50);
    --color-primary-600: var(--color-violet-600);
    --color-primary-700: var(--color-violet-700);
    /* ...the rest of the scale */
}

Point them at a Tailwind palette or at your own oklch() values. Shape is tunable too:

:root {
    --me-radius: 0.5rem;
    --me-sidebar-width: 16rem;
    --me-topbar-height: 3.5rem;
}

One definition per token

Light and dark values are declared once, side by side, with light-dark():

--me-surface: light-dark(var(--color-slate-50), var(--color-slate-950));

Switching themes flips color-scheme; there is no second palette to keep in sync, so the two can never drift apart. Tinted surfaces (outline buttons, alerts, the active nav item) are derived with color-mix() against the current surface, which is why no component stylesheet contains a dark-mode block.

Colour modes

Three modes — system (the default), light and dark. System follows the OS, so a first-time visitor gets whatever their machine asks for.

<x-me::theme-toggle />   {{-- cycles system → light → dark → system --}}
<x-me::theme-menu />     {{-- explicit three-option picker --}}

The icon shows the mode that is selected, so "system" stays distinguishable from an explicit choice that happens to match the OS right now. The bundled layouts apply the stored mode before first paint, so the page never flashes.

Components

Formsbutton, input, numeric, textarea, select, select-field, checkbox, radio, switch, upload, field

UIalert, badge, avatar, card, dropdown, modal, tooltip, toasts, progress, progress-ring, icon, brand, theme-toggle, theme-menu, user-menu, nav.*

Datatable, filters, pagination

Every form component resolves its own validation error, old input and label:

<x-me::input name="email" type="email" :label="__('Email')" required />

type="password" gets a reveal button automatically.

Numeric input

Renders two inputs: a visible one formatted for the user's locale and a hidden one carrying the raw value — so the server never parses 1.234,56.

<x-me::numeric name="price" :label="__('Price')" prefix="R$" :decimals="2" :min="0" />

Two selects, on purpose

<x-me::select> wraps the native element and gets the platform picker on mobile. Use it for a plain list of values.

<x-me::select-field> renders its own list, for multiple selection, search, descriptions, groups or disabled options:

<x-me::select-field
    name="tags"
    :label="__('Tags')"
    multiple
    :selected="['php']"
    :options="[
        ['value' => 'php', 'label' => 'PHP', 'group' => 'Backend'],
        ['value' => 'go',  'label' => 'Go',  'disabled' => true],
        ['value' => 'vue', 'label' => 'Vue', 'description' => 'Composition API'],
    ]"
/>

A flat ['php' => 'PHP'] map works too. Values post through hidden inputs, so the field submits like any other. On phones the list becomes a bottom sheet — a dropdown pinned under its trigger is hard to reach and gets covered by the keyboard.

Data table

Sorting, search, advanced filters, page size and pagination, all driven by the query string. The controller declares; the view renders.

use MyEyes\Filters\FilterType;
use MyEyes\Table\Column;
use MyEyes\Table\Table;

$table = Table::make(Order::query()->with('customer'), [
    Column::make('reference', __('Reference'))->sortable()->searchable(),
    Column::make('customer.name', __('Customer'))->searchable(),

    Column::make('status', __('Status'))
        ->filterable(FilterType::Select, ['paid' => __('Paid'), 'pending' => __('Pending')])
        ->format(fn (string $value) => view('cells.status', ['status' => $value])),

    Column::make('total_cents', __('Total'))
        ->sortable()
        ->numeric()
        ->format(fn (int $cents) => 'R$ '.number_format($cents / 100, 2, ',', '.')),

    Column::make('created_at', __('Placed'))->sortable()->filterable(FilterType::Date),
])->defaultSort('created_at', 'desc');

return view('orders.index', ['table' => $table]);
<x-me::table :table="$table" striped />

Column

| | | |---|---| | make($key, $label = null) | Label defaults to a humanised key | | sortable() | Adds the sort link and allows the column in ORDER BY | | searchable() | Includes it in the quick search box | | filterable(FilterType, $options = []) | Offers it in the filter builder | | numeric() | Right-aligns and uses tabular figures | | align('start'\|'center'\|'end') | | | field('orders.total') | Database column, when it differs from the key | | format(fn ($value, $row) => …) | String, Htmlable or a view | | view('cells.status') | Renders the cell through a Blade view | | sortUsing / filterUsing / searchUsing | Take over for relations and joins |

Table

| | | |---|---| | make($query, $columns) | Eloquent builder, query builder or relation | | defaultSort($key, $direction) | | | perPage($n) / perPageOptions([…]) | | | name('orders') | Namespaces the query keys, for two tables on one page | | forRequest($request) | Mostly for tests |

<x-me::table> takes striped, compact, :search, :filters, :per-page, plus empty and actions slots.

Escape hatches

Anything that is not a plain column takes a closure, and the rest of the table keeps working:

Column::make('customer', __('Customer'))
    ->sortable()
    ->sortUsing(fn ($query, string $direction) => $query
        ->orderBy(Customer::select('name')->whereColumn('id', 'orders.customer_id'), $direction))
    ->searchUsing(fn ($query, string $term) => $query
        ->orWhereHas('customer', fn ($c) => $c->where('name', 'like', "%{$term}%")));

Advanced filters

Field / operator / value rows, joined by one and/or conjunction chosen on the second row. Operators come from the column's FilterType, so a date column offers ranges and a select offers "is one of".

Why one conjunction instead of one per row? Mixing them in a flat list is ambiguous: A or B and C reads as A or (B and C) in SQL but as (A or B) and C to most people. Resolving that honestly needs nested groups with visible parentheses; until the UI has those, one conjunction is the truthful option.

Only columns declared filterable() can be filtered, and only with operators their own type offers. A crafted URL asking to filter on password, or to run contains against a select, is dropped rather than executed. The same applies to sorting. User-typed % and _ are escaped so they match literally.

Dates are filtered by whole days. An <input type="date"> submits 2026-01-31 with no time, which against a datetime column means midnight — so a naive between silently drops everything recorded during the final day. Each operator is widened to the day's real boundaries instead: between is inclusive at both ends, is matches anywhere inside the day, after starts once the day is over. A value that already carries a time is used verbatim.

Filters live in the URL:

?filters[0][field]=status&filters[0][operator]=eq&filters[0][values][0]=paid&conjunction=or

Confirmation modal

Built on the native <dialog>, so the backdrop, Escape handling, focus trapping and page inertness come from the browser rather than from JavaScript.

<x-me::button variant="danger" data-me-modal-open="delete-order">
    {{ __('Delete') }}
</x-me::button>

<x-me::modal
    id="delete-order"
    variant="danger"
    :title="__('Delete this order?')"
    :confirm="__('Delete order')"
    :cancel="__('Keep it')"
    :action="route('orders.destroy', $order)"
    method="DELETE"
>
    {{ __('Every line item goes with it. This cannot be undone.') }}
</x-me::modal>
  • confirm alone gives a single acknowledge button; add cancel for the pair.
  • variant colours the icon and the confirm button together, so a destructive confirmation cannot end up with a friendly blue button.
  • action makes confirming submit a form (CSRF and method spoofing included).
  • static refuses dismissal by backdrop or Escape — pair it with a cancel.
  • Cancel takes initial focus, so a stray Enter dismisses rather than confirms.

Toasts

return back()->with('success', __('Order created'));
return back()->with('toast', [
    'variant' => 'danger',
    'title'   => __('Upload failed'),
    'text'    => __('The file was rejected.'),
    'duration' => 0,          // stays until dismissed
]);
myEyes.toast({ variant: 'success', title: 'Saved', text: 'Your changes are live.' })

Drop the container once in your layout: <x-me::toasts position="top-end" />. Positions are top|bottom × start|center|end; bottom stacks reverse so the newest sits nearest the edge.

The progress bar is the timer. The countdown is a CSS animation and the dismissal is scheduled from that animation ending — the bar can never disagree with when the toast closes, and hovering pauses both.

Tooltip

<x-me::tooltip text="{{ __('Delete order') }}" placement="top">
    <x-me::button variant="ghost" icon="x" />
</x-me::tooltip>

One element is created for the whole page and positioned against the viewport, so it is never clipped by an overflow: hidden ancestor — the usual way tooltips break inside tables. It flips when it would run off screen, and the arrow keeps pointing at the trigger. Shown on hover and keyboard focus.

Progress

<x-me::progress :value="72" variant="success" label="Uploading" show-value />
<x-me::progress variant="primary" size="sm" />              {{-- indeterminate --}}
<x-me::progress-ring :value="64" variant="success" size="lg" />

Translations

English and pt-BR ship in the box, under the my-eyes:: namespace:

php artisan vendor:publish --tag=my-eyes-lang

Three files: ui.php (components, layout chrome, error pages), filters.php (table and filter builder), auth.php (starter kit screens).

Some strings are rendered by the browser, not by Blade — a toast's close button, upload validation, the filter builder's labels. <x-me::translations /> emits the current locale's copies as JSON (inside a script tag, so no relaxed CSP is needed) and the bindings pick them up. The bundled layouts already include it.

For Vue, React or Inertia, hand the same array to the client:

Inertia::share('myEyesMessages', fn () => \MyEyes\Support\Messages::forJavaScript());
import { configureMessages } from '@my-eyes/core'

configureMessages(page.props.myEyesMessages)

A test asserts the PHP and TypeScript key sets stay identical, so a message added on one side without the other fails CI rather than silently falling back.

Admin layout

<x-me::layouts.admin :heading="__('Dashboard')">
    <x-slot:nav>
        <x-me::nav.section>
            <x-me::nav.item :href="route('dashboard')" icon="layout-dashboard">
                {{ __('Dashboard') }}
            </x-me::nav.item>

            <x-me::nav.group :label="__('Settings')" icon="settings">
                <x-me::nav.subitem :href="route('settings.general')">
                    {{ __('General') }}
                </x-me::nav.subitem>
            </x-me::nav.group>
        </x-me::nav.section>
    </x-slot:nav>

    <x-slot:user>
        <x-me::user-menu :name="auth()->user()->name" :email="auth()->user()->email" />
    </x-slot:user>

    {{-- page content --}}
</x-me::layouts.admin>

Below 1024px the sidebar is a drawer; above it, a column that collapses to an icon rail (remembered between visits). Groups containing the current page open themselves.

Starter kit pages

Login, register, forgot/reset password, verify email, confirm password, dashboard and profile — plus 401, 403, 404, 419, 429, 500 and 503.

They are views only: no routes, controllers or migrations. They target the conventional Laravel route names (login, password.request, profile.edit), so publish them over a Breeze or Fortify install and the wiring is already there. my-eyes deliberately does not ship an auth backend.

Publishing

php artisan vendor:publish --tag=my-eyes-config      # config/my-eyes.php
php artisan vendor:publish --tag=my-eyes-lang        # translations
php artisan vendor:publish --tag=my-eyes-errors      # error pages
php artisan vendor:publish --tag=my-eyes-pages       # starter kit screens
php artisan vendor:publish --tag=my-eyes-components  # override components

Development

composer install && npm install

composer test          # Pest, against a real in-memory database
composer lint          # Pint
composer analyse       # PHPStan level 5
composer check         # all three

npm run typecheck      # strict tsc over the core
npm run build          # emit dist/ and the standalone bundle
npm run css:build      # compile the stylesheet through Tailwind

php playground/build.php   # render the showcase to playground/index.html

The showcase is generated from the real Blade components against a real database, so it cannot drift from what the package produces. CI rebuilds it on every push.

Roadmap

Blade + JS ✅ → Livewire → Vue → React. The point of keeping the design system in CSS and the behaviour in framework-free TypeScript is that each of those is a thin markup layer, not a rewrite.

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md.

License

MIT © Márcio Elias