- TypeScript 67.7%
- Vue 17.4%
- JavaScript 8.5%
- CSS 6.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
`brand.css` holds the charter as delivered — colour ramps, gradients, type scale, radii, shadows and motion — and is excluded from Prettier so it stays diffable against the `design-tokens.json` it is generated from. `main.css` bridges those tokens onto Nuxt UI's `--ui-*` variables and `app.config.ts` names the palette behind each semantic colour, so components keep using `primary`, `secondary`, `info` and `neutral` rather than raw palette names. Two mappings need stating explicitly: Nuxt UI picks shade 500 for light mode while the charter's brand colours are the 600 step, and its dark palette is a neutral grey where the charter calls for the near-black indigo of the logo. The page now sits on the charter surface with cards on the raised one, in both modes. Headings use Space Grotesk, body text Inter and code JetBrains Mono, all self-hosted; the login title carries the spectrum gradient, one per screen as the charter asks; and a global `:focus-visible` rule paints the aqua ring at 2px with 2px offset, unlayered so no component can drop it. Three deviations from the token file, each for a reason: - the numeric `--spacing-*` scale is exposed as `--brand-space-*` instead. In Tailwind v4 it would redefine `p-5`…`p-10` while `p-11` and beyond kept the 0.25rem step, making `py-8` (4rem) larger than `py-12` (3rem); - the neutral ramp travels as `ink`, because Nuxt UI routes the name `neutral` to its own internal palette; - `info` uses aqua 900 and `success` emerald 600: the charter's aqua 700 is 2.75:1 on white — its own contrast table marks it insufficient — and #0E9F6E is 3.39:1, both below AA for the text Nuxt UI paints with them. `warning` and `error` land on the charter's exact values. |
||
| .serena | ||
| app | ||
| scripts | ||
| server | ||
| shared | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| .npmrc | ||
| .prettierignore | ||
| .prettierrc.json | ||
| CLAUDE.md | ||
| eslint.config.mjs | ||
| nuxt.config.ts | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| playwright.direct.config.ts | ||
| README.md | ||
| task.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
GL Dashboard
A delivery dashboard over the GitLab REST API v4 (Epics → Stories → Tasks, Milestones and a milestone calendar). Built with Nuxt 4, Nuxt UI, Pug and TypeScript.
Branding
The visual identity comes from the brand charter, kept as delivered in
app/assets/css/brand.css (generated from the
charter's design-tokens.json, and excluded from Prettier so it stays diffable
against its source). app/assets/css/main.css bridges those tokens onto Nuxt UI,
and app/app.config.ts names the palettes each semantic colour uses.
| Charter | Where it lands |
|---|---|
| indigo / violet / magenta / aqua / neutral ramps | primary, secondary, info, neutral — components only ever use semantic names |
| Space Grotesk, Inter, JetBrains Mono | headings, body, code — self-hosted by @nuxt/fonts |
| Type scale, radii, shadows, motion | Tailwind's --text-*, --radius-*, --shadow-*, --duration-* |
| Light/dark surfaces | --ui-bg (raised, cards) and --ui-bg-muted (page) in both modes |
| Aqua focus ring, 2px, offset 2px | a global :focus-visible rule, unlayered so components cannot drop it |
spectrum gradient |
the login title — one signature gradient per screen, as the charter asks |
Three points where the integration deviates from the token file, each for a reason:
- Spacing. The charter's
--spacing-*scale is exposed as--brand-space-*instead. In Tailwind v4 it would redefinep-5…p-10whilep-11and beyond kept the 0.25rem step, sopy-8(4rem) would end up larger thanpy-12(3rem). - Neutral. Nuxt UI routes the name
neutralto its own internal palette, so the charter's neutral ramp travels asinkand is wired to--ui-color-neutral-*explicitly. - Two colours are shifted for contrast.
infouses aqua 900 rather than the charter's aqua 700, which its own contrast table marks insufficient (2.75:1);successuses emerald 600, since #0E9F6E is 3.39:1 on white and Nuxt UI paints badge text with it.warninganderrorland on the charter's exact values.
To update the identity, regenerate brand.css from the charter and re-check the
bridge in main.css.
Access token scopes
Reading the dashboard needs read_api. Starring an epic or a story writes a
⭐ emoji reaction back to GitLab, which needs the api scope — that is the
only write the dashboard performs. With a read_api token everything still
renders and the star buttons are disabled, with a tooltip saying why.
Stars and "My epics/stories"
A star is not stored in the dashboard: it is the ⭐ (star) emoji reaction on
the epic or issue in GitLab, so it is visible in GitLab itself and follows you
across browsers and deployments.
- Starred epics lead the Epics page in their own group.
- My epics/stories lists every epic and story in the base group carrying any reaction of yours — starred ones first, then anything you reacted to with another emoji.
- Epics and stories can be laid out boxed (cards) or inline (compact rows); the choice is remembered in a cookie.
Reactions are read with GitLab's my_reaction_emoji filter on the epics and
issues endpoints — a couple of requests per load, not one per epic and story.
Support link
Set NUXT_PUBLIC_SUPPORT_URL to a payment/donation link (e.g. a Stripe payment
link) to show a small "Support this project" button in the bottom-right corner,
next to the "Made with ❤️ by Alchemic.studio" credit. Leave it unset and the
button is not rendered; only http(s) URLs are accepted.
NUXT_PUBLIC_SUPPORT_URL="https://buy.stripe.com/…" npm run generate
In proxy mode the variable is read when the server starts. A static
npm run generate build has no server, so the value is baked in at build
time — set it on the build command, as above.
Analytics and cookie consent
Analytics is off unless NUXT_PUBLIC_ANALYTICS_ID holds a GA4 measurement id
(G-…). With no id there is no tracking and no cookie banner at all.
NUXT_PUBLIC_ANALYTICS_ID="G-XXXXXXXXXX" npm run generate
When an id is set, nuxt-gtag
is used, but nothing reaches Google until the visitor accepts:
- the module's own
gtag.idis left empty andinitModeismanual, so its plugin never bootstraps and no script is injected on its own; - a consent bar asks first, with Accept and Decline given equal weight;
- on accept, Consent Mode v2 defaults are queued (
analytics_storage: granted, every advertising signaldenied— this dashboard does not advertise) and only then isgtag.jsinjected, so the very first hit is already a consented one; - the answer is stored for a year in the first-party
gl-cookie-consentcookie and re-applied on later visits without asking again; - a "Cookies" link next to the credit reopens the choice; withdrawing stops
collection (
ga-disable-<id>), sends adeniedconsent update and clears the_gacookies.
The dashboard's own storage — the gl_session cookie (proxy mode), the
gl-dashboard:session entry in sessionStorage (direct mode) and the
gl-view-mode layout cookie — is what makes the app work and is not part of the
analytics consent.
Loading feedback
Long reads (the delivery tree, milestones, reactions) show skeletons plus a tiny
status line saying what is being read. In browser-direct mode that line follows
the actual GitLab calls (stories for epic 3 of 12, linking tasks · 7 of 11 stories); in proxy mode the whole aggregation is one server request, so the page
shows its own coarse label instead. The refresh button in the top bar re-reads
all three.
Deployment modes
The dashboard runs in either of two modes. It picks one at runtime by probing
/api/auth/session once, so you deploy the build that matches your host and the
app adapts.
| Mode | How GitLab is reached | Where the token lives | Needs |
|---|---|---|---|
| Proxy (recommended, default) | Nitro server routes (/api/**) call GitLab server-side |
Sealed httpOnly session cookie | A Node process |
| Direct (static hosting) | The browser calls https://<instance>/api/v4/** itself |
sessionStorage, for that tab only |
GitLab reachable from the browser, over the same scheme |
Force a mode with NUXT_PUBLIC_GITLAB_MODE=proxy|direct; the default is auto.
Proxy mode — build + Node server
npm install
npm run build # produces ./.output (server + client)
npm run start # = node .output/server/index.mjs → http://localhost:3000
To run it on another machine, copy the whole .output/ folder there (Node
20+ required) and run node .output/server/index.mjs. Open the printed URL,
enter your GitLab instance URL + a personal access token, and log in.
Set a strong session secret in production (≥ 32 chars):
NUXT_SESSION_PASSWORD="<a-long-random-string>" node .output/server/index.mjs
Behind nginx, proxy the whole app to that process:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; # so the session cookie gets `Secure`
}
Direct mode — static hosting, no server
npm run generate # = NUXT_PUBLIC_GITLAB_MODE=direct nuxt generate
Upload .output/public/ to any static host and serve it as an SPA:
root /var/www/gl-dashboard;
location / {
try_files $uri $uri/ /index.html;
}
What this trades away, and what it requires:
- The personal access token is held in the browser tab's
sessionStorage(cleared when the tab closes) instead of a server-side session. The login screen says so. - Same scheme both ways: an https page cannot call an
http://GitLab — the browser blocks it. The login form rejects that combination with an explanation rather than letting it fail as a generic network error. - The GitLab instance must answer cross-origin API calls. GitLab does this for
/api/v4/**out of the box (Access-Control-Allow-Origin: *,private-tokenallowed, pagination headers exposed) — check any reverse proxy in front of it does not strip those headers. - The instance must be reachable from the browser, not just from your server (a LAN-only instance will not work for remote users).
- Chrome logs
404s forGET /groups/:id/epicson Community Edition (the tree probe, plus the two reaction filters). Those probes are deliberate — they are how the dashboard detects that native Epics need Premium/Ultimate — and in direct mode the browser makes them, so they show up in the console. - Starring works in this mode too: GitLab's CORS policy allows
POST/DELETEon/api/v4/**with theprivate-tokenheader.
Develop
npm run dev # http://localhost:3000 with HMR (proxy mode)
npm run lint # ESLint
npm run typecheck # vue-tsc
npm run test # Vitest unit tests
npm run test:e2e # Playwright E2E against the dev server (proxy mode)
npm run test:e2e:direct # Playwright E2E against a browser-direct SPA server
Test data
npm run seed provisions a representative dataset on the test instance and mints
an api-scoped token — see scripts/README.md.