chore(01-design-tokens-rams-foundation-01): commit design baseline — Rams docs, mockups, agent skills

- Root Rams design docs (DESIGN.md, IDEAS.md) and GSD install-fix notes (GSD_FIX.md, gsd-fix.sh)
- Four Telemetry Rams mockup screens + telemetry_rams/DESIGN.md (canonical token frontmatter)
- .agents/ project skills (design-taste-frontend) + skills-lock.json
- .gitignore: node_modules/dist/env secrets/OS junk; .planning stays local-only; Zone.Identifier scrubbed (9 files deleted)
This commit is contained in:
araharah-executor
2026-10-22 07:33:04 +08:00
parent d4ee39d08b
commit 506ff68976
54 changed files with 39105 additions and 0 deletions
@@ -0,0 +1,28 @@
/* tasteskill / scenes / stage.css — the held stage under every scroll scene (references/scenes.md).
Without .is-live (no script, a failed CDN, reduced motion, a phone set to 'rest') a scene is an ordinary
section showing its finished composition. With it, the section is (hold + 1) screens tall and the
stage sticks to the viewport for the hold. */
.scene { position: relative; }
.scene > .scene-stage { position: relative; }
html.js .scene.is-live { height: calc((var(--hold, 2) + 1) * 100svh); }
html.js .scene.is-live > .scene-stage {
position: sticky;
top: 0;
height: 100svh;
overflow: clip; /* clip, not hidden: hidden would make the stage a scroll container */
contain: layout paint;
}
/* Everything a scene moves is composited; nothing it moves is laid out again. */
.scene.is-live [data-move] { will-change: transform, opacity; }
/* Lenis */
html.is-smooth { scroll-behavior: auto; }
html.is-smooth body { overscroll-behavior: none; }
@media (prefers-reduced-motion: reduce) {
html.js .scene.is-live { height: auto; }
html.js .scene.is-live > .scene-stage { position: relative; height: auto; }
}
@@ -0,0 +1,133 @@
// tasteskill / scenes / stage.js — the shared stage every scroll scene runs on (references/scenes.md).
//
// A scene is a tall section whose stage sticks to the viewport while the page scrolls past it; a GSAP
// timeline is scrubbed by that scroll. The page always scrolls natively (no wheel capture), the
// scrollbar always tells the truth, and the finished composition is what reduced motion, no script and
// a failed CDN all show.
//
// Load at the end of <body>, in this order (versions pinned):
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script> (only if a scene splits text)
// <script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script> (optional smoothing)
// <script src="assets/scenes/stage.js"></script>
// <script src="assets/scenes/<scene>.js"></script>
// and keep the one-line <script>document.documentElement.classList.add('js')</script> in <head>.
//
// Stage.smooth() weighted wheel scrolling (Lenis), off for touch and reduced motion
// Stage.scene(root, { hold, scrub, build, phone, rest })
// Stage.split(el, 'lines' | 'words' | 'chars') masked split text, SplitText when loaded
// Stage.ready() resolves when fonts and the scenes' pictures are ready
// Stage.init() starts every <section class="scene" data-scene="<id>"> on the page
//
// A page: Stage.smooth(); Stage.init();
(() => {
const html = document.documentElement;
const mq = (q) => window.matchMedia(q).matches;
const live = !!(window.gsap && window.ScrollTrigger);
if (live) gsap.registerPlugin(ScrollTrigger);
if (live && window.SplitText) gsap.registerPlugin(SplitText);
const Stage = {
live,
get reduced() { return mq('(prefers-reduced-motion: reduce)'); },
get phone() { return mq('(max-width: 767px)'); },
get touch() { return mq('(pointer: coarse)'); },
lenis: null,
scenes: [],
};
// Weighted wheel scrolling. lerp 0.1-0.14 feels heavy without lagging behind the hand; touch keeps the
// phone's own scrolling, and reduced motion keeps the browser's.
Stage.smooth = ({ lerp = 0.12 } = {}) => {
if (!live || !window.Lenis || Stage.reduced || Stage.touch || Stage.lenis) return Stage.lenis;
const lenis = new Lenis({ lerp, smoothWheel: true, anchors: true, autoRaf: false });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
html.classList.add('is-smooth');
Stage.lenis = lenis;
return lenis;
};
// One scroll scene.
// root <section class="scene"> holding one <div class="scene-stage">
// hold screens of scroll the stage is held for (1-4); the section is (hold + 1) screens tall
// scrub true = locked to the scrollbar; a number = seconds of catch-up (0.3-0.8 reads as weight)
// build(tl, ctx) add the tweens to tl; the timeline runs 0 → 1 over the hold. ctx: { root, stage, phone, q }
// phone 'same' (default) | 'rest' (the phone gets the finished composition) | { hold, build }
// rest(ctx) optional: anything extra the finished composition needs (most scenes need nothing)
// Sets --p (0-1) and data-beat on root, so CSS and captions can follow the scene too.
Stage.scene = (root, opts = {}) => {
if (!root) return null;
const { hold = 2, scrub = true, build, phone = 'same', rest, beats = 0 } = opts;
const stage = root.querySelector(':scope > .scene-stage') || root.firstElementChild;
const q = (sel) => [...root.querySelectorAll(sel)];
const toRest = (ctx) => { root.classList.remove('is-live'); root.classList.add('is-rest'); root.style.removeProperty('--hold'); if (rest) rest(ctx); };
if (!live) { toRest({ root, stage, phone: Stage.phone, q }); return null; }
const mm = gsap.matchMedia();
mm.add({ motion: '(prefers-reduced-motion: no-preference)', phone: '(max-width: 767px)' }, (c) => {
const isPhone = c.conditions.phone;
const ctx = { root, stage, phone: isPhone, q };
const plan = isPhone ? (phone === 'rest' ? null : phone === 'same' ? { hold, build } : { hold: phone.hold ?? hold, build: phone.build ?? build }) : { hold, build };
if (!c.conditions.motion || !plan || !plan.build) { toRest(ctx); return () => root.classList.remove('is-rest'); }
root.classList.remove('is-rest');
root.classList.add('is-live');
root.style.setProperty('--hold', plan.hold);
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: root, start: 'top top', end: 'bottom bottom', scrub, invalidateOnRefresh: true,
onUpdate: (self) => {
root.style.setProperty('--p', self.progress.toFixed(4));
if (beats) root.dataset.beat = String(Math.min(beats - 1, Math.floor(self.progress * beats)));
},
},
});
plan.build(tl, ctx);
// pad the timeline to exactly 1 so positions in build() read as fractions of the hold
if (tl.duration() < 1) tl.to({}, { duration: 1 - tl.duration() }, tl.duration());
return () => { root.classList.remove('is-live'); root.style.removeProperty('--hold'); root.style.removeProperty('--p'); };
});
Stage.scenes.push(mm);
return mm;
};
// Masked split text. Lines rise out of their own mask; words and characters are for short headings.
Stage.split = (el, type = 'lines') => {
if (!el) return [];
if (window.SplitText && live) {
const s = SplitText.create(el, { type: type === 'lines' ? 'lines' : type === 'words' ? 'lines,words' : 'lines,words,chars', mask: 'lines', aria: 'auto', autoSplit: false });
return type === 'lines' ? s.lines : type === 'words' ? s.words : s.chars;
}
// fallback: words in inline-block spans, the full text kept for screen readers
if (!el.dataset.split) {
el.setAttribute('aria-label', el.textContent.trim());
el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => `<span aria-hidden="true" style="display:inline-block">${w}</span>`).join(' ');
el.dataset.split = 'words';
}
return [...el.children];
};
// Fonts and the scenes' pictures decide every height a trigger measures; refresh once they are in.
Stage.ready = () => {
const imgs = [...document.querySelectorAll('.scene img')].map((i) => (i.complete ? null : i.decode().catch(() => {}))).filter(Boolean);
return Promise.all([document.fonts ? document.fonts.ready : null, ...imgs]).then(() => { if (live) ScrollTrigger.refresh(); });
};
// Every <section class="scene" data-scene="<id>" data-options='{"hold":3}'> on the page, started by
// the scene file of that id (assets/scenes/<id>.js registers Scenes['<id>']).
Stage.init = () => {
for (const el of document.querySelectorAll('.scene[data-scene]')) {
if (el.__scene) continue;
const make = window.Scenes[el.dataset.scene];
if (!make) { console.warn('No scene file loaded for data-scene="' + el.dataset.scene + '"'); continue; }
let o = {}; try { o = JSON.parse(el.dataset.options || '{}'); } catch {}
el.__scene = make(el, o) || true;
}
};
window.Scenes = window.Scenes || {};
window.Stage = Stage;
if (live) { window.addEventListener('load', () => Stage.ready()); }
})();