@picasso-ai/delivery-spike-core 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/components/Badge.astro +4 -0
  2. package/components/Greeting.tsx +3 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +8 -0
  5. package/dist/config/site-schema.d.ts +21 -0
  6. package/dist/config/site-schema.js +1 -0
  7. package/dist/config/variants.d.ts +2 -0
  8. package/dist/config/variants.js +1 -0
  9. package/dist/content.d.ts +15 -0
  10. package/dist/content.js +15 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.js +61 -0
  13. package/dist/lib/variant-css.d.ts +33 -0
  14. package/dist/lib/variant-css.js +96 -0
  15. package/dist/site.d.ts +4 -0
  16. package/dist/site.js +6 -0
  17. package/dist/split-variant-css.d.ts +15 -0
  18. package/dist/split-variant-css.js +34 -0
  19. package/package.json +56 -4
  20. package/routes/album.astro +34 -0
  21. package/src/components/card-looks/compact.css +24 -0
  22. package/src/components/card-looks/title-slide.css +50 -0
  23. package/src/components/card-looks/title-strip.css +8 -0
  24. package/src/components/menu-effects/borders-slide-in.css +43 -0
  25. package/src/components/menu-effects/bottom-dots-expand.css +31 -0
  26. package/src/components/menu-effects/bottom-line-drop.css +31 -0
  27. package/src/components/menu-effects/bottom-line-slide-in.css +30 -0
  28. package/src/components/menu-effects/circles-expand.css +52 -0
  29. package/src/components/menu-effects/opacity-change.css +22 -0
  30. package/src/components/menu-effects/text-bounce-in.css +15 -0
  31. package/src/components/menu-effects/text-grow.css +15 -0
  32. package/src/components/menu-effects/word-slide-in.css +29 -0
  33. package/src/components/portfolio/album-layouts/crop.css +41 -0
  34. package/src/components/portfolio/album-layouts/justified.css +25 -0
  35. package/src/components/portfolio/album-layouts/masonry.css +18 -0
  36. package/src/components/portfolio/album-layouts/rect.css +8 -0
  37. package/src/components/portfolio/album-layouts/square.css +8 -0
  38. package/src/components/top-patterns/carbon.css +18 -0
  39. package/src/components/top-patterns/diagonal.css +13 -0
  40. package/src/components/top-patterns/hatch.css +18 -0
  41. package/src/components/top-patterns/plain.css +7 -0
  42. package/src/components/top-patterns/radial.css +13 -0
  43. package/src/components/top-patterns/weave.css +30 -0
  44. package/src/styles/layers.css +2 -0
  45. package/styles/core.css +5 -0
  46. package/README.md +0 -3
@@ -0,0 +1,4 @@
1
+ ---
2
+ ---
3
+
4
+ <span class="spike-badge tracking-[0.42em]"><slot /></span>
@@ -0,0 +1,3 @@
1
+ export default function Greeting({ name }: { name: string }) {
2
+ return <p className="spike-greeting">React island from the core package for {name}</p>
3
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { readAlbumLayouts, selectVariantCss } from "./lib/variant-css.js";
4
+ import { loadSite } from "./site.js";
5
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
6
+ const siteRoot = process.argv[2] ?? process.cwd();
7
+ console.log(`${manifest.name}@${manifest.version} cli from ${import.meta.filename}`);
8
+ console.log(JSON.stringify(selectVariantCss(loadSite(siteRoot), readAlbumLayouts(siteRoot))));
@@ -0,0 +1,21 @@
1
+ import type { AlbumLayout } from './variants.ts';
2
+ type CardLook = 'details' | 'compact' | 'title-strip' | 'title-slide';
3
+ export type SiteConfig = {
4
+ title: string;
5
+ menu: {
6
+ animate: string;
7
+ };
8
+ topSection: {
9
+ pattern: string;
10
+ };
11
+ album: {
12
+ layout: AlbumLayout;
13
+ };
14
+ portfolio: {
15
+ card: CardLook;
16
+ };
17
+ moreDocs: Record<string, {
18
+ card: CardLook;
19
+ }>;
20
+ };
21
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare const ALBUM_LAYOUTS: readonly ["justified", "masonry", "square", "rect"];
2
+ export type AlbumLayout = (typeof ALBUM_LAYOUTS)[number];
@@ -0,0 +1 @@
1
+ export const ALBUM_LAYOUTS = ['justified', 'masonry', 'square', 'rect'];
@@ -0,0 +1,15 @@
1
+ import { z } from 'astro/zod';
2
+ export declare const defineCoreCollections: () => {
3
+ albums: import("astro/content/config").CollectionConfig<z.ZodObject<{
4
+ title: z.ZodString;
5
+ layout: z.ZodOptional<z.ZodEnum<{
6
+ justified: "justified";
7
+ masonry: "masonry";
8
+ square: "square";
9
+ rect: "rect";
10
+ }>>;
11
+ }, z.core.$strip>, import("astro/loaders").Loader>;
12
+ notes: import("astro/content/config").CollectionConfig<z.ZodObject<{
13
+ title: z.ZodString;
14
+ }, z.core.$strip>, import("astro/loaders").Loader>;
15
+ };
@@ -0,0 +1,15 @@
1
+ import { defineCollection } from 'astro:content';
2
+ import { glob } from 'astro/loaders';
3
+ import { z } from 'astro/zod';
4
+ import { ALBUM_LAYOUTS } from "./config/variants.js";
5
+ // the site's content.config.ts is one call of this builder; bases are relative to the site root
6
+ export const defineCoreCollections = () => ({
7
+ albums: defineCollection({
8
+ loader: glob({ pattern: '**/*.yaml', base: './src/content/albums' }),
9
+ schema: z.object({ title: z.string(), layout: z.enum(ALBUM_LAYOUTS).optional() }),
10
+ }),
11
+ notes: defineCollection({
12
+ loader: glob({ pattern: '**/*.mdx', base: './src/content/notes' }),
13
+ schema: z.object({ title: z.string() }),
14
+ }),
15
+ });
@@ -0,0 +1,5 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ export declare const PACKAGE_ROOT: string;
3
+ export default function deliverySpikeCore(options?: {
4
+ customCss?: string[];
5
+ }): AstroIntegration;
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import mdx from '@astrojs/mdx';
4
+ import react from '@astrojs/react';
5
+ import tailwindcss from '@tailwindcss/vite';
6
+ import { variantCssPlugin } from "./lib/variant-css.js";
7
+ import { loadSite, siteConfigFile } from "./site.js";
8
+ import { splitVariantCssPlugin } from "./split-variant-css.js";
9
+ export const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
10
+ const SITE_MODULE = 'virtual:delivery-spike/site';
11
+ const RESOLVED_SITE_MODULE = `\0${SITE_MODULE}`;
12
+ function siteModulePlugin(siteRoot, customCss) {
13
+ return {
14
+ name: 'delivery-spike-site',
15
+ resolveId: (id) => (id === SITE_MODULE ? RESOLVED_SITE_MODULE : undefined),
16
+ load(id) {
17
+ if (id !== RESOLVED_SITE_MODULE)
18
+ return undefined;
19
+ const css = customCss.map((file) => `import ${JSON.stringify(path.resolve(siteRoot, file))}`);
20
+ const { title } = loadSite(siteRoot);
21
+ return [...css, `export const title = ${JSON.stringify(title)}`].join('\n');
22
+ },
23
+ };
24
+ }
25
+ function variantPlugin(variantRoot, siteRoot) {
26
+ const shared = {
27
+ siteConfigFile: siteConfigFile(siteRoot),
28
+ loadSite: () => loadSite(siteRoot),
29
+ };
30
+ if (variantRoot === 'split')
31
+ return splitVariantCssPlugin({ packageRoot: PACKAGE_ROOT, siteRoot, ...shared });
32
+ const root = { cwd: process.cwd(), site: siteRoot, package: PACKAGE_ROOT }[variantRoot];
33
+ return variantCssPlugin({ root, ...shared });
34
+ }
35
+ export default function deliverySpikeCore(options = {}) {
36
+ return {
37
+ name: 'delivery-spike-core',
38
+ hooks: {
39
+ 'astro:config:setup': ({ config, injectRoute, updateConfig, logger }) => {
40
+ const siteRoot = fileURLToPath(config.root);
41
+ const variantRoot = (process.env.SPIKE_VARIANT_ROOT ?? 'split');
42
+ logger.info(`package root ${PACKAGE_ROOT}`);
43
+ logger.info(`site root ${siteRoot}, cwd ${process.cwd()}, variant root ${variantRoot}`);
44
+ injectRoute({
45
+ pattern: '/albums/[slug]',
46
+ entrypoint: path.join(PACKAGE_ROOT, 'routes/album.astro'),
47
+ });
48
+ updateConfig({
49
+ integrations: [mdx(), react()],
50
+ vite: {
51
+ plugins: [
52
+ tailwindcss(),
53
+ siteModulePlugin(siteRoot, options.customCss ?? []),
54
+ variantPlugin(variantRoot, siteRoot),
55
+ ],
56
+ },
57
+ });
58
+ },
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,33 @@
1
+ import type { SiteConfig } from '../config/site-schema.ts';
2
+ import { type AlbumLayout } from '../config/variants.ts';
3
+ export declare const VARIANT_CSS_DIRS: {
4
+ readonly 'menu-effect': "src/components/menu-effects";
5
+ readonly 'top-pattern': "src/components/top-patterns";
6
+ readonly 'album-layout': "src/components/portfolio/album-layouts";
7
+ readonly 'card-look': "src/components/card-looks";
8
+ };
9
+ export type VariantCssFamily = keyof typeof VARIANT_CSS_DIRS;
10
+ export type VariantCssSite = Pick<SiteConfig, 'menu' | 'topSection' | 'album' | 'portfolio' | 'moreDocs'>;
11
+ export declare function selectVariantCss(site: VariantCssSite, albums: readonly {
12
+ layout?: AlbumLayout;
13
+ }[]): Record<VariantCssFamily, string[]>;
14
+ export declare function readAlbumLayouts(root: string): {
15
+ file: string;
16
+ layout?: AlbumLayout;
17
+ }[];
18
+ export declare const isVariantCssFamily: (value: string) => value is VariantCssFamily;
19
+ type PluginContext = {
20
+ addWatchFile(id: string): void;
21
+ };
22
+ export declare function everyVariantCss(root: string): Record<VariantCssFamily, string[]>;
23
+ export declare function variantCssPlugin(options: {
24
+ root: string;
25
+ siteConfigFile: string;
26
+ loadSite: () => VariantCssSite;
27
+ everyVariant?: boolean;
28
+ }): {
29
+ name: string;
30
+ resolveId(id: string): string | undefined;
31
+ load(this: PluginContext, id: string): string | undefined;
32
+ };
33
+ export {};
@@ -0,0 +1,96 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'astro/zod';
4
+ import { parse } from 'yaml';
5
+ import { ALBUM_LAYOUTS } from "../config/variants.js";
6
+ // astro bundles CSS by the route's module graph, not by what renders, so a component importing every
7
+ // variant stylesheet would ship them all. Each owner imports `virtual:variant-css/<family>` instead, and
8
+ // the plugin expands it into imports of the selected variants only.
9
+ const VIRTUAL_PREFIX = 'virtual:variant-css/';
10
+ const RESOLVED_PREFIX = `\0${VIRTUAL_PREFIX}`;
11
+ const ALBUMS_DIR = 'src/content/albums';
12
+ export const VARIANT_CSS_DIRS = {
13
+ 'menu-effect': 'src/components/menu-effects',
14
+ 'top-pattern': 'src/components/top-patterns',
15
+ 'album-layout': 'src/components/portfolio/album-layouts',
16
+ 'card-look': 'src/components/card-looks',
17
+ };
18
+ const files = (family, names) => [...new Set(names)].sort().map((name) => `${VARIANT_CSS_DIRS[family]}/${name}.css`);
19
+ // repo-relative stylesheets per family. A family takes the union of every config place that picks it:
20
+ // `off` has no file and `details` is the card base in card.css.
21
+ export function selectVariantCss(site, albums) {
22
+ const animate = site.menu.animate;
23
+ const pattern = site.topSection.pattern;
24
+ const looks = [site.portfolio.card, ...Object.values(site.moreDocs).map((more) => more.card)];
25
+ return {
26
+ 'menu-effect': files('menu-effect', animate === 'off' ? [] : [animate]),
27
+ 'top-pattern': files('top-pattern', pattern === 'off' ? [] : [pattern]),
28
+ 'album-layout': files('album-layout', [
29
+ site.album.layout,
30
+ ...albums.flatMap((album) => (album.layout ? [album.layout] : [])),
31
+ ]),
32
+ // a title-slide card without a frame renders as title-strip (card.astro)
33
+ 'card-look': files('card-look', looks.flatMap((look) => look === 'details' ? [] : look === 'title-slide' ? [look, 'title-strip'] : [look])),
34
+ };
35
+ }
36
+ const albumLayoutSchema = z.object({ layout: z.enum(ALBUM_LAYOUTS).optional() });
37
+ // reads only `layout` of every album; the full content snapshot is validated elsewhere
38
+ export function readAlbumLayouts(root) {
39
+ const directory = path.resolve(root, ALBUMS_DIR);
40
+ return readdirSync(directory, { recursive: true, encoding: 'utf8' })
41
+ .filter((name) => /\.ya?ml$/.test(name))
42
+ .sort()
43
+ .map((name) => {
44
+ const file = path.join(directory, name);
45
+ const result = albumLayoutSchema.safeParse(parse(readFileSync(file, 'utf8')));
46
+ if (!result.success)
47
+ throw new Error(`${file}: ${result.error.message}`);
48
+ return { file, ...result.data };
49
+ });
50
+ }
51
+ export const isVariantCssFamily = (value) => Object.hasOwn(VARIANT_CSS_DIRS, value);
52
+ const imports = (root, selected) => selected.length === 0
53
+ ? 'export {}'
54
+ : selected.map((file) => `import ${JSON.stringify(path.resolve(root, file))}`).join('\n');
55
+ // every stylesheet of a family, shared ones included, for the E2E harness that renders unselected variants
56
+ export function everyVariantCss(root) {
57
+ const every = (family) => files(family, readdirSync(path.resolve(root, VARIANT_CSS_DIRS[family]))
58
+ .filter((name) => name.endsWith('.css'))
59
+ .map((name) => name.slice(0, -'.css'.length)));
60
+ return {
61
+ 'menu-effect': every('menu-effect'),
62
+ 'top-pattern': every('top-pattern'),
63
+ 'album-layout': every('album-layout'),
64
+ 'card-look': every('card-look'),
65
+ };
66
+ }
67
+ // `loadSite` reads the config through the single loader (lib never imports it) and `siteConfigFile`
68
+ // is watched so a site.yaml or album edit re-selects the files in dev. `everyVariant` serves the E2E
69
+ // harness: its own imports would precede the owners' stylesheets (Astro orders page-level CSS first) and
70
+ // lose the equal-specificity overrides, so the variants ride the owners' imports instead.
71
+ export function variantCssPlugin(options) {
72
+ return {
73
+ name: 'variant-css',
74
+ resolveId(id) {
75
+ if (!id.startsWith(VIRTUAL_PREFIX))
76
+ return undefined;
77
+ const family = id.slice(VIRTUAL_PREFIX.length);
78
+ if (!isVariantCssFamily(family))
79
+ throw new Error(`unknown variant CSS family: ${family}`);
80
+ return `${RESOLVED_PREFIX}${family}`;
81
+ },
82
+ load(id) {
83
+ if (!id.startsWith(RESOLVED_PREFIX))
84
+ return undefined;
85
+ const family = id.slice(RESOLVED_PREFIX.length);
86
+ if (options.everyVariant)
87
+ return imports(options.root, everyVariantCss(options.root)[family]);
88
+ this.addWatchFile(options.siteConfigFile);
89
+ // only the album-layout module depends on the albums, so only it re-selects on their edits
90
+ const albums = family === 'album-layout' ? readAlbumLayouts(options.root) : [];
91
+ for (const album of albums)
92
+ this.addWatchFile(album.file);
93
+ return imports(options.root, selectVariantCss(options.loadSite(), albums)[family]);
94
+ },
95
+ };
96
+ }
package/dist/site.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { SiteConfig } from './config/site-schema.ts';
2
+ export declare const SITE_CONFIG_FILE = "site.yaml";
3
+ export declare const siteConfigFile: (siteRoot: string) => string;
4
+ export declare const loadSite: (siteRoot: string) => SiteConfig;
package/dist/site.js ADDED
@@ -0,0 +1,6 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+ export const SITE_CONFIG_FILE = 'site.yaml';
5
+ export const siteConfigFile = (siteRoot) => path.resolve(siteRoot, SITE_CONFIG_FILE);
6
+ export const loadSite = (siteRoot) => parse(readFileSync(siteConfigFile(siteRoot), 'utf8'));
@@ -0,0 +1,15 @@
1
+ import { type VariantCssSite } from './lib/variant-css.ts';
2
+ type PluginContext = {
3
+ addWatchFile(id: string): void;
4
+ };
5
+ export declare function splitVariantCssPlugin(options: {
6
+ packageRoot: string;
7
+ siteRoot: string;
8
+ siteConfigFile: string;
9
+ loadSite: () => VariantCssSite;
10
+ }): {
11
+ name: string;
12
+ resolveId(id: string): string | undefined;
13
+ load(this: PluginContext, id: string): string | undefined;
14
+ };
15
+ export {};
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ import { isVariantCssFamily, readAlbumLayouts, selectVariantCss, } from "./lib/variant-css.js";
3
+ const VIRTUAL_PREFIX = 'virtual:variant-css/';
4
+ const RESOLVED_PREFIX = `\0${VIRTUAL_PREFIX}`;
5
+ // the verbatim plugin resolves both the variant stylesheets and the albums from one `root`; here the
6
+ // stylesheets come from the installed package and the albums from the consumer site
7
+ export function splitVariantCssPlugin(options) {
8
+ return {
9
+ name: 'variant-css-split',
10
+ resolveId(id) {
11
+ if (!id.startsWith(VIRTUAL_PREFIX))
12
+ return undefined;
13
+ const family = id.slice(VIRTUAL_PREFIX.length);
14
+ if (!isVariantCssFamily(family))
15
+ throw new Error(`unknown variant CSS family: ${family}`);
16
+ return `${RESOLVED_PREFIX}${family}`;
17
+ },
18
+ load(id) {
19
+ if (!id.startsWith(RESOLVED_PREFIX))
20
+ return undefined;
21
+ const family = id.slice(RESOLVED_PREFIX.length);
22
+ this.addWatchFile(options.siteConfigFile);
23
+ const albums = family === 'album-layout' ? readAlbumLayouts(options.siteRoot) : [];
24
+ for (const album of albums)
25
+ this.addWatchFile(album.file);
26
+ const selected = selectVariantCss(options.loadSite(), albums)[family];
27
+ if (selected.length === 0)
28
+ return 'export {}';
29
+ return selected
30
+ .map((file) => `import ${JSON.stringify(path.resolve(options.packageRoot, file))}`)
31
+ .join('\n');
32
+ },
33
+ };
34
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@picasso-ai/delivery-spike-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Throwaway delivery-channel spike package. Not for use.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/picasso/core-delivery-spike.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./content": {
18
+ "types": "./dist/content.d.ts",
19
+ "default": "./dist/content.js"
20
+ },
21
+ "./components/*": "./components/*",
22
+ "./core.css": "./styles/core.css"
23
+ },
24
+ "bin": {
25
+ "delivery-spike-core": "./dist/cli.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "components",
30
+ "routes",
31
+ "styles",
32
+ "src/components",
33
+ "src/styles"
34
+ ],
35
+ "peerDependencies": {
36
+ "astro": "^7.1.1",
37
+ "react": "^19.2.7",
38
+ "react-dom": "^19.2.7"
39
+ },
40
+ "dependencies": {
41
+ "@astrojs/mdx": "^7.0.3",
42
+ "@astrojs/react": "^6.0.1",
43
+ "@tailwindcss/vite": "^4.3.3",
44
+ "tailwindcss": "^4.3.3",
45
+ "yaml": "^2.9.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^24.0.0",
49
+ "@types/react": "^19.2.17",
50
+ "astro": "7.1.1",
51
+ "react": "19.2.7",
52
+ "react-dom": "19.2.7",
53
+ "typescript": "^6.0.3"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc -p tsconfig.json"
57
+ }
58
+ }
@@ -0,0 +1,34 @@
1
+ ---
2
+ import 'virtual:variant-css/album-layout'
3
+ import 'virtual:variant-css/card-look'
4
+ import 'virtual:variant-css/menu-effect'
5
+ import 'virtual:variant-css/top-pattern'
6
+
7
+ import { getCollection, getEntry, render } from 'astro:content'
8
+ import { title } from 'virtual:delivery-spike/site'
9
+
10
+ import Badge from '../components/Badge.astro'
11
+ import Greeting from '../components/Greeting.tsx'
12
+
13
+ export async function getStaticPaths() {
14
+ const albums = await getCollection('albums')
15
+ return albums.map((album) => ({ params: { slug: album.id }, props: { album } }))
16
+ }
17
+
18
+ const { album } = Astro.props
19
+ const note = await getEntry('notes', album.id)
20
+ const rendered = note ? await render(note) : undefined
21
+ const Content = rendered?.Content
22
+ ---
23
+
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <title>{album.data.title} · {title}</title>
28
+ </head>
29
+ <body data-layout={album.data.layout ?? 'site-default'}>
30
+ <h1 class="core-route-heading">{album.data.title}</h1>
31
+ <Greeting name={title} />
32
+ {Content && <Content components={{ Badge }} />}
33
+ </body>
34
+ </html>
@@ -0,0 +1,24 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* Only a Card with an explicit presentation sets these modifiers, so other collections are unaffected.
5
+ * Two lines at most so the strip does not cover the photo. */
6
+ .card--title-strip .card__title,
7
+ .card--title-slide .card__title {
8
+ display: -webkit-box;
9
+ overflow: hidden;
10
+ margin: 0;
11
+ font-size: 18px;
12
+ line-height: 1.3;
13
+ -webkit-box-orient: vertical;
14
+ -webkit-line-clamp: 2;
15
+ line-clamp: 2;
16
+ }
17
+
18
+ @media (max-width: 767px) {
19
+ .card--title-strip .card__title,
20
+ .card--title-slide .card__title {
21
+ font-size: 16px;
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,50 @@
1
+ @import '../../styles/layers.css';
2
+ @import './compact.css';
3
+
4
+ @layer components {
5
+ /* Dark translucent plate with white text in both themes: a theme-colored plate gets lost on a light
6
+ * sky, while this one stays ≈ 5.5:1 even on a white photo. Visible by default so touch needs no extra
7
+ * tap; hidden at rest only for a fine hover pointer. */
8
+ .card--title-slide .card__media-frame {
9
+ position: relative;
10
+ overflow: hidden;
11
+ }
12
+
13
+ .card__overlay {
14
+ position: absolute;
15
+ inset-inline: 0;
16
+ bottom: 0;
17
+ padding: 10px 16px;
18
+ background-color: color-mix(in srgb, var(--color-overlay) 62%, transparent);
19
+ }
20
+
21
+ .card--title-slide .card__title,
22
+ .card--title-slide:hover .card__title,
23
+ .card--title-slide:focus-visible .card__title {
24
+ color: var(--color-on-image);
25
+ }
26
+
27
+ /* The moving plate is feedback enough; dimming the photo too would be redundant. */
28
+ .card--title-slide:hover .card__img,
29
+ .card--title-slide:focus-visible .card__img {
30
+ opacity: 1;
31
+ }
32
+
33
+ @media (hover: hover) and (pointer: fine) {
34
+ .card--title-slide .card__overlay {
35
+ transform: translateY(100%);
36
+ transition: transform var(--duration-base) var(--ease-out);
37
+ }
38
+
39
+ .card--title-slide:hover .card__overlay,
40
+ .card--title-slide:focus-visible .card__overlay {
41
+ transform: none;
42
+ }
43
+ }
44
+
45
+ @media (prefers-reduced-motion: reduce) {
46
+ .card--title-slide .card__overlay {
47
+ transition: none;
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,8 @@
1
+ @import '../../styles/layers.css';
2
+ @import './compact.css';
3
+
4
+ @layer components {
5
+ .card--title-strip .card__body {
6
+ padding-inline: 16px;
7
+ }
8
+ }
@@ -0,0 +1,43 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* Top and bottom borders slide toward the text. */
5
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link {
6
+ padding-top: 10px;
7
+ padding-bottom: 10px;
8
+ font-weight: var(--font-weight-semibold);
9
+ text-shadow: none;
10
+ }
11
+
12
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link::before,
13
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link::after {
14
+ content: '';
15
+ position: absolute;
16
+ left: 0;
17
+ width: 100%;
18
+ height: 2px;
19
+ background-color: var(--menu-effect-color);
20
+ opacity: 0;
21
+ transition:
22
+ opacity var(--duration-base) linear,
23
+ transform var(--duration-base) linear;
24
+ }
25
+
26
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link::before {
27
+ top: 0;
28
+ transform: translateY(-10px);
29
+ }
30
+
31
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link::after {
32
+ bottom: 0;
33
+ transform: translateY(10px);
34
+ }
35
+
36
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link:hover::before,
37
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link:hover::after,
38
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link:focus-visible::before,
39
+ nav[aria-label='Primary'].anim-borders-slide-in .top-menu-link:focus-visible::after {
40
+ opacity: 1;
41
+ transform: translateY(0);
42
+ }
43
+ }
@@ -0,0 +1,31 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* A dot splits into three. */
5
+ nav[aria-label='Primary'].anim-bottom-dots-expand .top-menu-link {
6
+ padding-bottom: 5px;
7
+ transition: color calc(var(--duration-base) * 2) var(--ease-out);
8
+ }
9
+
10
+ nav[aria-label='Primary'].anim-bottom-dots-expand .top-menu-link::before {
11
+ content: '•';
12
+ position: absolute;
13
+ top: 100%;
14
+ left: 50%;
15
+ color: transparent;
16
+ font-size: 1.2em;
17
+ transform: translateX(-50%);
18
+ transition:
19
+ text-shadow var(--duration-base) linear,
20
+ color var(--duration-base) linear;
21
+ pointer-events: none;
22
+ }
23
+
24
+ nav[aria-label='Primary'].anim-bottom-dots-expand .top-menu-link:hover::before,
25
+ nav[aria-label='Primary'].anim-bottom-dots-expand .top-menu-link:focus-visible::before {
26
+ color: var(--menu-effect-color);
27
+ text-shadow:
28
+ 15px 0 var(--menu-effect-color),
29
+ -15px 0 var(--menu-effect-color);
30
+ }
31
+ }
@@ -0,0 +1,31 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* The underline grows down from the top. */
5
+ nav[aria-label='Primary'].anim-bottom-line-drop .top-menu-link {
6
+ padding-bottom: 10px;
7
+ }
8
+
9
+ nav[aria-label='Primary'].anim-bottom-line-drop .top-menu-link::after {
10
+ content: '';
11
+ position: absolute;
12
+ top: 100%;
13
+ left: 0;
14
+ width: 100%;
15
+ height: 1px;
16
+ background: var(--menu-effect-color);
17
+ opacity: 0;
18
+ transform: translateY(-10px);
19
+ transition:
20
+ height var(--duration-base) linear,
21
+ opacity var(--duration-base) linear,
22
+ transform var(--duration-base) linear;
23
+ }
24
+
25
+ nav[aria-label='Primary'].anim-bottom-line-drop .top-menu-link:hover::after,
26
+ nav[aria-label='Primary'].anim-bottom-line-drop .top-menu-link:focus-visible::after {
27
+ height: 5px;
28
+ opacity: 1;
29
+ transform: translateY(0);
30
+ }
31
+ }
@@ -0,0 +1,30 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* The underline slides in from below. */
5
+ nav[aria-label='Primary'].anim-bottom-line-slide-in .top-menu-link {
6
+ padding-top: 10px;
7
+ padding-bottom: 10px;
8
+ }
9
+
10
+ nav[aria-label='Primary'].anim-bottom-line-slide-in .top-menu-link::after {
11
+ content: '';
12
+ position: absolute;
13
+ top: 100%;
14
+ left: 0;
15
+ width: 100%;
16
+ height: 5px;
17
+ background: var(--menu-effect-color);
18
+ opacity: 0;
19
+ transform: translateY(10px);
20
+ transition:
21
+ opacity var(--duration-base) linear,
22
+ transform var(--duration-base) linear;
23
+ }
24
+
25
+ nav[aria-label='Primary'].anim-bottom-line-slide-in .top-menu-link:hover::after,
26
+ nav[aria-label='Primary'].anim-bottom-line-slide-in .top-menu-link:focus-visible::after {
27
+ opacity: 1;
28
+ transform: translateY(0);
29
+ }
30
+ }
@@ -0,0 +1,52 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* Two circles expand from the center. */
5
+ nav[aria-label='Primary'].anim-circles-expand .top-menu-label {
6
+ position: relative;
7
+ }
8
+
9
+ nav[aria-label='Primary'].anim-circles-expand .top-menu-label::before,
10
+ nav[aria-label='Primary'].anim-circles-expand .top-menu-label::after {
11
+ content: '';
12
+ position: absolute;
13
+ top: 50%;
14
+ left: 50%;
15
+ width: 50px;
16
+ height: 50px;
17
+ border: 2px solid var(--menu-effect-color);
18
+ border-radius: 50%;
19
+ opacity: 0;
20
+ transform: translate(-50%, -50%) scale(0.2);
21
+ transition:
22
+ opacity var(--duration-base) linear,
23
+ transform var(--duration-base) linear;
24
+ pointer-events: none;
25
+ }
26
+
27
+ nav[aria-label='Primary'].anim-circles-expand .top-menu-label::after {
28
+ width: 40px;
29
+ height: 40px;
30
+ border-width: 5px;
31
+ transform: translate(-50%, -50%) scale(0.8);
32
+ }
33
+
34
+ /* In light this effect alone draws the circles in translucent black, not the hover color, matching the
35
+ * legacy site. */
36
+ [data-theme='light'] nav[aria-label='Primary'].anim-circles-expand .top-menu-label::before,
37
+ [data-theme='light'] nav[aria-label='Primary'].anim-circles-expand .top-menu-label::after {
38
+ border-color: color-mix(in srgb, var(--color-shade) 15%, transparent);
39
+ }
40
+
41
+ nav[aria-label='Primary'].anim-circles-expand .top-menu-link:hover .top-menu-label::before,
42
+ nav[aria-label='Primary'].anim-circles-expand .top-menu-link:hover .top-menu-label::after,
43
+ nav[aria-label='Primary'].anim-circles-expand
44
+ .top-menu-link:focus-visible
45
+ .top-menu-label::before,
46
+ nav[aria-label='Primary'].anim-circles-expand
47
+ .top-menu-link:focus-visible
48
+ .top-menu-label::after {
49
+ opacity: 1;
50
+ transform: translate(-50%, -50%) scale(1);
51
+ }
52
+ }
@@ -0,0 +1,22 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* A dimmed link turns fully opaque. */
5
+ nav[aria-label='Primary'].anim-opacity-change .top-menu-link {
6
+ opacity: 0.6;
7
+ transition:
8
+ opacity var(--duration-medium) var(--ease-cubic),
9
+ color var(--duration-medium) var(--ease-cubic);
10
+ }
11
+
12
+ nav[aria-label='Primary'].anim-opacity-change .top-menu-link:hover,
13
+ nav[aria-label='Primary'].anim-opacity-change .top-menu-link:focus-visible {
14
+ opacity: 1;
15
+ }
16
+
17
+ @media (prefers-reduced-motion: reduce) {
18
+ nav[aria-label='Primary'].anim-opacity-change .top-menu-link {
19
+ opacity: 1;
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,15 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* The text grows with a bounce. */
5
+ nav[aria-label='Primary'].anim-text-bounce-in .top-menu-label {
6
+ display: inline-block;
7
+ transform: scale(1);
8
+ transition: transform var(--duration-medium) cubic-bezier(0.47, 2.02, 0.31, -0.36);
9
+ }
10
+
11
+ nav[aria-label='Primary'].anim-text-bounce-in .top-menu-link:hover .top-menu-label,
12
+ nav[aria-label='Primary'].anim-text-bounce-in .top-menu-link:focus-visible .top-menu-label {
13
+ transform: scale(1.08);
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* The text grows smoothly. */
5
+ nav[aria-label='Primary'].anim-text-grow .top-menu-label {
6
+ display: inline-block;
7
+ transform: scale(1);
8
+ transition: transform var(--duration-base) var(--ease-out);
9
+ }
10
+
11
+ nav[aria-label='Primary'].anim-text-grow .top-menu-link:hover .top-menu-label,
12
+ nav[aria-label='Primary'].anim-text-grow .top-menu-link:focus-visible .top-menu-label {
13
+ transform: scale(1.1);
14
+ }
15
+ }
@@ -0,0 +1,29 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* A copy of the word from attr(data-hover) slides in from below. */
5
+ nav[aria-label='Primary'].anim-word-slide-in .top-menu-link {
6
+ overflow: hidden;
7
+ height: 1.1em;
8
+ line-height: 1.1em;
9
+ }
10
+
11
+ nav[aria-label='Primary'].anim-word-slide-in .top-menu-label {
12
+ position: relative;
13
+ display: inline-block;
14
+ transition: transform var(--duration-base) linear;
15
+ }
16
+
17
+ nav[aria-label='Primary'].anim-word-slide-in .top-menu-label::before {
18
+ position: absolute;
19
+ top: 110%;
20
+ left: 0;
21
+ content: attr(data-hover);
22
+ font-weight: var(--font-weight-semibold);
23
+ }
24
+
25
+ nav[aria-label='Primary'].anim-word-slide-in .top-menu-link:hover .top-menu-label,
26
+ nav[aria-label='Primary'].anim-word-slide-in .top-menu-link:focus-visible .top-menu-label {
27
+ transform: translateY(-110%);
28
+ }
29
+ }
@@ -0,0 +1,41 @@
1
+ @import '../../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* The minimum cell width makes `auto-fill` give min(columns, max(minColumns, floor((W + gap) /
5
+ * (240 + gap)))) columns, the same formula as `sizes` (lib/portfolio/sizes.ts) and Gallery. The
6
+ * viewer shows the uncropped photo. */
7
+ .album-square,
8
+ .album-rect {
9
+ --album-tile-max: calc(
10
+ (100% - (var(--album-columns) - 1) * var(--album-gap)) / var(--album-columns)
11
+ );
12
+ --album-tile-min: calc(
13
+ (100% - (var(--album-min-columns) - 1) * var(--album-gap)) / var(--album-min-columns)
14
+ );
15
+
16
+ display: grid;
17
+ grid-template-columns: repeat(
18
+ auto-fill,
19
+ minmax(max(var(--album-tile-max), min(240px, var(--album-tile-min))), 1fr)
20
+ );
21
+ gap: var(--album-gap, 12px);
22
+ margin: 0;
23
+ padding: 0;
24
+ list-style: none;
25
+ }
26
+
27
+ /* The anchor holds the cell ratio: <img> carries the photo's inline aspect-ratio (no CLS), and with
28
+ * width and height both set aspect-ratio no longer applies, so the image fills the cell height. */
29
+ :is(.album-square__item, .album-rect__item) .album-photo {
30
+ overflow: hidden;
31
+ }
32
+
33
+ :is(.album-square__item, .album-rect__item) :is(.album-photo__media, .album-photo__img) {
34
+ height: 100%;
35
+ }
36
+
37
+ :is(.album-square__item, .album-rect__item) .album-photo__img {
38
+ object-fit: cover;
39
+ object-position: var(--album-focal, 50% 50%);
40
+ }
41
+ }
@@ -0,0 +1,25 @@
1
+ @import '../../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* photo-item.astro sets flex-grow ∝ aspect-ratio and flex-basis ∝ aspect-ratio × row height inline per
5
+ * photo, which gives every photo in a row the same height without cropping. The `::after` with a large
6
+ * flex-grow takes the slack only on the incomplete last row, so its photos keep the target height. */
7
+ .album-justified {
8
+ display: flex;
9
+ flex-wrap: wrap;
10
+ gap: var(--justified-gap, 12px);
11
+ margin: 0;
12
+ padding: 0;
13
+ list-style: none;
14
+ }
15
+
16
+ .album-justified::after {
17
+ content: '';
18
+ flex-grow: 1000000;
19
+ flex-basis: 0;
20
+ }
21
+
22
+ .album-justified__item {
23
+ min-width: 0;
24
+ }
25
+ }
@@ -0,0 +1,18 @@
1
+ @import '../../../styles/layers.css';
2
+
3
+ @layer components {
4
+ /* Photos run top to bottom by column; DOM, keyboard and viewer order stay the album order. */
5
+ .album-masonry {
6
+ column-width: var(--album-col-width, 300px);
7
+ column-count: var(--album-max-columns, 5);
8
+ column-gap: var(--album-gap, 12px);
9
+ margin: 0;
10
+ padding: 0;
11
+ list-style: none;
12
+ }
13
+
14
+ .album-masonry__item {
15
+ break-inside: avoid;
16
+ margin-bottom: var(--album-gap, 12px);
17
+ }
18
+ }
@@ -0,0 +1,8 @@
1
+ @import '../../../styles/layers.css';
2
+ @import './crop.css';
3
+
4
+ @layer components {
5
+ .album-rect__item .album-photo {
6
+ aspect-ratio: 3 / 2;
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ @import '../../../styles/layers.css';
2
+ @import './crop.css';
3
+
4
+ @layer components {
5
+ .album-square__item .album-photo {
6
+ aspect-ratio: 1;
7
+ }
8
+ }
@@ -0,0 +1,18 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ .top-section--pattern-carbon .top-section__pattern::after {
5
+ background:
6
+ radial-gradient(
7
+ color-mix(in srgb, var(--color-shade) 15%, transparent) 30%,
8
+ transparent 40%
9
+ )
10
+ 0 0,
11
+ radial-gradient(
12
+ color-mix(in srgb, var(--color-shade) 15%, transparent) 30%,
13
+ transparent 40%
14
+ )
15
+ 3px 3px;
16
+ background-size: 6px 6px;
17
+ }
18
+ }
@@ -0,0 +1,13 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ .top-section--pattern-diagonal .top-section__pattern::after {
5
+ background: repeating-linear-gradient(
6
+ -25deg,
7
+ color-mix(in srgb, var(--color-shade) 20%, transparent),
8
+ color-mix(in srgb, var(--color-shade) 20%, transparent) 3px,
9
+ color-mix(in srgb, var(--color-shade) 10%, transparent) 3px,
10
+ color-mix(in srgb, var(--color-shade) 10%, transparent) 6px
11
+ );
12
+ }
13
+ }
@@ -0,0 +1,18 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ .top-section--pattern-hatch .top-section__pattern::after {
5
+ background:
6
+ radial-gradient(
7
+ transparent 40%,
8
+ color-mix(in srgb, var(--color-shade) 20%, transparent) 41%
9
+ )
10
+ 0 0,
11
+ radial-gradient(
12
+ transparent 40%,
13
+ color-mix(in srgb, var(--color-shade) 20%, transparent) 41%
14
+ )
15
+ 3px 3px;
16
+ background-size: 6px 6px;
17
+ }
18
+ }
@@ -0,0 +1,7 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ .top-section--pattern-plain .top-section__pattern::after {
5
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAEAQMAAACTPww9AAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMAtyOW6xcAAAAQSURBVAjXYxBgUGBwYGgAAAJYAPF9gAMvAAAAAElFTkSuQmCC);
6
+ }
7
+ }
@@ -0,0 +1,13 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ .top-section--pattern-radial .top-section__pattern::after {
5
+ background: repeating-radial-gradient(
6
+ circle,
7
+ color-mix(in srgb, var(--color-shade) 10%, transparent),
8
+ color-mix(in srgb, var(--color-shade) 10%, transparent) 2px,
9
+ transparent 2px,
10
+ transparent 5px
11
+ );
12
+ }
13
+ }
@@ -0,0 +1,30 @@
1
+ @import '../../styles/layers.css';
2
+
3
+ @layer components {
4
+ .top-section--pattern-weave .top-section__pattern::after {
5
+ background:
6
+ linear-gradient(
7
+ 45deg,
8
+ color-mix(in srgb, var(--color-on-image) 10%, transparent) 12%,
9
+ transparent 0,
10
+ transparent 88%,
11
+ color-mix(in srgb, var(--color-on-image) 10%, transparent) 0
12
+ ),
13
+ linear-gradient(
14
+ 135deg,
15
+ transparent 37%,
16
+ color-mix(in srgb, var(--color-shade) 15%, transparent) 0,
17
+ color-mix(in srgb, var(--color-shade) 15%, transparent) 63%,
18
+ transparent 0
19
+ ),
20
+ linear-gradient(
21
+ 45deg,
22
+ transparent 37%,
23
+ color-mix(in srgb, var(--color-on-image) 10%, transparent) 0,
24
+ color-mix(in srgb, var(--color-on-image) 10%, transparent) 63%,
25
+ transparent 0
26
+ )
27
+ color-mix(in srgb, var(--color-shade) 20%, transparent);
28
+ background-size: 5px 5px;
29
+ }
30
+ }
@@ -0,0 +1,2 @@
1
+ /* Synthetic cascade-layer order for the variant stylesheets copied into the spike. */
2
+ @layer properties, theme, base, vendor, components, utilities;
@@ -0,0 +1,5 @@
1
+ @import 'tailwindcss';
2
+
3
+ /* The package lives in node_modules, which Tailwind's automatic detection skips. */
4
+ @source '../components';
5
+ @source '../routes';
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.